import { useEffect, useMemo, useRef, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import { createPortal } from 'react-dom' import type { BattleEvent, Card, ClientMessage, GameView } from '../types' import { CardView, CardZoom } from './CardView' import { DiceRoll, ROLL_MS } from './DiceRoll' import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed' import { useMediaQuery } from '../useMediaQuery' // How long a settled rock roll (and its damage) stays on screen before the // battle advances to the next step. const ROCK_PAUSE_MS = 650 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 spooked: number // Unicorn pack ailment: lowers the pet's clash attack exposed: number // Unicorn pack ailment: raises damage it takes per hit } 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, at 1ร— speed. // These are the base beats โ€” kept snappy so the replay reads quickly; the // battle-speed multiplier scales every one of them (and the CSS animations // keyed off --battle-speed) in lockstep. const EVENT_MS: Record = { prep: 450, reveal: 520, summon: 650, mill: 450, // 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: 320, clash: 900, shield: 600, strip: 720, steal: 720, eat: 640, heal: 600, setaside: 480, release: 380, trumpet: 540, prevent: 600, mana: 540, ailment: 600, bounce: 680, } 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, spooked: 0, exposed: 0, } break } s.stack-- const card = ev.card! if (card.kind === 'food' || card.kind === 'ailment') { // Ailments waiting on top of the deck sit in the pending fan until a // pet arrives; the backend then emits an 'ailment' event to attach it. s.pending.push(card) } else { s.unit = { card, foods: s.pending, bonus: ev.bonus ?? appleCount(s.pending), damage: 0, dying: false, spooked: 0, exposed: 0, } 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 'mana': break // pure animation; the Mana pool isn't drawn on the board case 'ailment': { // A pet gained (count > 0) an ailment; count 0 means Baku shrugged it. const u = sides[ev.seat!].unit const kind = ev.card?.ailment if (u && ev.count && kind === 'spooked') u.spooked += ev.count else if (u && ev.count && kind === 'exposed') u.exposed += ev.count break } case 'bounce': { // The target pet is sent to the bottom of its deck: it leaves play and // the deck grows by one. const t = sides[ev.target!] t.unit = null t.stack++ break } 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 'mana': if (ev.seat !== seat) return null return (ev.count ?? 0) >= 0 ? `๐Ÿ”ฎ +${ev.count}` : `๐Ÿ”ฎ ${ev.count}` case 'ailment': if (ev.seat !== seat) return null return ev.card?.ailment === 'spooked' ? '๐Ÿ‘ป' : '๐ŸŽฏ' case 'bounce': return ev.target === seat ? '๐ŸŒ€' : null 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 ?? [] // acked = the player committed to the next round (waiting on the opponent). const [acked, setAcked] = useState(false) // The result dialog pops when the replay ends. "Not yet" dismisses it so the // battle can be rewatched; a toolbar button then remains to advance the round. const [resultDismissed, setResultDismissed] = useState(false) // The deck-peek popover lives inside .battlefield, which scrolls on one axis // and hides the other โ€” 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; pos: 'top' | 'bottom'; rect: DOMRect } | null>( null, ) const lineups = battle.lineups const done = step >= events.length const lastEvent = step > 0 ? events[step - 1] : null // Playback speed multiplier, remembered across sessions. It divides every JS // step timer and feeds --battle-speed to the CSS so the animations quicken // right along with the pacing. const [speed, setSpeed] = useBattleSpeed() // paused freezes auto-advance so the player can walk the log manually. const [paused, setPaused] = useState(false) // Phone tap-to-magnify: tapping a pet/food opens a big readable copy (phones // can't hover to preview). Opening it pauses the replay; closing it resumes // only if the replay was actually playing when we tapped, so a manual pause // (or a finished battle) stays put. const isPhone = useMediaQuery('(max-width: 600px)') const [zoom, setZoom] = useState<{ card: Card; bonus: number; damage: number; dead: boolean } | null>( null, ) const resumeAfterZoom = useRef(false) const openZoom = (z: { card: Card; bonus?: number; damage?: number; dead?: boolean }) => { resumeAfterZoom.current = !paused && !done setPaused(true) setZoom({ card: z.card, bonus: z.bonus ?? 0, damage: z.damage ?? 0, dead: !!z.dead }) } const closeZoom = () => { setZoom(null) if (resumeAfterZoom.current) setPaused(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 / speed) return () => window.clearTimeout(t) }, [step, showingRock, speed]) 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. Every beat is scaled by // the speed multiplier so faster playback shortens the whole replay. const base = showingRock ? ROLL_MS + ROCK_PAUSE_MS : (EVENT_MS[events[step].type] ?? 1000) const t = window.setTimeout(() => setStep((s) => s + 1), base / speed) return () => window.clearTimeout(t) }, [step, done, paused, events, showingRock, speed]) // 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 // Commit to the next round: ack and hand off to the server. const proceed = () => { setAcked(true) send({ type: 'ready' }) } const nextLabel = battle.round >= view.maxRounds ? 'See final results' : 'Next round โ†’' // Show the result dialog when the replay finishes โ€” unless the player chose // "Not yet" to rewatch (then only the toolbar button remains). Once they've // committed, the dialog reappears to show the "waiting" state. const showResult = done && (acked || !resultDismissed) // 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 // 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 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?.kind === 'ailment'))) ? lastEvent.card?.id : null // 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 : 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, 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 to the left of 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 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 && (
{foods.map((f, i) => (
openZoom({ card: f }) : undefined} />
))}
) const unitEl = (
{setAsideEl} {foodFanEl} {s.unit && (
openZoom({ card: s.unit!.card, bonus: s.unit!.bonus, damage: s.unit!.damage, dead: s.unit!.dying, }) : undefined } /> {(s.unit.spooked > 0 || s.unit.exposed > 0) && (
{s.unit.spooked > 0 && ( ๐Ÿ‘ป{s.unit.spooked > 1 ? s.unit.spooked : ''} )} {s.unit.exposed > 0 && ( ๐ŸŽฏ{s.unit.exposed > 1 ? s.unit.exposed : ''} )}
)} {pop && (
{pop}
)}
)}
) return (
{pos === 'top' ? ( <> {stackEl} {unitEl} ) : ( <> {unitEl} {stackEl} )}
) } // A clash on screen flashes the centre seam; key the burst by step so every // clash re-triggers it. const centerClash = !done && lastEvent?.type === 'clash' return (

Battle! Round {battle.round}

{Math.min(step, events.length)} / {events.length} {/* Once the replay ends, a persistent way to advance the round โ€” the path forward after the result dialog is dismissed to rewatch. */} {done && !acked && ( )}
{you?.name} (you) VS {opp?.name}
{renderSide(oppSeat, 'top')}
โšก
{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 = { left: centerX, transform: 'translateX(-50%)', ...(peek.pos === 'bottom' ? { bottom: window.innerHeight - peek.rect.top + 8 } : { top: peek.rect.bottom + 8 }), } const mine = peek.seat === youSeat // 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 left)
โš”๏ธ first
{lineup.map((c, i) => ( ))}
, document.body, ) })()} {/* The result lands as a centered dialog โ€” always fully on screen, even on a phone where the arena fills the viewport. "Not yet" dismisses it so the battle can be rewatched; the toolbar keeps a way to advance. */} {showResult && createPortal(
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeatโ€ฆ'}
{!draw ? (
{view.players[battle.winnerSeat]?.name} wins {'๐Ÿ†'.repeat(battle.trophies)}
) : (
No trophies awarded
)} {acked ? (

Waiting for opponentโ€ฆ

) : (
)}
, document.body, )} {zoom && ( )}
) } // 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 }