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.
791 lines
28 KiB
TypeScript
791 lines
28 KiB
TypeScript
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' : 'Opponent’s'} 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
|
||
}
|