diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index 23b2e88..4cf1992 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -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 { CardView } from './CardView' @@ -19,6 +19,16 @@ export function ArrangePhase({ view, you, send }: Props) { 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(null) + const cardRefs = useRef<(HTMLDivElement | null)[]>([]) + const [connectors, setConnectors] = useState([]) + 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 // ordering whenever the opponent acts. @@ -33,6 +43,96 @@ export function ArrangePhase({ view, you, send }: Props) { }) }, [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) { if (to < 0 || to >= order.length) return setOrder((o) => { @@ -92,53 +192,77 @@ export function ArrangePhase({ view, you, send }: Props) { )}

-
- {order - .map((c, i) => ({ c, i })) - .reverse() - .map(({ c, i }) => ( -
{ - dragIndex.current = i - setDragging(true) - }} - onDragOver={(e) => { - e.preventDefault() - if (dragIndex.current !== null && dragIndex.current !== i) { - move(dragIndex.current, i) - dragIndex.current = i - } - }} - onDragEnd={() => { - dragIndex.current = null - setDragging(false) - }} - > - -
- - -
-
- ))} +
⚔️ first
+ {order.map((c, i) => ( +
{ + cardRefs.current[i] = el + }} + draggable + onDragStart={() => { + dragIndex.current = i + setDragging(true) + }} + onDragOver={(e) => { + e.preventDefault() + if (dragIndex.current !== null && dragIndex.current !== i) { + move(dragIndex.current, i) + dragIndex.current = i + } + }} + onDragEnd={() => { + dragIndex.current = null + setDragging(false) + }} + > + +
+ + +
+
+ ))} +
diff --git a/web/src/styles.css b/web/src/styles.css index 842bee9..487f9d6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1093,22 +1093,40 @@ h3 { } .arrange-row { + position: relative; 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; flex-wrap: wrap; - justify-content: center; + justify-content: flex-start; background: radial-gradient(ellipse 80% 120% at 50% 0%, rgba(255, 255, 255, 0.04), transparent 70%), rgba(0, 0, 0, 0.24); border: 1px solid rgba(0, 0, 0, 0.3); 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; 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 { + position: absolute; + top: 20px; + right: 16px; + height: 148px; + display: flex; + align-items: center; + z-index: 2; + direction: ltr; font-family: var(--font-display); color: var(--gold); writing-mode: vertical-rl; @@ -1118,7 +1136,31 @@ h3 { 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 { + direction: ltr; display: flex; flex-direction: column; align-items: center; @@ -1836,6 +1878,9 @@ h3 { width: 92px; height: 128px; } + .arrange-marker { + height: 128px; + } .card-lg { width: 104px; height: 146px;