297 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|