import { useEffect, useMemo, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import { createPortal } from 'react-dom' import type { BattleEvent, Card, ClientMessage, GameView, PendingBattleDecision } from '../types' import { CardView } from './CardView' import { DiceRoll, ROLL_MS } from './DiceRoll' // How long a settled rock roll (and its damage) stays on screen before the // battle advances to the next step. const ROCK_PAUSE_MS = 1000 interface Props { view: GameView send: (msg: ClientMessage) => void // Step is owned by Table so the event log can stay in sync with the replay. step: number setStep: Dispatch> } interface UnitVis { card: Card foods: Card[] bonus: number damage: number dying: boolean } interface SideVis { stack: number pending: Card[] // foods revealed (or prepped) waiting for a pet unit: UnitVis | null setAside: Card[] // fainted pets kept beside the arena with a pending effect // Ids of cards (set-aside pets or spent food perks) released this step: kept // in their arrays for one beat so they can animate out, cleared next event. leaving: string[] } // Milliseconds each event type stays on screen during playback. const EVENT_MS: Record = { prep: 700, reveal: 800, summon: 1000, mill: 700, // Lead-in before the dice appear; the roll itself is timed by ROLL_MS + // ROCK_PAUSE_MS once it's on screen (see the auto-advance effect). rock: 500, clash: 1400, shield: 900, strip: 1100, steal: 1100, eat: 1000, heal: 900, setaside: 700, release: 500, trumpet: 800, prevent: 900, } const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length // replay applies the first `upto` events to fresh stacks and returns each // seat's visual state. Units that died in the last applied event are still // present with dying=true so they can animate out. function replay(events: BattleEvent[], stackSizes: number[], upto: number): SideVis[] { const sides: SideVis[] = stackSizes.map((n) => ({ stack: n, pending: [], unit: null, setAside: [], leaving: [], })) for (let k = 0; k < upto && k < events.length; k++) { for (const s of sides) { if (s.unit?.dying) s.unit = null // clear last step's casualties if (s.leaving.length) { // Drop cards that finished animating out last step. const gone = new Set(s.leaving) s.setAside = s.setAside.filter((c) => !gone.has(c.id)) if (s.unit) s.unit.foods = s.unit.foods.filter((c) => !gone.has(c.id)) s.pending = s.pending.filter((c) => !gone.has(c.id)) s.leaving = [] } } const ev = events[k] switch (ev.type) { case 'prep': sides[ev.seat!].pending.push(ev.card!) break case 'reveal': { const s = sides[ev.seat!] // The Golden Retriever is summoned straight into play, not flipped off // the deck, so it doesn't decrement the stack. if (ev.card?.name === 'Golden Retriever') { const trumpets = ev.count ?? 0 s.unit = { card: ev.card!, // Show the Trumpets that powered it, fanned like food tokens. foods: Array.from({ length: trumpets }, (_, i) => ({ id: `${ev.card!.id}-t${i}`, kind: 'food' as const, name: 'Trumpet', food: 'trumpet', })), bonus: 0, damage: 0, dying: false, } break } s.stack-- const card = ev.card! if (card.kind === 'food') { s.pending.push(card) } else { s.unit = { card, foods: s.pending, bonus: ev.bonus ?? appleCount(s.pending), damage: 0, dying: false, } s.pending = [] } break } case 'summon': sides[ev.seat!].stack++ break case 'mill': sides[ev.seat!].stack-- break case 'rock': { const u = sides[ev.target!].unit if (u) { u.damage = ev.damageAfter ?? u.damage if (ev.targetDied) u.dying = true } break } case 'clash': sides.forEach((s, seat) => { if (!s.unit) return s.unit.damage = ev.damage?.[seat] ?? s.unit.damage if (ev.died?.[seat]) s.unit.dying = true }) break case 'strip': { const u = sides[ev.target!].unit if (u) { u.bonus -= appleCount(u.foods) u.foods = [] if (ev.targetDied) u.dying = true } break } case 'steal': { const from = sides[ev.target!].unit const to = sides[ev.seat!].unit const n = ev.count ?? 0 if (from && to) { let moved = 0 from.foods = from.foods.filter((f) => { if (f.food === 'apple' && moved < n) { moved++ to.foods.push(f) return false } return true }) from.bonus -= moved to.bonus += moved if (ev.targetDied) from.dying = true } break } case 'eat': { const u = sides[ev.seat!].unit if (u) u.bonus = ev.bonus ?? u.bonus break } case 'heal': { const u = sides[ev.seat!].unit if (u) u.damage = ev.damageAfter ?? u.damage break } case 'shield': break // pure animation; no state change case 'trumpet': break // pure animation; the pool isn't drawn on the board case 'prevent': { // Cone Snail shaved damage off the hit; the reduced total rides along. const u = sides[ev.seat!].unit if (u) u.damage = ev.damageAfter ?? u.damage break } case 'setaside': if (ev.card) sides[ev.seat!].setAside.push(ev.card) break case 'release': { // A spent card leaves the play area: a set-aside pet (Turtle) or a // used-up food perk (Melon), which lives in the pet's food fan. Flag it // rather than removing it, so it animates out before unmounting. const id = ev.card?.id if (id) sides[ev.seat!].leaving.push(id) break } } } return sides } // unitPop decides the floating effect text over a seat's pet for the event // currently playing. Null = nothing. function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], step: number): string | null { if (!ev) return null switch (ev.type) { case 'rock': if (ev.target !== seat) return null return ev.roll === 0 ? 'miss!' : `−${ev.roll}` case 'clash': { const taken = clashDamageTaken(events, step - 1, seat) return taken > 0 ? `−${taken}` : null } case 'shield': return ev.seat === seat ? '🛡️' : null case 'prevent': return ev.seat === seat ? `🛡️ −${ev.count}` : null case 'trumpet': if (ev.seat !== seat) return null return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}` case 'eat': return ev.seat === seat ? '🍎' : null case 'heal': return ev.seat === seat ? '💚 +1' : null case 'strip': return ev.target === seat ? '💨' : null case 'steal': if (ev.seat === seat) return `+🍎×${ev.count}` if (ev.target === seat) return `−🍎×${ev.count}` return null default: return null } } // BattlePhase plays back the battle log: cards flip off each deck, rocks // fly, pets clash, the fallen fade out, then the round result lands. export function BattlePhase({ view, send, step, setStep }: Props) { const battle = view.battle! const events = battle.events ?? [] const [acked, setAcked] = useState(false) // The deck-peek popover lives inside .battlefield, which sets overflow-x // (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>( null, ) const lineups = battle.lineups const done = step >= events.length const lastEvent = step > 0 ? events[step - 1] : null // paused freezes auto-advance so the player can walk the log manually. const [paused, setPaused] = useState(false) // A rock event on screen scrambles its dice first; only once they settle do // we apply the damage and reveal the result. const showingRock = !done && lastEvent?.type === 'rock' const [rockSettled, setRockSettled] = useState(false) useEffect(() => { if (!showingRock) { setRockSettled(true) return } setRockSettled(false) const t = window.setTimeout(() => setRockSettled(true), ROLL_MS) return () => window.clearTimeout(t) }, [step, showingRock]) useEffect(() => { if (done || paused) return // The rock roll owns its timing: hold until the dice settle, then a full // beat with the damage showing before moving on. const delay = showingRock ? ROLL_MS + ROCK_PAUSE_MS : (EVENT_MS[events[step].type] ?? 1000) const t = window.setTimeout(() => setStep((s) => s + 1), delay) return () => window.clearTimeout(t) }, [step, done, paused, events, showingRock]) // Manual controls. Stepping by hand pauses playback so it doesn't fight you. const stepTo = (n: number) => { setPaused(true) setStep(Math.max(0, Math.min(events.length, n))) } const restart = () => { setPaused(false) setStep(0) } // While the dice are still scrambling, replay only up to just before the // rock so its damage/faint hasn't landed yet. const upto = showingRock && !rockSettled ? step - 1 : step const sides = useMemo( () => replay(events, battle.stackSizes, upto), [events, battle.stackSizes, upto], ) const youSeat = view.youSeat const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1 const you = view.players[youSeat] const opp = view.players[oppSeat] const won = battle.winnerSeat === youSeat const draw = battle.winnerSeat < 0 // Dice for the rock event currently on screen. renderSide draws them under // the throwing side's deck; `step` keys the tray so the scramble replays for // every rock event. const rockDice = !done && lastEvent?.type === 'rock' && lastEvent.dice && lastEvent.dice.length > 0 ? lastEvent.dice : null function renderSide(seat: number, dir: 'left' | 'right') { const s = sides[seat] const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying const clashDying = lastEvent?.type === 'clash' && s.unit?.dying const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat // A food (apple) that just landed on this side's fan — reveal off the deck or // a Battle Prep hand-out — animates in from the deck rather than popping. const newFoodId = !done && lastEvent?.seat === seat && (lastEvent.type === 'prep' || (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. const newSetAsideId = !done && lastEvent?.type === 'setaside' && lastEvent.seat === seat ? lastEvent.card?.id : null // Hold the −N / miss! pop until the dice settle. const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, seat, events, step) 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.
)}
) // Set-aside pets (Blowfish, Badger, …) sit in a row above the active pet // until their pending effect resolves. const setAsideEl = s.setAside.length > 0 && (
{s.setAside.map((c) => (
))}
) // 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. const foods = s.unit ? s.unit.foods : s.pending const foodFanEl = foods.length > 0 && (
{foods.map((f, i) => (
))}
) const unitEl = (
{setAsideEl} {foodFanEl} {s.unit && (
{pop && (
{pop}
)}
)}
) return dir === 'left' ? (
{stackEl} {unitEl}
) : (
{unitEl} {stackEl}
) } return (

Battle! Round {battle.round}

{Math.min(step, events.length)} / {events.length}
{you?.name} (you) VS {opp?.name}
{renderSide(youSeat, 'left')}
{renderSide(oppSeat, 'right')}
{peek && (() => { const lineup = lineups?.[peek.seat] ?? [] if (!lineup.length) return null const style: React.CSSProperties = { bottom: window.innerHeight - peek.rect.top + 8, ...(peek.dir === 'left' ? { left: peek.rect.left } : { right: window.innerWidth - peek.rect.right }), } return createPortal(
{peek.seat === youSeat ? 'Your' : 'Opponent’s'} deck · {lineup.length} card {lineup.length !== 1 ? 's' : ''} (top first)
{lineup.map((c, i) => ( ))}
, document.body, ) })()} {done && view.pendingBattle ? ( ) : ( done && (
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
{!draw && (
{view.players[battle.winnerSeat]?.name} wins{' '} {'🏆'.repeat(battle.trophies)}
)} {draw &&
No trophies awarded
} {acked ? (

Waiting for opponent…

) : ( )}
) )}
) } // BattleDecision is the mid-battle prompt (Golden pack: Nurse Shark). The // deciding player picks how many Trumpets to spend; the other player waits. function BattleDecision({ pd, youSeat, oppName, send, }: { pd: PendingBattleDecision youSeat: number oppName: string send: (msg: ClientMessage) => void }) { const [sent, setSent] = useState(false) // Reset when a fresh decision arrives (e.g. a second Nurse Shark). useEffect(() => setSent(false), [pd.seat, pd.trumpets, pd.max]) if (pd.seat !== youSeat) { return (

{oppName} is deciding {pd.petName}…

) } return (
{pd.petName}
Spend Trumpets to throw 2 🪨 each — you hold {pd.trumpets} 🎺
{Array.from({ length: pd.max + 1 }, (_, n) => ( ))}
) } // clashDamageTaken computes how much damage a seat's pet took in the clash // at event index `idx` (its damage total there minus its total beforehand). function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number { const ev = events[idx] if (ev?.type !== 'clash') return 0 const after = ev.damage?.[seat] ?? 0 // Walk back to the pet's damage before this clash. let before = 0 for (let k = idx - 1; k >= 0; k--) { const e = events[k] if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === seat) { before = e.damageAfter ?? 0 break } if (e.type === 'clash') { before = e.damage?.[seat] ?? 0 break } } return after - before }