Files
super-auto-pets-board-game/web/src/components/BattlePhase.tsx
T
Greyson Parrelli a2024229ff Hold the battle arena at a fixed height.
The play area grew and shrank as cards entered and left play. Two slots
were sized independently of the cards that fill them: the pet zone
reserved 150px for a 162px battle card, and the empty-deck placeholder
kept its desktop 84x116 on phones where the face-down card is 56x78. So
each faint/reveal moved the arena 12px per side on desktop, and a deck
running dry moved it 38px per side on a phone.

Every arena slot now derives from one set of --unit-*/--deck-* metrics on
.battle, and holds that size whether or not a card occupies it. Measured
across a battle's states (no pets, both revealed, one fainted, fans of
apples, decks dry), the arena height spread goes from 24px desktop /
76px phone to 0.

Also hide the arena's horizontal scrollbar: a wide apple fan made it
appear and vanish mid-battle, and with a content-driven height that alone
jumped the arena by the scrollbar's thickness. Touch, trackpad and
shift+wheel still scroll it.
2026-07-27 16:24:52 -04:00

791 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<SetStateAction<number>>
}
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<BattleEvent['type'], number> = {
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 <body> 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 = (
<div className={`battle-deck battle-deck-${pos}`}>
<div
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
onMouseEnter={
lineup.length
? (e) => 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 ? (
<div className="card-back">
<span className="card-back-count">{s.stack}</span>
</div>
) : (
<div className="card-slot-empty stack-empty" />
)}
{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.
<div className={`summon-pop summon-to-${pos}`}>
<CardView card={lastEvent.card} size="sm" />
</div>
)}
{milling && lastEvent?.card && (
<div className="summon-pop mill-pop">
<CardView card={lastEvent.card} size="sm" dead />
</div>
)}
{rockDice && lastEvent?.seat === seat && (
// The dice roll sits beside the throwing side's deck.
<div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={pos} speed={speed} />
</div>
)}
</div>
</div>
)
// 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) => (
<div
key={c.id}
className={`setaside-card ${s.leaving.includes(c.id) ? 'is-leaving' : ''} ${
c.id === newSetAsideId ? 'setaside-in' : ''
}`}
>
<CardView card={c} size="sm" />
</div>
))}
</div>
)
// 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 && (
<div className="food-fan">
{foods.map((f, i) => (
<div
key={f.id}
className={`food-fan-card ${s.leaving.includes(f.id) ? 'is-leaving' : ''} ${
f.id === newFoodId ? `food-in food-in-${pos}` : ''
}`}
style={{ zIndex: i + 1 }}
>
<CardView
card={f}
size="sm"
onClick={isPhone ? () => openZoom({ card: f }) : undefined}
/>
</div>
))}
</div>
)
const unitEl = (
<div className="battle-unit-zone">
{setAsideEl}
{foodFanEl}
{s.unit && (
<div
key={`${s.unit.card.id}-${clashing || rockVictim ? step : 'idle'}`}
className={[
'battle-unit',
clashing || clashDying ? `clash-${pos}` : '',
s.unit.dying ? 'unit-dying' : '',
revealing ? `unit-reveal unit-reveal-${pos}` : '',
]
.filter(Boolean)
.join(' ')}
>
<CardView
card={s.unit.card}
bonus={s.unit.bonus}
damage={s.unit.damage}
dead={s.unit.dying}
onClick={
isPhone
? () =>
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) && (
<div className="ailment-badges">
{s.unit.spooked > 0 && (
<span
className="ailment-badge ailment-spooked"
title={`Spooked ×${s.unit.spooked}: deals ${s.unit.spooked} less damage`}
>
👻{s.unit.spooked > 1 ? s.unit.spooked : ''}
</span>
)}
{s.unit.exposed > 0 && (
<span
className="ailment-badge ailment-exposed"
title={`Exposed ×${s.unit.exposed}: takes ${s.unit.exposed} extra damage per hit`}
>
🎯{s.unit.exposed > 1 ? s.unit.exposed : ''}
</span>
)}
</div>
)}
{pop && (
<div key={`pop-${step}`} className={pop.startsWith('') ? 'damage-pop' : 'fx-pop'}>
{pop}
</div>
)}
</div>
)}
</div>
)
return (
<div className={`battle-side battle-side-${pos}`}>
{pos === 'top' ? (
<>
{stackEl}
{unitEl}
</>
) : (
<>
{unitEl}
{stackEl}
</>
)}
</div>
)
}
// 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 (
<div className="battle" style={{ '--battle-speed': speed } as React.CSSProperties}>
<div className="battle-header">
<h2>Battle! Round {battle.round}</h2>
<div className="battle-controls">
<button className="btn btn-ghost btn-sm" onClick={restart} title="Replay from the start">
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => stepTo(step - 1)}
disabled={step === 0}
title="Previous step"
>
|
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setPaused((p) => !p)}
disabled={done}
title={paused ? 'Play' : 'Pause'}
>
{paused || done ? '▶' : '⏸'}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => stepTo(step + 1)}
disabled={done}
title="Next step"
>
|
</button>
<span className="battle-step">
{Math.min(step, events.length)} / {events.length}
</span>
<button
className="btn btn-ghost btn-sm"
onClick={() => setStep(events.length)}
disabled={done}
title="Skip to the end"
>
Skip
</button>
<button
className="btn btn-ghost btn-sm battle-speed-toggle"
onClick={() => {
const i = SPEED_OPTIONS.indexOf(speed as (typeof SPEED_OPTIONS)[number])
setSpeed(SPEED_OPTIONS[(i + 1) % SPEED_OPTIONS.length])
}}
title="Playback speed — tap to cycle"
aria-label={`Playback speed ${speed}×, tap to change`}
>
{speed}×
</button>
{/* Once the replay ends, a persistent way to advance the round — the
path forward after the result dialog is dismissed to rewatch. */}
{done && !acked && (
<button
className="btn btn-primary btn-sm battle-next"
onClick={proceed}
title={nextLabel}
>
{battle.round >= view.maxRounds ? 'Results →' : 'Next round →'}
</button>
)}
</div>
</div>
<div className="battle-names">
<span>{you?.name} (you)</span>
<span className="battle-vs">VS</span>
<span>{opp?.name}</span>
</div>
<div className="battlefield">
{renderSide(oppSeat, 'top')}
<div
key={centerClash ? `clash-${step}` : 'center'}
className={`battle-center ${centerClash ? 'battle-center-clash' : ''}`}
aria-hidden
>
<span className="battle-center-bolt"></span>
</div>
{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 = {
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(
<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 left)
</div>
<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" />
))}
</div>
</div>,
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(
<div className="modal-backdrop">
<div className={`modal battle-result-modal ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
<div className="battle-result-title">
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
</div>
{!draw ? (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins {'🏆'.repeat(battle.trophies)}
</div>
) : (
<div className="battle-result-sub">No trophies awarded</div>
)}
{acked ? (
<p className="muted">Waiting for opponent</p>
) : (
<div className="actions">
<button className="btn btn-ghost" onClick={() => setResultDismissed(true)}>
Not yet
</button>
<button className="btn btn-primary btn-big" onClick={proceed}>
{nextLabel}
</button>
</div>
)}
</div>
</div>,
document.body,
)}
{zoom && (
<CardZoom
card={zoom.card}
bonus={zoom.bonus}
damage={zoom.damage}
dead={zoom.dead}
onClose={closeZoom}
/>
)}
</div>
)
}
// 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
}