Move the UI to be vertical.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
||||
import { CardView } from './CardView'
|
||||
import { useCardAnimations } from '../anim'
|
||||
@@ -9,31 +9,21 @@ interface Props {
|
||||
send: (msg: ClientMessage) => void
|
||||
}
|
||||
|
||||
// ArrangePhase lets the player order their deck for battle. Rightmost card
|
||||
// fights first; food cards buff the next pet to their left... i.e. foods
|
||||
// apply to the next pet later in the play order. Internally `order` stays in
|
||||
// play order (index 0 fights first) to match the backend; we only reverse for
|
||||
// display. Drag cards or use the arrow buttons to reorder, then lock in.
|
||||
// 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)
|
||||
const locked = you.ready
|
||||
|
||||
// Connector arrows drawn in the gaps between wrapped rows. The battle line
|
||||
// reads right-to-left (card 0 fights first, top-right) and wraps downward, so
|
||||
// when the line spills onto a new row we draw a curve from the leftmost card
|
||||
// of a row to the rightmost card of the row below it — i.e. following the
|
||||
// play order across the wrap, arrowhead landing on the next pet to fight.
|
||||
const trayRef = useRef<HTMLDivElement>(null)
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
// The arrow buttons reorder `order` and the cards glide to their new slot.
|
||||
// Drag-and-drop reorders instantly instead: a drag fires continuously, so
|
||||
// animating every crossing looks frantic — we suppress it while dragging.
|
||||
const arrangeAnim = useCardAnimations(order, (c) => c.id, !dragging)
|
||||
const [connectors, setConnectors] = useState<string[]>([])
|
||||
const [svgSize, setSvgSize] = useState({ w: 0, h: 0 })
|
||||
|
||||
// Resync only if the deck's actual contents changed — every broadcast
|
||||
// creates a fresh array, and blindly resetting would wipe an in-progress
|
||||
@@ -49,100 +39,6 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
})
|
||||
}, [you.deck])
|
||||
|
||||
// Measure the laid-out cards and recompute the inter-row connector paths.
|
||||
// The arrows connect fixed slot positions, not specific cards: reordering
|
||||
// swaps which card sits in a slot but never moves the slots, so we only
|
||||
// remeasure when the card count changes or the tray resizes (which is what
|
||||
// actually moves the wrap points). Recomputing on every reorder would also
|
||||
// read cards mid-glide and make the arrows jump around.
|
||||
useLayoutEffect(() => {
|
||||
const tray = trayRef.current
|
||||
if (!tray) return
|
||||
|
||||
const measure = () => {
|
||||
const trayBox = tray.getBoundingClientRect()
|
||||
setSvgSize({ w: trayBox.width, h: trayBox.height })
|
||||
|
||||
// Card boxes in play order. The `.card` gives the art rect; the wrapper
|
||||
// also spans the reorder buttons, so its bottom marks the true row floor.
|
||||
const boxes: {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
wrapBottom: number
|
||||
}[] = []
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const wrap = cardRefs.current[i]
|
||||
if (!wrap) continue
|
||||
const cardEl = (wrap.querySelector('.card') as HTMLElement | null) ?? wrap
|
||||
const cb = cardEl.getBoundingClientRect()
|
||||
const wb = wrap.getBoundingClientRect()
|
||||
boxes.push({
|
||||
left: cb.left - trayBox.left,
|
||||
right: cb.right - trayBox.left,
|
||||
top: cb.top - trayBox.top,
|
||||
bottom: cb.bottom - trayBox.top,
|
||||
wrapBottom: wb.bottom - trayBox.top,
|
||||
})
|
||||
}
|
||||
|
||||
// Cards run in play order and the RTL wrap makes each visual row a
|
||||
// contiguous run (rightmost = earliest to play). Group by shared top edge.
|
||||
const rows: number[][] = []
|
||||
boxes.forEach((b, i) => {
|
||||
const row = rows[rows.length - 1]
|
||||
if (row && Math.abs(boxes[row[0]].top - b.top) < 24) row.push(i)
|
||||
else rows.push([i])
|
||||
})
|
||||
|
||||
// One connector per row break, running flat along the band between rows.
|
||||
// It comes out the right side (mid-height) of the lower row's rightmost
|
||||
// card — the next pet to play — sweeps across, and points into the left
|
||||
// side (mid-height) of the row above's leftmost card, so the wrap reads as
|
||||
// a return to where the line began. OUT is how far the risers sit outside
|
||||
// the cards — kept generous so the arrowhead has a clean straight run to
|
||||
// render on rather than being crammed against the card edge.
|
||||
const OUT = 26
|
||||
const conns: string[] = []
|
||||
for (let r = 0; r < rows.length - 1; r++) {
|
||||
const upper = rows[r]
|
||||
const lower = rows[r + 1]
|
||||
const head = boxes[upper[upper.length - 1]] // upper row, leftmost card
|
||||
const tail = boxes[lower[0]] // lower row, rightmost card
|
||||
const upperFloor = Math.max(...upper.map((i) => boxes[i].wrapBottom))
|
||||
const lowerTop = Math.min(...lower.map((i) => boxes[i].top))
|
||||
const flatY = (upperFloor + lowerTop) / 2
|
||||
const headMidY = (head.top + head.bottom) / 2
|
||||
const tailMidY = (tail.top + tail.bottom) / 2
|
||||
const xL = head.left - OUT
|
||||
const xR = tail.right + OUT
|
||||
const rad = Math.min(10, OUT, (tailMidY - flatY) / 2, (flatY - headMidY) / 2)
|
||||
conns.push(
|
||||
[
|
||||
`M ${tail.right},${tailMidY}`,
|
||||
`L ${xR - rad},${tailMidY}`,
|
||||
`Q ${xR},${tailMidY} ${xR},${tailMidY - rad}`,
|
||||
`L ${xR},${flatY + rad}`,
|
||||
`Q ${xR},${flatY} ${xR - rad},${flatY}`,
|
||||
`L ${xL + rad},${flatY}`,
|
||||
`Q ${xL},${flatY} ${xL},${flatY - rad}`,
|
||||
`L ${xL},${headMidY + rad}`,
|
||||
`Q ${xL},${headMidY} ${xL + rad},${headMidY}`,
|
||||
`L ${head.left},${headMidY}`,
|
||||
].join(' '),
|
||||
)
|
||||
}
|
||||
setConnectors(conns)
|
||||
}
|
||||
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(tray)
|
||||
return () => ro.disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [order.length])
|
||||
|
||||
function move(from: number, to: number) {
|
||||
if (to < 0 || to >= order.length) return
|
||||
setOrder((o) => {
|
||||
@@ -153,7 +49,8 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
})
|
||||
}
|
||||
|
||||
// Which pets do the foods land on? Compute buff per card for preview.
|
||||
// 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
|
||||
@@ -166,6 +63,8 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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++
|
||||
@@ -191,26 +90,17 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
<span className="status-hot">Arrange your battle line</span>
|
||||
</div>
|
||||
<p className="hint">
|
||||
The <strong>rightmost</strong> card fights first. Food cards power up the
|
||||
next pet to their <strong>left</strong>.
|
||||
The <strong>topmost</strong> card fights first. Food cards power up the
|
||||
next pet <strong>below</strong> them.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="arrange-row"
|
||||
ref={(el) => {
|
||||
trayRef.current = el
|
||||
arrangeAnim.containerRef.current = el
|
||||
}}
|
||||
>
|
||||
<div className="arrange-marker">⚔️ first</div>
|
||||
<div className="arrange-col" ref={arrangeAnim.containerRef}>
|
||||
<div className="arrange-marker">⚔️ first to fight</div>
|
||||
{order.map((c, i) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="arrange-card"
|
||||
data-flip-key={c.id}
|
||||
ref={(el) => {
|
||||
cardRefs.current[i] = el
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => {
|
||||
dragIndex.current = i
|
||||
@@ -228,57 +118,33 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
setDragging(false)
|
||||
}}
|
||||
>
|
||||
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
|
||||
<div className="arrange-arrows">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={i === order.length - 1}
|
||||
onClick={() => move(i, i + 1)}
|
||||
aria-label="move left"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, i - 1)}
|
||||
aria-label="move right"
|
||||
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>
|
||||
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
|
||||
</div>
|
||||
))}
|
||||
<svg
|
||||
className="arrange-connectors"
|
||||
width={svgSize.w}
|
||||
height={svgSize.h}
|
||||
viewBox={`0 0 ${svgSize.w} ${svgSize.h}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrange-arrowhead"
|
||||
markerWidth="12"
|
||||
markerHeight="12"
|
||||
refX="9"
|
||||
refY="5"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path className="arrange-arrowhead-shape" d="M0,0 L10,5 L0,10 Z" />
|
||||
</marker>
|
||||
</defs>
|
||||
{connectors.map((d, i) => (
|
||||
<path key={i} className="arrange-connector" d={d} markerEnd="url(#arrange-arrowhead)" />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{trailingFoods > 0 && (
|
||||
<p className="hint warn-text">
|
||||
⚠️ {trailingFoods} food card{trailingFoods > 1 ? 's' : ''} on the far
|
||||
left will be wasted!
|
||||
⚠️ {trailingFoods} food card{trailingFoods > 1 ? 's' : ''} at the
|
||||
bottom will be wasted!
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user