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>
)}
+46 -34
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,11 +350,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
const lineup = lineups?.[seat] ?? []
const stackEl = (
<div className={`battle-deck battle-deck-${pos}`}>
<div
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
onMouseEnter={
lineup.length
? (e) => setPeek({ seat, dir, rect: e.currentTarget.getBoundingClientRect() })
? (e) => setPeek({ seat, pos, rect: e.currentTarget.getBoundingClientRect() })
: undefined
}
onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))}
@@ -364,10 +369,10 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
<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'}`}>
// 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>
)}
@@ -377,16 +382,17 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
</div>
)}
{rockDice && lastEvent?.seat === seat && (
// The dice roll sits directly under the throwing side's deck.
// The dice roll sits beside the throwing side's deck.
<div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={dir} />
<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">
return (
<div className={`battle-side battle-side-${pos}`}>
{pos === 'top' ? (
<>
{stackEl}
{unitEl}
</div>
</>
) : (
<div className="battle-side">
<>
{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)
+20 -1
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' && (
<>
{/* 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>
+216 -172
View File
@@ -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;