import { useEffect, useRef, useState } from 'react' import type { Card, ClientMessage, GameView, PlayerView } from '../types' import { CardView, CardZoom } from './CardView' import { useCardAnimations } from '../anim' import { useMediaQuery } from '../useMediaQuery' interface Props { view: GameView you: PlayerView send: (msg: ClientMessage) => void } // ArrangePhase lets the player order their deck for battle. The list runs top to // bottom in play order: the topmost card fights first, and food cards buff the // next pet below them. `order` stays in play order (index 0 fights first) to // match the backend. Drag cards or use the arrow buttons to reorder, then lock // in. export function ArrangePhase({ view, you, send }: Props) { const [order, setOrder] = useState(you.deck ?? []) const dragIndex = useRef(null) const [dragging, setDragging] = useState(false) // The card currently under the finger/cursor: it lifts and follows the // pointer (`dragTranslate`), while `dropping` glides it into its final slot // when released. const [dragId, setDragId] = useState(null) const [dragTranslate, setDragTranslate] = useState(0) const [dropping, setDropping] = useState(false) // Drag bookkeeping: where the drag began, the pointer Y at grab time, and the // slot-to-slot pixel stride (rows are uniform), so the lifted card can track // the finger even as the list reorders beneath it. const startIndex = useRef(0) const grabY = useRef(0) const stride = useRef(0) // One entry per card row, in current play order, so a pointer drag can find // which slot the finger/cursor is currently over by hit-testing rects. const cardRefs = useRef<(HTMLDivElement | null)[]>([]) // Holds the latest `onDragMove` so the window listener (attached once per // drag) always calls the fresh closure — see the drag effect below. const moveRef = useRef<(clientY: number) => void>(() => {}) const locked = you.ready // Phones can't hover to preview a card, so a tap opens the magnified view // instead. Dragging uses the grip handle, so tapping the card body is free to // mean "read it". Ignored on desktop, which keeps the hover magnifier. const isPhone = useMediaQuery('(max-width: 600px)') const [zoom, setZoom] = useState<{ card: Card; bonus: number } | null>(null) // The arrow buttons reorder `order` and the cards glide to their new slot. // A drag reorders live too, and the neighbors the dragged card passes should // glide into the gap it opens — so animation stays on. Only the dragged card // itself is excluded (`dragId`): its inner wrapper already tracks the finger, // so FLIP-sliding its box on top of that would fight the follow transform. const arrangeAnim = useCardAnimations(order, (c) => c.id, true, dragId ?? undefined) // Resync only if the deck's actual contents changed — every broadcast // creates a fresh array, and blindly resetting would wipe an in-progress // ordering whenever the opponent acts. useEffect(() => { setOrder((prev) => { const deck = you.deck ?? [] const ids = new Set(deck.map((c) => c.id)) if (prev.length === deck.length && prev.every((c) => ids.has(c.id))) { return prev } return deck }) }, [you.deck]) function move(from: number, to: number) { if (to < 0 || to >= order.length) return setOrder((o) => { const next = [...o] const [c] = next.splice(from, 1) next.splice(to, 0, c) return next }) } // Drag-to-reorder via pointer events, driven by the grip handle. Native HTML5 // drag doesn't fire on touch, so we use pointer events (mouse + touch alike). // `touch-action: none` on the handle stops the browser from scrolling the // page mid-drag. // // We do NOT rely on `setPointerCapture` here: the captured handle lives inside // the keyed row that reorders mid-drag, and React moves that DOM node // (`insertBefore`) as the list changes — which makes browsers drop the active // pointer capture, freezing the drag until the user re-grabs. Instead we // listen on `window` for the drag's lifetime (see the effect below), so // reordering the rows can never interrupt the gesture. // // The lifted card follows the finger while the list reorders live beneath it. // The translate is applied to an inner wrapper, not the `.arrange-card` box // the FLIP animator measures, so the two never fight. function startDrag(e: React.PointerEvent, i: number) { e.preventDefault() dragIndex.current = i startIndex.current = i grabY.current = e.clientY // Row stride = distance between two adjacent slots; rows are uniform. const a = cardRefs.current[0]?.getBoundingClientRect() const b = cardRefs.current[1]?.getBoundingClientRect() stride.current = a && b ? b.top - a.top : (a?.height ?? 0) + 12 setDragId(order[i].id) setDragTranslate(0) setDropping(false) setDragging(true) } function onDragMove(clientY: number) { if (dragIndex.current === null) return // Find the slot the pointer has crossed into by hit-testing the *other* // rows' midpoints (the lifted row's own box stays in its natural slot). let target = dragIndex.current for (let j = 0; j < order.length; j++) { if (j === dragIndex.current) continue const el = cardRefs.current[j] if (!el) continue const r = el.getBoundingClientRect() const mid = r.top + r.height / 2 if (j < dragIndex.current && clientY < mid) target = Math.min(target, j) else if (j > dragIndex.current && clientY > mid) target = Math.max(target, j) } if (target !== dragIndex.current) { move(dragIndex.current, target) dragIndex.current = target } // Follow the finger from where the drag began, minus how far the card's own // slot has since shifted — keeping it pinned under the finger. const shift = (dragIndex.current - startIndex.current) * stride.current setDragTranslate(clientY - grabY.current - shift) } // Keep the window listener pointed at the current-render closure (fresh // `order`/refs) without re-attaching the listener on every reorder. moveRef.current = onDragMove function endDrag() { if (dragIndex.current === null) return dragIndex.current = null // Glide the lifted card down into its resting slot, then clear drag state. setDropping(true) setDragTranslate(0) window.setTimeout(() => { setDragId(null) setDropping(false) setDragging(false) }, 180) } // While a drag is active, track the pointer on `window` so the gesture keeps // running even as the list reorders under the finger (the handle's own // pointer capture would be lost when React moves its row). Attached once per // drag; `moveRef` keeps it calling the latest closure. useEffect(() => { if (!dragging) return const onMove = (e: PointerEvent) => moveRef.current(e.clientY) const onUp = () => endDrag() window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onUp) return () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onUp) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [dragging]) // Which pets do the foods land on? Foods buff the next pet later in play order // (i.e. the next pet below them in the list). Compute buff per card for preview. const bonuses = new Map() { let pendingApples = 0 for (const c of order) { if (c.kind === 'food') { if (c.food === 'apple') pendingApples++ } else { bonuses.set(c.id, pendingApples) pendingApples = 0 } } } // Foods at the very end of play order (bottom of the list) have no pet after // them, so their buff is wasted. const trailingFoods = (() => { let n = 0 for (let i = order.length - 1; i >= 0 && order[i].kind === 'food'; i--) n++ return n })() const opponent = view.players.find((p) => p.seat !== view.youSeat) if (locked) { return (

Order locked in ⚔️

Waiting for {opponent?.name ?? 'your opponent'} to arrange their deck…

) } return (
Arrange your battle line

The topmost card fights first. Food cards power up the next pet below them.

⚔️ first to fight
{order.map((c, i) => { const isDragged = c.id === dragId return (
{ cardRefs.current[i] = el }} >
startDrag(e, i)} > ⠿
setZoom({ card: c, bonus: bonuses.get(c.id) ?? 0 }) : undefined } />
) })}
{trailingFoods > 0 && (

⚠️ {trailingFoods} food card{trailingFoods > 1 ? 's' : ''} at the bottom will be wasted!

)}
{zoom && ( setZoom(null)} /> )}
) }