import { useEffect, useMemo, useState } from 'react' import type { BattleEvent, Card, ClientMessage, GameView } from '../types' import { CardView } from './CardView' import { DiceTray } from './Dice' import { artFor } from '../petArt' interface Props { view: GameView send: (msg: ClientMessage) => void } 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 } // Milliseconds each event type stays on screen during playback. const EVENT_MS: Record = { prep: 700, reveal: 800, summon: 1000, mill: 700, rock: 1500, clash: 1400, shield: 900, strip: 1100, steal: 1100, eat: 1000, heal: 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 })) 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 } const ev = events[k] switch (ev.type) { case 'prep': sides[ev.seat!].pending.push(ev.card!) break case 'reveal': { const s = sides[ev.seat!] 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 } } 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 '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 }: Props) { const battle = view.battle! const events = battle.events ?? [] const [step, setStep] = useState(0) const [acked, setAcked] = useState(false) const [peekSeat, setPeekSeat] = useState(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) useEffect(() => { if (done || paused) return const delay = EVENT_MS[events[step].type] ?? 1000 const t = window.setTimeout(() => setStep((s) => s + 1), delay) return () => window.clearTimeout(t) }, [step, done, paused, events]) // 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) } const sides = useMemo( () => replay(events, battle.stackSizes, step), [events, battle.stackSizes, step], ) 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 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 const pop = done ? null : unitPop(lastEvent, seat, events, step) const lineup = lineups?.[seat] ?? [] const stackEl = (
setPeekSeat(seat) : undefined} onMouseLeave={() => setPeekSeat((s) => (s === seat ? null : s))} title={lineup.length ? 'Hover to see the whole deck' : undefined} > {s.stack > 0 ? (
{s.stack}
) : (
)} {summoning && lastEvent?.card && (
)} {milling && lastEvent?.card && (
)} {peekSeat === seat && lineup.length > 0 && (
{seat === youSeat ? 'Your' : 'Opponent’s'} deck · {lineup.length} card {lineup.length !== 1 ? 's' : ''} (top first)
{lineup.map((c, i) => ( ))}
)}
) const foodsEl = (
{s.pending.map((f) => ( {artFor(f.name)} ))}
) const unitEl = (
{s.unit && (
{s.unit.foods.length > 0 && (
{s.unit.foods.map((f) => ( {artFor(f.name)} ))}
)} {pop && (
{pop}
)}
)}
) return dir === 'left' ? (
{stackEl} {foodsEl} {unitEl}
) : (
{unitEl} {foodsEl} {stackEl}
) } return (

Battle! Round {battle.round}

{Math.min(step, events.length)} / {events.length} {!done && ( )}
{you?.name} (you) VS {opp?.name}
{renderSide(youSeat, 'left')}
{!done && lastEvent?.type === 'rock' && (lastEvent.dice && lastEvent.dice.length > 0 ? ( // Remount per rock event (keyed by step) so the tumble replays. ) : (
🪨
))}
{renderSide(oppSeat, 'right')}
{done && (
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
{!draw && (
{view.players[battle.winnerSeat]?.name} wins{' '} {'🏆'.repeat(battle.trophies)}
)} {draw &&
No trophies awarded
} {acked ? (

Waiting for opponent…

) : ( )}
)}
) } // 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 }