From 95499026d7d69e27d2378bc754f09951d636c4b5 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 24 Jul 2026 22:16:28 -0400 Subject: [PATCH] Move the UI to be vertical. --- web/src/components/ArrangePhase.tsx | 188 ++------------ web/src/components/BattlePhase.tsx | 148 ++++++----- web/src/components/DiceRoll.tsx | 2 +- web/src/components/Table.tsx | 33 ++- web/src/styles.css | 388 ++++++++++++++++------------ 5 files changed, 350 insertions(+), 409 deletions(-) diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index bfe83f0..b28ec1f 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -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(you.deck ?? []) const dragIndex = useRef(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(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([]) - 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() { 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) { Arrange your battle line

- The rightmost card fights first. Food cards power up the - next pet to their left. + The topmost card fights first. Food cards power up the + next pet below them.

-
{ - trayRef.current = el - arrangeAnim.containerRef.current = el - }} - > -
⚔️ first
+
+
⚔️ first to fight
{order.map((c, i) => (
{ - cardRefs.current[i] = el - }} draggable onDragStart={() => { dragIndex.current = i @@ -228,57 +118,33 @@ export function ArrangePhase({ view, you, send }: Props) { setDragging(false) }} > -
- +
+
))} -
{trailingFoods > 0 && (

- ⚠️ {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!

)} diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 9ad572f..146e37c 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -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 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 = ( -
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 ? ( -
- {s.stack} -
- ) : ( -
- )} - {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. -
- -
- )} - {milling && lastEvent?.card && ( -
- -
- )} - {rockDice && lastEvent?.seat === seat && ( - // The dice roll sits directly under the throwing side's deck. -
- -
- )} +
+
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 ? ( +
+ {s.stack} +
+ ) : ( +
+ )} + {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. +
+ +
+ )} + {milling && lastEvent?.card && ( +
+ +
+ )} + {rockDice && lastEvent?.seat === seat && ( + // The dice roll sits beside the throwing side's deck. +
+ +
+ )} +
) - // 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 && (
{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 && (
@@ -412,7 +418,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
@@ -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) {
) - return dir === 'left' ? ( -
- {stackEl} - {unitEl} -
- ) : ( -
- {unitEl} - {stackEl} + return ( +
+ {pos === 'top' ? ( + <> + {stackEl} + {unitEl} + + ) : ( + <> + {unitEl} + {stackEl} + + )}
) } @@ -520,35 +530,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
- {renderSide(youSeat, 'left')} + {renderSide(oppSeat, 'top')}
- {renderSide(oppSeat, 'right')} + {renderSide(youSeat, 'bottom')}
{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( -
+
{mine ? 'Your' : 'Opponent’s'} deck · {lineup.length} card - {lineup.length !== 1 ? 's' : ''} (first on the {firstSide}) + {lineup.length !== 1 ? 's' : ''} (first on the left)
-
+
⚔️ first
{lineup.map((c, i) => ( diff --git a/web/src/components/DiceRoll.tsx b/web/src/components/DiceRoll.tsx index f82593d..3721f64 100644 --- a/web/src/components/DiceRoll.tsx +++ b/web/src/components/DiceRoll.tsx @@ -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(dice) const [settled, setSettled] = useState(false) diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index 3e92a00..a1a95a5 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -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' && } {view.phase !== 'lobby' && ( - + <> + {/* Floating toggle — only shown on narrow screens (CSS). */} + + {logOpen &&
setLogOpen(false)} />} +
+ +
+ )}
diff --git a/web/src/styles.css b/web/src/styles.css index 321d427..c53343e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -531,10 +531,13 @@ h3 { min-height: 0; } +/* A centred portrait column — the app is laid out for a tall, narrow play + field, so it stays compact even on a wide desktop (the event log docks + alongside it). */ .table-main { flex: 1; - padding: 26px 20px 48px; - max-width: 1100px; + padding: 22px 16px 48px; + max-width: 620px; width: 100%; margin: 0 auto; } @@ -668,24 +671,94 @@ h3 { box-shadow: inset 0 0 0 1px rgba(246, 201, 78, 0.45); } -@media (max-width: 860px) { - .table-body { - flex-direction: column; +/* On wide screens the dock wrapper is transparent to layout, so the log docks + as a right column exactly as its own rules describe. */ +.event-log-dock { + display: contents; +} + +/* The floating log button and drawer backdrop only exist on narrow screens. */ +.log-fab, +.log-backdrop { + display: none; +} + +/* Narrow screens: the event log becomes a drawer that slides in from the right + over the play field, opened by a floating button. */ +@media (max-width: 1000px) { + .event-log-dock { + display: block; + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(340px, 88vw); + z-index: 80; + transform: translateX(100%); + transition: transform 240ms ease; + box-shadow: -8px 0 24px rgba(0, 0, 0, 0.5); } - .event-log { + + .event-log-dock.is-open { + transform: translateX(0); + } + + .event-log-dock .event-log { width: 100%; + height: 100%; + max-height: none; position: static; - max-height: 220px; - border-left: none; - border-top: 3px solid rgba(0, 0, 0, 0.35); + border-left: 1px solid rgba(0, 0, 0, 0.4); } - .event-log.is-collapsed { + + .event-log-dock .event-log.is-collapsed { width: 100%; } - .event-log.is-collapsed .event-log-head { + + .event-log-dock .event-log.is-collapsed .event-log-head { writing-mode: horizontal-tb; height: auto; } + + .log-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 75; + background: rgba(10, 20, 14, 0.5); + backdrop-filter: blur(1.5px); + animation: result-in 200ms ease; + } + + .log-fab { + display: grid; + place-items: center; + position: fixed; + right: 14px; + bottom: 14px; + width: 52px; + height: 52px; + padding: 0; + border-radius: 50%; + z-index: 70; + font-size: 1.4rem; + background: linear-gradient(180deg, var(--wood-light), var(--wood)); + color: var(--cream); + border: none; + cursor: pointer; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.22), + 0 4px 0 var(--wood-dark), + 0 6px 14px rgba(0, 0, 0, 0.45); + } + + .log-fab:active { + transform: translateY(3px); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + 0 1px 0 var(--wood-dark), + 0 2px 6px rgba(0, 0, 0, 0.4); + } } /* ---------- lobby ---------- */ @@ -1567,79 +1640,42 @@ h3 { gap: 20px; } -.arrange-row { +/* A vertical battle line laid into a felt tray: card 0 (fights first) sits at + the top and later pets flow downward. */ +.arrange-col { position: relative; display: flex; - /* 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; + flex-direction: column; align-items: center; - flex-wrap: wrap; - justify-content: flex-start; + gap: 12px; background: - radial-gradient(ellipse 80% 120% at 50% 0%, rgba(255, 255, 255, 0.04), transparent 70%), + radial-gradient(ellipse 120% 80% 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; - /* 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; + padding: 16px 18px 20px; 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. */ +/* The "first to fight" label sits at the top of the column, above the first + card. It's a static first child (no data-flip-key), so the FLIP animator + ignores it. */ .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; - transform: rotate(180deg); font-size: 0.95rem; letter-spacing: 0.04em; opacity: 0.85; + padding-bottom: 2px; } -/* 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); -} - +/* Each row: reorder arrows on the left, the card on the right. */ .arrange-card { - direction: ltr; display: flex; - flex-direction: column; + flex-direction: row; align-items: center; - gap: 8px; + gap: 12px; cursor: grab; } @@ -1649,7 +1685,8 @@ h3 { .arrange-arrows { display: flex; - gap: 5px; + flex-direction: column; + gap: 6px; } /* ---------- battle ---------- */ @@ -1710,18 +1747,19 @@ h3 { .battlefield { position: relative; display: flex; - align-items: center; + flex-direction: column; + align-items: stretch; justify-content: center; - gap: 8px; + gap: 6px; background: - radial-gradient(ellipse 55% 80% at center, rgba(246, 201, 78, 0.12), transparent 66%), + radial-gradient(ellipse 80% 55% at center, rgba(246, 201, 78, 0.12), transparent 66%), linear-gradient(180deg, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.18)); border: 1px solid rgba(0, 0, 0, 0.3); border-radius: 20px; - /* Roomy top/bottom padding: pets keep their central row while set-aside - cards fan above and attached foods fan below. The bottom needs plenty of - room since a pet can carry a big stack of apples (Manatee, Fire Ant, …). */ - padding: 132px 14px 268px; + /* Side padding leaves room for set-aside pets (left) and attached foods + (right) to fan out beside each pet; a big apple stack (Manatee, Fire Ant) + scrolls horizontally rather than clipping. */ + padding: 18px 14px; min-height: 150px; overflow-x: auto; box-shadow: @@ -1735,18 +1773,17 @@ h3 { flex-shrink: 0; display: grid; place-items: center; - min-width: 2.4rem; - align-self: stretch; + min-height: 2.4rem; } -/* A soft seam down the middle of the arena. */ +/* A soft seam across the middle of the arena, where the two sides clash. */ .battle-center::before { content: ''; position: absolute; - top: 8%; - bottom: 8%; - width: 2px; - background: linear-gradient(180deg, transparent, rgba(246, 201, 78, 0.25), transparent); + left: 8%; + right: 8%; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(246, 201, 78, 0.25), transparent); } .battle-center-bolt { @@ -1754,12 +1791,20 @@ h3 { filter: drop-shadow(0 0 8px rgba(246, 201, 78, 0.5)); } +/* One player's half. The opponent (top) stacks deck-over-pet; you (bottom) + stack pet-over-deck, so both decks sit on the outer edge and the two pets + meet at the centre seam. */ .battle-side { display: flex; - gap: 12px; + flex-direction: column; align-items: center; - flex: 1; + gap: 10px; min-width: 0; +} + +/* Centres the deck stack under (you) / over (opponent) the pet. */ +.battle-deck { + display: flex; justify-content: center; } @@ -1838,53 +1883,57 @@ h3 { pointer-events: none; } -/* Spawn arcs out from the pet (inner edge) onto the deck. The pet sits to the - right of the deck on the left side and to the left on the right side, so - each side pulls its spawn in from the opposite direction. */ -@keyframes summon-from-right { +/* Spawn arcs out from the pet onto the deck. The pet sits toward the centre + seam — above your deck (bottom half) and below the opponent's (top half) — so + each spawn is pulled in from its own pet's side. */ +@keyframes summon-to-bottom { 0% { opacity: 0; - transform: translate(120px, 8px) scale(0.5) rotate(10deg); + transform: translateY(-70px) scale(0.5); } 25% { opacity: 1; - transform: translate(64px, -22px) scale(1.05) rotate(5deg); + transform: translateY(-30px) scale(1.05); } 70% { opacity: 1; - transform: translate(0, -12px) scale(1) rotate(0); + transform: translateY(4px) scale(1); } 100% { opacity: 0; - transform: translate(0, 2px) scale(0.72); + transform: translateY(14px) scale(0.72); } } -@keyframes summon-from-left { +@keyframes summon-to-top { 0% { opacity: 0; - transform: translate(-120px, 8px) scale(0.5) rotate(-10deg); + transform: translateY(70px) scale(0.5); } 25% { opacity: 1; - transform: translate(-64px, -22px) scale(1.05) rotate(-5deg); + transform: translateY(30px) scale(1.05); } 70% { opacity: 1; - transform: translate(0, -12px) scale(1) rotate(0); + transform: translateY(-4px) scale(1); } 100% { opacity: 0; - transform: translate(0, 2px) scale(0.72); + transform: translateY(-14px) scale(0.72); } } -.summon-pop.summon-from-right { - animation-name: summon-from-right; +/* You throw from the bottom (deck below the pet); the opponent from the top. */ +.summon-pop.summon-to-bottom { + top: auto; + bottom: calc(100% - 24px); + animation-name: summon-to-bottom; } -.summon-pop.summon-from-left { - animation-name: summon-from-left; +.summon-pop.summon-to-top { + top: calc(100% - 24px); + animation-name: summon-to-top; } /* Apple/bee spawned in the shop, flying out from the creature that made it. */ @@ -1948,27 +1997,27 @@ h3 { place-items: center; } -/* Set-aside pets (Blowfish, Badger, …) laid out in a row above the active - pet, kept until their pending effect resolves. */ +/* Set-aside pets (Blowfish, Badger, …) laid out in a row to the left of the + active pet, kept until their pending effect resolves. */ .setaside-row { position: absolute; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); + right: calc(100% + 6px); + top: 50%; + transform: translateY(-50%); display: flex; gap: 6px; z-index: 5; } -/* Attached (or pending) foods fanned below the pet: a vertical stack that - overlaps ~¾ so only each card's top edge shows, last card fully on top. */ +/* Attached (or pending) foods fanned to the right of the pet: a horizontal row + that overlaps ~¾ so only each card's left edge shows, last card fully on top. */ .food-fan { position: absolute; - top: calc(100% - 6px); - left: 50%; - transform: translateX(-50%); + left: calc(100% - 6px); + top: 50%; + transform: translateY(-50%); display: flex; - flex-direction: column; + flex-direction: row; align-items: center; z-index: 4; } @@ -1978,8 +2027,8 @@ h3 { } .food-fan-card:not(:first-child) { - /* Slightly tighter overlap so a tall apple stack fits the play area. */ - margin-top: -92px; + /* Overlap so a tall apple stack stays compact beside the pet. */ + margin-left: -64px; } /* Released cards (a spent set-aside pet, or a used-up food perk) shrink and @@ -1998,13 +2047,13 @@ h3 { pointer-events: none; } -/* A freshly dealt food (apple) sails in from the deck — up and toward the outer - edge — and settles onto the fan, rather than blinking into place. Direction - follows the side the deck sits on (see food-in-left / food-in-right). */ -@keyframes food-in-left { +/* A freshly dealt food (apple) sails in from the deck and settles onto the fan, + rather than blinking into place. The deck sits below the pet on your side and + above it on the opponent's, so the apple flies up (bottom) or down (top). */ +@keyframes food-in-bottom { from { opacity: 0; - transform: translate(-64px, -52px) rotate(-14deg) scale(0.6); + transform: translate(-30px, 70px) rotate(-12deg) scale(0.6); } to { opacity: 1; @@ -2012,10 +2061,10 @@ h3 { } } -@keyframes food-in-right { +@keyframes food-in-top { from { opacity: 0; - transform: translate(64px, -52px) rotate(14deg) scale(0.6); + transform: translate(-30px, -70px) rotate(12deg) scale(0.6); } to { opacity: 1; @@ -2023,27 +2072,27 @@ h3 { } } -.food-fan-card.food-in-left { - animation: food-in-left 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; +.food-fan-card.food-in-bottom { + animation: food-in-bottom 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; } -.food-fan-card.food-in-right { - animation: food-in-right 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; +.food-fan-card.food-in-top { + animation: food-in-top 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; } -/* A pet that faints with a pending effect rises up out of the arena into the - set-aside row, shrinking from its arena size to the smaller set-aside size. */ +/* A pet that faints with a pending effect slides out of the arena into the + set-aside row on its left, shrinking to the smaller set-aside size. */ @keyframes setaside-in { from { opacity: 0; - transform: translateY(150px) scale(1.2); + transform: translateX(120px) scale(1.2); } 55% { opacity: 1; } to { opacity: 1; - transform: translateY(0) scale(1); + transform: translateX(0) scale(1); } } @@ -2071,61 +2120,68 @@ h3 { } /* A pet flips face-up off its deck: it starts over the stack (outer edge) still - turned edge-on, then slides in toward its arena slot while turning to face us. - The deck sits to the outer side of each unit, so each side pulls the reveal in - from its own edge. */ -@keyframes unit-reveal-left { + turned edge-on, then rises in toward its arena slot while turning to face us. + The deck sits below your pet and above the opponent's, so each side pulls the + reveal in from its own edge. */ +@keyframes unit-reveal-bottom { from { opacity: 0; - transform: translateX(-108px) perspective(760px) rotateY(-92deg) scale(0.92); + transform: translateY(108px) perspective(760px) rotateX(-92deg) scale(0.92); } 45% { opacity: 1; } to { opacity: 1; - transform: translateX(0) perspective(760px) rotateY(0deg) scale(1); + transform: translateY(0) perspective(760px) rotateX(0deg) scale(1); } } -@keyframes unit-reveal-right { +@keyframes unit-reveal-top { from { opacity: 0; - transform: translateX(108px) perspective(760px) rotateY(92deg) scale(0.92); + transform: translateY(-108px) perspective(760px) rotateX(92deg) scale(0.92); } 45% { opacity: 1; } to { opacity: 1; - transform: translateX(0) perspective(760px) rotateY(0deg) scale(1); + transform: translateY(0) perspective(760px) rotateX(0deg) scale(1); } } -.battle-unit.unit-reveal-left { - transform-origin: left center; - animation: unit-reveal-left 520ms cubic-bezier(0.2, 0.8, 0.3, 1); +.battle-unit.unit-reveal-bottom { + transform-origin: center bottom; + animation: unit-reveal-bottom 520ms cubic-bezier(0.2, 0.8, 0.3, 1); } -.battle-unit.unit-reveal-right { - transform-origin: right center; - animation: unit-reveal-right 520ms cubic-bezier(0.2, 0.8, 0.3, 1); +.battle-unit.unit-reveal-top { + transform-origin: center top; + animation: unit-reveal-top 520ms cubic-bezier(0.2, 0.8, 0.3, 1); } /* --- rock dice --- */ -/* A little tray of dice, drawn just below the throwing side's deck (see - .stack-dice). Fixed slots: the dice scramble in place, then settle showing - the rolled rock faces. */ +/* A little tray of dice, drawn beside the throwing side's deck on the side that + faces the centre seam — above your deck, below the opponent's. Fixed slots: + the dice scramble in place, then settle showing the rolled rock faces. */ .stack-dice { position: absolute; - top: calc(100% + 8px); left: 50%; transform: translateX(-50%); z-index: 8; pointer-events: none; } +.battle-deck-bottom .stack-dice { + bottom: calc(100% + 8px); +} + +.battle-deck-top .stack-dice { + top: calc(100% + 8px); +} + .dice-roll { display: flex; gap: 7px; @@ -2247,42 +2303,43 @@ h3 { filter: grayscale(1); } -@keyframes clash-left { +/* You (bottom) lunge up into the seam; the opponent (top) lunges down. */ +@keyframes clash-bottom { 0% { - transform: translateX(0); + transform: translateY(0); } 35% { - transform: translateX(26px) rotate(4deg); + transform: translateY(-26px) rotate(2deg); } 60% { - transform: translateX(-6px); + transform: translateY(6px); } 100% { - transform: translateX(0); + transform: translateY(0); } } -@keyframes clash-right { +@keyframes clash-top { 0% { - transform: translateX(0); + transform: translateY(0); } 35% { - transform: translateX(-26px) rotate(-4deg); + transform: translateY(26px) rotate(-2deg); } 60% { - transform: translateX(6px); + transform: translateY(-6px); } 100% { - transform: translateX(0); + transform: translateY(0); } } -.battle-unit.clash-left { - animation: clash-left 500ms ease; +.battle-unit.clash-bottom { + animation: clash-bottom 500ms ease; } -.battle-unit.clash-right { - animation: clash-right 500ms ease; +.battle-unit.clash-top { + animation: clash-top 500ms ease; } @keyframes dying { @@ -2462,9 +2519,6 @@ h3 { width: 92px; height: 128px; } - .arrange-marker { - height: 128px; - } .card-lg { width: 104px; height: 146px; @@ -2624,22 +2678,12 @@ h3 { max-width: min(60vw, 560px); } -/* first-right: the first pet sits on the right (matches your own arrangement). - first-left: the first pet sits on the left (a rival, who faces you from the - right of the board). The "⚔️ first" marker is the first DOM child, so it - lands next to the first card at whichever end. */ -.deck-peek-cards.first-right { - direction: rtl; -} +/* first-left: the lineup runs first-to-last, left to right, with the "⚔️ first" + marker as the first DOM child so it lands next to the first card. */ .deck-peek-cards.first-left { direction: ltr; } -/* Keep each card's own contents left-to-right regardless of row direction. */ -.deck-peek-cards > * { - direction: ltr; -} - /* Compact echo of the arrangement phase's "⚔️ first" indicator. */ .deck-peek-first { display: flex;