Improve arrangement phase UI.

This commit is contained in:
Greyson Parrelli
2026-07-23 15:26:29 -04:00
parent 547cd81a99
commit 3825dbacec
2 changed files with 219 additions and 50 deletions
+131 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { Card, ClientMessage, GameView, PlayerView } from '../types' import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView' import { CardView } from './CardView'
@@ -19,6 +19,16 @@ export function ArrangePhase({ view, you, send }: Props) {
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const locked = you.ready 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)[]>([])
const [connectors, setConnectors] = useState<string[]>([])
const [svgSize, setSvgSize] = useState({ w: 0, h: 0 })
// Resync only if the deck's actual contents changed — every broadcast // Resync only if the deck's actual contents changed — every broadcast
// creates a fresh array, and blindly resetting would wipe an in-progress // creates a fresh array, and blindly resetting would wipe an in-progress
// ordering whenever the opponent acts. // ordering whenever the opponent acts.
@@ -33,6 +43,96 @@ export function ArrangePhase({ view, you, send }: Props) {
}) })
}, [you.deck]) }, [you.deck])
// Measure the laid-out cards and recompute the inter-row connector paths.
// Runs whenever the order changes and whenever the tray resizes (which is
// what actually moves the wrap points), so the arrows track the real layout.
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()
}, [order])
function move(from: number, to: number) { function move(from: number, to: number) {
if (to < 0 || to >= order.length) return if (to < 0 || to >= order.length) return
setOrder((o) => { setOrder((o) => {
@@ -92,14 +192,15 @@ export function ArrangePhase({ view, you, send }: Props) {
)} )}
</p> </p>
<div className="arrange-row"> <div className="arrange-row" ref={trayRef}>
{order <div className="arrange-marker"> first</div>
.map((c, i) => ({ c, i })) {order.map((c, i) => (
.reverse()
.map(({ c, i }) => (
<div <div
key={c.id} key={c.id}
className="arrange-card" className="arrange-card"
ref={(el) => {
cardRefs.current[i] = el
}}
draggable draggable
onDragStart={() => { onDragStart={() => {
dragIndex.current = i dragIndex.current = i
@@ -138,7 +239,30 @@ export function ArrangePhase({ view, you, send }: Props) {
</div> </div>
</div> </div>
))} ))}
<div className="arrange-marker"> first</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> </div>
<div className="actions"> <div className="actions">
+48 -3
View File
@@ -1093,22 +1093,40 @@ h3 {
} }
.arrange-row { .arrange-row {
position: relative;
display: flex; display: flex;
gap: 14px; /* Battle line reads right-to-left, wrapping downward: card 0 (fights first)
sits top-right and later pets flow left, then onto rows below. The wide
row-gap leaves a band for the wrap-around connector arrows. */
direction: rtl;
column-gap: 14px;
row-gap: 56px;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: flex-start;
background: background:
radial-gradient(ellipse 80% 120% at 50% 0%, rgba(255, 255, 255, 0.04), transparent 70%), radial-gradient(ellipse 80% 120% at 50% 0%, rgba(255, 255, 255, 0.04), transparent 70%),
rgba(0, 0, 0, 0.24); rgba(0, 0, 0, 0.24);
border: 1px solid rgba(0, 0, 0, 0.3); border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 18px; border-radius: 18px;
padding: 20px 18px; /* Extra right padding reserves the strip the "first" marker sits in, so its
width doesn't shove the top row left of the wrapped rows below it. */
padding: 20px 52px 20px 18px;
min-height: 224px; min-height: 224px;
box-shadow: var(--tray-inset); box-shadow: var(--tray-inset);
} }
/* Pinned out of flow to the reserved strip beside the first (rightmost) card,
vertically centred on the top row, so it never affects card alignment. */
.arrange-marker { .arrange-marker {
position: absolute;
top: 20px;
right: 16px;
height: 148px;
display: flex;
align-items: center;
z-index: 2;
direction: ltr;
font-family: var(--font-display); font-family: var(--font-display);
color: var(--gold); color: var(--gold);
writing-mode: vertical-rl; writing-mode: vertical-rl;
@@ -1118,7 +1136,31 @@ h3 {
opacity: 0.85; opacity: 0.85;
} }
/* SVG overlay for the wrap-around connector arrows. Absolute so it sits out of
the flex flow; pointer-events off so it never blocks dragging. overflow is
visible because the curve control points can bow just past the tray edge. */
.arrange-connectors {
position: absolute;
inset: 0;
z-index: 3;
overflow: visible;
pointer-events: none;
}
.arrange-connector {
fill: none;
stroke: rgba(190, 194, 200, 0.55);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.arrange-arrowhead-shape {
fill: rgba(190, 194, 200, 0.55);
}
.arrange-card { .arrange-card {
direction: ltr;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -1836,6 +1878,9 @@ h3 {
width: 92px; width: 92px;
height: 128px; height: 128px;
} }
.arrange-marker {
height: 128px;
}
.card-lg { .card-lg {
width: 104px; width: 104px;
height: 146px; height: 146px;