Move the UI to be vertical.

This commit is contained in:
Greyson Parrelli
2026-07-24 22:16:28 -04:00
parent 0a1e2f9bee
commit 95499026d7
5 changed files with 350 additions and 409 deletions
+27 -161
View File
@@ -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>
)}
+80 -68
View File
@@ -253,7 +253,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
// (and thus overflow-y) to auto — so an absolutely-positioned popover gets
// clipped to the battlefield. We anchor it to the hovered stack's viewport
// rect and portal it to <body> so it floats over the whole window instead.
const [peek, setPeek] = useState<{ seat: number; dir: 'left' | 'right'; rect: DOMRect } | null>(
const [peek, setPeek] = useState<{ seat: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>(
null,
)
const lineups = battle.lineups
@@ -319,7 +319,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
? lastEvent.dice
: null
function renderSide(seat: number, dir: 'left' | 'right') {
// pos is the half this seat occupies: 'top' = opponent, 'bottom' = you. The
// two pets meet at a horizontal clash line. Each half is a row —
// [set-aside | pet | apples] — with the deck under the pet (bottom) or over
// it (top). Set-aside stays on the left and apples on the right for both.
function renderSide(seat: number, pos: 'top' | 'bottom') {
const s = sides[seat]
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
@@ -336,7 +340,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
(lastEvent.type === 'reveal' && lastEvent.card?.kind === 'food'))
? lastEvent.card?.id
: null
// A pet just set aside rises up from the arena into the set-aside row.
// A pet just set aside slides into the set-aside row beside the arena.
const newSetAsideId =
!done && lastEvent?.type === 'setaside' && lastEvent.seat === seat
? lastEvent.card?.id
@@ -346,47 +350,49 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
const lineup = lineups?.[seat] ?? []
const stackEl = (
<div
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
onMouseEnter={
lineup.length
? (e) => setPeek({ seat, dir, rect: e.currentTarget.getBoundingClientRect() })
: undefined
}
onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))}
title={lineup.length ? 'Hover to see the whole deck' : undefined}
>
{s.stack > 0 ? (
<div className="card-back">
<span className="card-back-count">{s.stack}</span>
</div>
) : (
<div className="card-slot-empty stack-empty" />
)}
{summoning && lastEvent?.card && (
// Fly the spawned apple/bee out from where the pet stood (the unit
// zone sits on the inner edge) onto the top of the deck, so it reads
// as coming from the pet that just fainted.
<div className={`summon-pop ${dir === 'left' ? 'summon-from-right' : 'summon-from-left'}`}>
<CardView card={lastEvent.card} size="sm" />
</div>
)}
{milling && lastEvent?.card && (
<div className="summon-pop mill-pop">
<CardView card={lastEvent.card} size="sm" dead />
</div>
)}
{rockDice && lastEvent?.seat === seat && (
// The dice roll sits directly under the throwing side's deck.
<div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={dir} />
</div>
)}
<div className={`battle-deck battle-deck-${pos}`}>
<div
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
onMouseEnter={
lineup.length
? (e) => setPeek({ seat, pos, rect: e.currentTarget.getBoundingClientRect() })
: undefined
}
onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))}
title={lineup.length ? 'Hover to see the whole deck' : undefined}
>
{s.stack > 0 ? (
<div className="card-back">
<span className="card-back-count">{s.stack}</span>
</div>
) : (
<div className="card-slot-empty stack-empty" />
)}
{summoning && lastEvent?.card && (
// Fly the spawned apple/bee out from where the pet stood (toward the
// clash line) onto the deck, so it reads as coming from the pet that
// just fainted.
<div className={`summon-pop summon-to-${pos}`}>
<CardView card={lastEvent.card} size="sm" />
</div>
)}
{milling && lastEvent?.card && (
<div className="summon-pop mill-pop">
<CardView card={lastEvent.card} size="sm" dead />
</div>
)}
{rockDice && lastEvent?.seat === seat && (
// The dice roll sits beside the throwing side's deck.
<div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={pos} />
</div>
)}
</div>
</div>
)
// Set-aside pets (Blowfish, Badger, …) sit in a row above the active pet
// until their pending effect resolves.
// Set-aside pets (Blowfish, Badger, …) sit in a row to the left of the
// active pet until their pending effect resolves.
const setAsideEl = s.setAside.length > 0 && (
<div className="setaside-row">
{s.setAside.map((c) => (
@@ -403,8 +409,8 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
)
// Foods attached to the pet (or, before one is in play, waiting for the
// next) fan out below it: a vertical stack overlapping ~¾, so only each
// card's top edge shows — like cards laid out on a table.
// next) fan out to the right of it: a horizontal row overlapping ~¾, so
// only each card's left edge shows — like cards laid out on a table.
const foods = s.unit ? s.unit.foods : s.pending
const foodFanEl = foods.length > 0 && (
<div className="food-fan">
@@ -412,7 +418,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
<div
key={f.id}
className={`food-fan-card ${s.leaving.includes(f.id) ? 'is-leaving' : ''} ${
f.id === newFoodId ? `food-in food-in-${dir}` : ''
f.id === newFoodId ? `food-in food-in-${pos}` : ''
}`}
style={{ zIndex: i + 1 }}
>
@@ -431,9 +437,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
key={`${s.unit.card.id}-${clashing || rockVictim ? step : 'idle'}`}
className={[
'battle-unit',
clashing || clashDying ? `clash-${dir}` : '',
clashing || clashDying ? `clash-${pos}` : '',
s.unit.dying ? 'unit-dying' : '',
revealing ? `unit-reveal unit-reveal-${dir}` : '',
revealing ? `unit-reveal unit-reveal-${pos}` : '',
]
.filter(Boolean)
.join(' ')}
@@ -454,15 +460,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div>
)
return dir === 'left' ? (
<div className="battle-side">
{stackEl}
{unitEl}
</div>
) : (
<div className="battle-side">
{unitEl}
{stackEl}
return (
<div className={`battle-side battle-side-${pos}`}>
{pos === 'top' ? (
<>
{stackEl}
{unitEl}
</>
) : (
<>
{unitEl}
{stackEl}
</>
)}
</div>
)
}
@@ -520,35 +530,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div>
<div className="battlefield">
{renderSide(youSeat, 'left')}
{renderSide(oppSeat, 'top')}
<div className="battle-center" aria-hidden>
<span className="battle-center-bolt"></span>
</div>
{renderSide(oppSeat, 'right')}
{renderSide(youSeat, 'bottom')}
</div>
{peek &&
(() => {
const lineup = lineups?.[peek.seat] ?? []
if (!lineup.length) return null
// Your deck sits at the bottom of the board, so float the peek above
// it; the rival's sits at the top, so float it below.
const centerX = peek.rect.left + peek.rect.width / 2
const style: React.CSSProperties = {
bottom: window.innerHeight - peek.rect.top + 8,
...(peek.dir === 'left'
? { left: peek.rect.left }
: { right: window.innerWidth - peek.rect.right }),
left: centerX,
transform: 'translateX(-50%)',
...(peek.pos === 'bottom'
? { bottom: window.innerHeight - peek.rect.top + 8 }
: { top: peek.rect.bottom + 8 }),
}
// Your deck sits on the left of the board and fights from its right
// edge; the rival's sits on the right and fights from its left edge.
// Order each peek to match, so "first" always points toward the clash.
const mine = peek.seat === youSeat
const firstSide = mine ? 'right' : 'left'
// Each player's first pet is the one nearest the clash line; show the
// lineup first-to-last, left to right, for both.
return createPortal(
<div className={`deck-peek deck-peek-${peek.dir}`} style={style}>
<div className={`deck-peek deck-peek-${peek.pos}`} style={style}>
<div className="deck-peek-label">
{mine ? 'Your' : 'Opponents'} deck · {lineup.length} card
{lineup.length !== 1 ? 's' : ''} (first on the {firstSide})
{lineup.length !== 1 ? 's' : ''} (first on the left)
</div>
<div className={`deck-peek-cards first-${firstSide}`}>
<div className="deck-peek-cards first-left">
<div className="deck-peek-first"> first</div>
{lineup.map((c, i) => (
<CardView key={c.id || i} card={c} size="sm" />
+1 -1
View File
@@ -12,7 +12,7 @@ const TICK_MS = 70
// on the values the server actually rolled. Faces carry rock icons (0, 1, or
// 2 rocks) — the same die the game uses, not a pip D6. Mount it with a `key`
// that changes per rock event so the scramble replays every time.
export function DiceRoll({ dice, side }: { dice: number[]; side: 'left' | 'right' }) {
export function DiceRoll({ dice, side }: { dice: number[]; side: 'top' | 'bottom' }) {
const [display, setDisplay] = useState<number[]>(dice)
const [settled, setSettled] = useState(false)
+26 -7
View File
@@ -39,6 +39,11 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
// (its arranged lineup is already public). Shown as a centered modal.
const [deckPeek, setDeckPeek] = useState<{ seat: number } | null>(null)
// On narrow screens the event log rides in a drawer that slides over the play
// field; on wide screens it stays docked as a right column (CSS-driven) and
// this open flag is simply ignored.
const [logOpen, setLogOpen] = useState(false)
// The battle outcome is known before the replay plays out, so we hold its
// "result" log entry back: it's dropped from the persistent list during the
// battle and appended as the final battle line only once the replay reaches
@@ -87,7 +92,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
if (view) {
view.shopRow?.forEach(add)
view.players?.forEach((p) => p.deck?.forEach(add))
view.battle?.lineups?.forEach((line) => line.forEach(add))
view.battle?.lineups?.forEach((line) => line?.forEach(add))
view.battle?.events?.forEach((ev) => add(ev.card))
}
return map
@@ -190,12 +195,26 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
</main>
{view.phase !== 'lobby' && (
<EventLog
entries={entries}
youSeat={view.youSeat}
battleLines={battleLines}
cardLookup={cardLookup}
/>
<>
{/* Floating toggle — only shown on narrow screens (CSS). */}
<button
className="log-fab"
onClick={() => setLogOpen((o) => !o)}
aria-label="Toggle event log"
title="Event log"
>
📜
</button>
{logOpen && <div className="log-backdrop" onClick={() => setLogOpen(false)} />}
<div className={`event-log-dock ${logOpen ? 'is-open' : ''}`}>
<EventLog
entries={entries}
youSeat={view.youSeat}
battleLines={battleLines}
cardLookup={cardLookup}
/>
</div>
</>
)}
</div>