666 lines
22 KiB
TypeScript
666 lines
22 KiB
TypeScript
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<SetStateAction<number>>
|
||
}
|
||
|
||
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<BattleEvent['type'], number> = {
|
||
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 <body> 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 = (
|
||
<div
|
||
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
|
||
onMouseEnter={
|
||
lineup.length
|
||
? (e) => 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 ? (
|
||
<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 (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'}`}>
|
||
<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 directly under the throwing side's deck.
|
||
<div className="stack-dice">
|
||
<DiceRoll key={step} dice={rockDice} side={dir} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
// Set-aside pets (Blowfish, Badger, …) sit in a row above 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 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 && (
|
||
<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-${dir}` : ''
|
||
}`}
|
||
style={{ zIndex: i + 1 }}
|
||
>
|
||
<CardView card={f} size="sm" />
|
||
</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-${dir}` : '',
|
||
s.unit.dying ? 'unit-dying' : '',
|
||
revealing ? `unit-reveal unit-reveal-${dir}` : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
>
|
||
<CardView
|
||
card={s.unit.card}
|
||
bonus={s.unit.bonus}
|
||
damage={s.unit.damage}
|
||
dead={s.unit.dying}
|
||
/>
|
||
{pop && (
|
||
<div key={`pop-${step}`} className={pop.startsWith('−') ? 'damage-pop' : 'fx-pop'}>
|
||
{pop}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
return dir === 'left' ? (
|
||
<div className="battle-side">
|
||
{stackEl}
|
||
{unitEl}
|
||
</div>
|
||
) : (
|
||
<div className="battle-side">
|
||
{unitEl}
|
||
{stackEl}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="battle">
|
||
<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>
|
||
</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(youSeat, 'left')}
|
||
<div className="battle-center" aria-hidden>
|
||
<span className="battle-center-bolt">⚡</span>
|
||
</div>
|
||
{renderSide(oppSeat, 'right')}
|
||
</div>
|
||
|
||
{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(
|
||
<div className={`deck-peek deck-peek-${peek.dir}`} style={style}>
|
||
<div className="deck-peek-label">
|
||
{peek.seat === youSeat ? 'Your' : 'Opponent’s'} deck · {lineup.length} card
|
||
{lineup.length !== 1 ? 's' : ''} (top first)
|
||
</div>
|
||
<div className="deck-peek-cards">
|
||
{lineup.map((c, i) => (
|
||
<CardView key={c.id || i} card={c} size="sm" />
|
||
))}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
})()}
|
||
|
||
{done && view.pendingBattle ? (
|
||
<BattleDecision
|
||
pd={view.pendingBattle}
|
||
youSeat={youSeat}
|
||
oppName={opp?.name ?? 'Opponent'}
|
||
send={send}
|
||
/>
|
||
) : (
|
||
done && (
|
||
<div className={`battle-result ${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>
|
||
)}
|
||
{draw && <div className="battle-result-sub">No trophies awarded</div>}
|
||
{acked ? (
|
||
<p className="muted">Waiting for opponent…</p>
|
||
) : (
|
||
<button
|
||
className="btn btn-primary btn-big"
|
||
onClick={() => {
|
||
setAcked(true)
|
||
send({ type: 'ready' })
|
||
}}
|
||
>
|
||
{battle.round >= view.maxRounds ? 'See final results' : 'Next round →'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<div className="battle-result">
|
||
<p className="muted">{oppName} is deciding {pd.petName}…</p>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="battle-result battle-decision">
|
||
<div className="battle-result-title">{pd.petName}</div>
|
||
<div className="battle-result-sub">
|
||
Spend Trumpets to throw 2 🪨 each — you hold {pd.trumpets} 🎺
|
||
</div>
|
||
<div className="battle-decision-options">
|
||
{Array.from({ length: pd.max + 1 }, (_, n) => (
|
||
<button
|
||
key={n}
|
||
className="btn btn-primary"
|
||
disabled={sent}
|
||
onClick={() => {
|
||
setSent(true)
|
||
send({ type: 'battleChoose', value: n })
|
||
}}
|
||
>
|
||
{n === 0 ? 'Spend none' : `${n} 🎺 → ${2 * n} 🪨`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</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
|
||
}
|