Files
super-auto-pets-board-game/web/src/anim.ts
T

174 lines
6.0 KiB
TypeScript

import { useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, RefObject } from 'react'
// useCardAnimations animates a keyed row of cards laid out in a flex container:
//
// • MOVE — when the order changes (dragging to reorder) or a neighbor is
// added/removed, the surviving cards glide from their old slot to the new
// one using the FLIP technique (measure, invert, play).
// • ENTER — a card that appears animates in. `data-enter="flip"` reads as the
// card being flipped face-up off the deck; `data-enter="pop"` springs it in.
// • EXIT — a card that disappears is pinned where it sat and shrinks away as
// a "ghost" (the live element is already gone, so React can't hold it), then
// is dropped once the exit finishes. Meanwhile the MOVE pass slides its
// former neighbors into the gap it left.
//
// Only children carrying `data-flip-key` take part; placeholders (empty shop
// slots) are ignored. The consumer renders `ghosts` inside the same container.
// Honors prefers-reduced-motion by recording positions but skipping animation.
const MOVE_MS = 300
const ENTER_FLIP_MS = 440
const ENTER_POP_MS = 320
const EXIT_MS = 280
const MOVE_EASE = 'cubic-bezier(0.34, 1.4, 0.5, 1)'
const ENTER_EASE = 'cubic-bezier(0.2, 0.8, 0.3, 1)'
const STAGGER_MS = 55
export interface Ghost<T> {
key: string
item: T
style: CSSProperties
}
interface Tracked<T> {
rect: DOMRect
item: T
}
function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
!!window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
)
}
function enterFrames(style: string): Keyframe[] {
if (style === 'flip') {
return [
{ transform: 'perspective(700px) rotateY(-95deg)', opacity: 0, offset: 0 },
{ opacity: 1, offset: 0.4 },
{ transform: 'perspective(700px) rotateY(0deg)', opacity: 1, offset: 1 },
]
}
// 'pop'
return [
{ transform: 'scale(0.4) translateY(16px)', opacity: 0 },
{ transform: 'scale(1) translateY(0)', opacity: 1 },
]
}
// `animate` can be turned off transiently (e.g. while a drag is in progress) to
// reorder instantly without gliding; positions are still recorded so the next
// animated change starts from the right place.
export function useCardAnimations<T>(
items: T[],
keyOf: (item: T) => string,
animate = true,
): { containerRef: RefObject<HTMLDivElement | null>; ghosts: Ghost<T>[] } {
const containerRef = useRef<HTMLDivElement | null>(null)
// Positions + items from the previous committed layout, keyed by card id.
const prev = useRef<Map<string, Tracked<T>>>(new Map())
// In-flight move animations, so a fast drag can cancel-and-replace instead of
// stacking transforms on the same card.
const moving = useRef<Map<string, Animation>>(new Map())
const [ghosts, setGhosts] = useState<Ghost<T>[]>([])
// Re-run only when membership or order actually changes — plain re-renders
// (an opponent acting, connector re-measurement) leave the signature alone.
const signature = items.map(keyOf).join('')
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
const itemByKey = new Map(items.map((i) => [keyOf(i), i]))
const kids = container.querySelectorAll<HTMLElement>('[data-flip-key]')
const measured = new Map<string, { rect: DOMRect; el: HTMLElement; enter: string }>()
kids.forEach((el) => {
const key = el.dataset.flipKey
if (key) measured.set(key, { rect: el.getBoundingClientRect(), el, enter: el.dataset.enter ?? 'none' })
})
const commit = () => {
const next = new Map<string, Tracked<T>>()
measured.forEach((m, key) => {
const item = itemByKey.get(key)
if (item !== undefined) next.set(key, { rect: m.rect, item })
})
prev.current = next
}
if (!animate || prefersReducedMotion()) {
commit()
return
}
const before = prev.current
let enterIndex = 0
// ENTER + MOVE, in DOM order (so the enter stagger runs left to right).
measured.forEach((m, key) => {
const was = before.get(key)
if (!was) {
if (m.enter !== 'none') {
m.el.animate(enterFrames(m.enter), {
duration: m.enter === 'flip' ? ENTER_FLIP_MS : ENTER_POP_MS,
delay: Math.min(enterIndex, 8) * STAGGER_MS,
easing: ENTER_EASE,
fill: 'backwards',
})
}
enterIndex++
return
}
const dx = was.rect.left - m.rect.left
const dy = was.rect.top - m.rect.top
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
moving.current.get(key)?.cancel()
const anim = m.el.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
{ duration: MOVE_MS, easing: MOVE_EASE },
)
moving.current.set(key, anim)
anim.onfinish = () => {
if (moving.current.get(key) === anim) moving.current.delete(key)
}
}
})
// EXIT: keys that were present last commit but are gone now. Pin a ghost at
// the old spot (relative to the container, which is position: relative) and
// let CSS shrink it away.
const cRect = container.getBoundingClientRect()
const newGhosts: Ghost<T>[] = []
before.forEach((was, key) => {
if (measured.has(key)) return
newGhosts.push({
key,
item: was.item,
style: {
position: 'absolute',
left: was.rect.left - cRect.left + container.scrollLeft,
top: was.rect.top - cRect.top + container.scrollTop,
width: was.rect.width,
height: was.rect.height,
margin: 0,
pointerEvents: 'none',
},
})
})
if (newGhosts.length) {
setGhosts((g) => [...g, ...newGhosts])
const gone = new Set(newGhosts.map((g) => g.key))
window.setTimeout(() => setGhosts((list) => list.filter((x) => !gone.has(x.key))), EXIT_MS + 40)
}
commit()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signature])
return { containerRef, ghosts }
}