Files
super-auto-pets-board-game/web/src/components/ArrangePhase.tsx
T
2026-07-27 00:03:23 -04:00

297 lines
11 KiB
TypeScript

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<Card[]>(you.deck ?? [])
const dragIndex = useRef<number | null>(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<string | null>(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<HTMLElement>, 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<string, number>()
{
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 (
<div className="centered">
<h2>Order locked in ⚔️</h2>
<p className="muted">
Waiting for {opponent?.name ?? 'your opponent'} to arrange their deck
</p>
</div>
)
}
return (
<div className="arrange">
<div className="shop-status">
<span className="status-hot">Arrange your battle line</span>
</div>
<p className="hint">
The <strong>topmost</strong> card fights first. Food cards power up the
next pet <strong>below</strong> them.
</p>
<div className="arrange-col" ref={arrangeAnim.containerRef}>
<div className="arrange-marker">⚔️ first to fight</div>
{order.map((c, i) => {
const isDragged = c.id === dragId
return (
<div
key={c.id}
className={`arrange-card${isDragged ? ' is-dragging' : ''}`}
data-flip-key={c.id}
ref={(el) => {
cardRefs.current[i] = el
}}
>
<div
className={`arrange-drag${isDragged && dropping ? ' is-dropping' : ''}`}
style={isDragged ? { transform: `translateY(${dragTranslate}px)` } : undefined}
>
<div className="arrange-controls">
<div
className="arrange-handle"
role="button"
tabIndex={-1}
aria-label="drag to reorder"
title="Drag to reorder"
onPointerDown={(e) => startDrag(e, i)}
>
</div>
<div className="arrange-arrows">
<button
className="btn btn-ghost btn-sm"
disabled={i === 0}
onClick={() => move(i, i - 1)}
aria-label="move up (earlier)"
>
</button>
<button
className="btn btn-ghost btn-sm"
disabled={i === order.length - 1}
onClick={() => move(i, i + 1)}
aria-label="move down (later)"
>
</button>
</div>
</div>
<CardView
card={c}
bonus={bonuses.get(c.id) ?? 0}
noMagnify={dragging}
onClick={
isPhone && !dragging
? () => setZoom({ card: c, bonus: bonuses.get(c.id) ?? 0 })
: undefined
}
/>
</div>
</div>
)
})}
</div>
{trailingFoods > 0 && (
<p className="hint warn-text">
⚠️ {trailingFoods} food card{trailingFoods > 1 ? 's' : ''} at the
bottom will be wasted!
</p>
)}
<div className="actions">
<button
className="btn btn-primary btn-big"
onClick={() => send({ type: 'arrange', order: order.map((c) => c.id) })}
>
Lock in & battle ⚔️
</button>
</div>
{zoom && (
<CardZoom card={zoom.card} bonus={zoom.bonus} onClose={() => setZoom(null)} />
)}
</div>
)
}