Files
super-auto-pets-board-game/web/src/components/BattlePhase.tsx
T
Greyson Parrelli e5604cde8e Add step-through replay controls to the battle view
Prev/next/play-pause/restart and a step counter let a player walk the
battle log manually and replay it before hitting Next. Local-only view
state; nothing is shared or sent to the server.
2026-07-23 01:14:08 -04:00

449 lines
14 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, 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<BattleEvent['type'], number> = {
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<number | 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)
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 = (
<div
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
onMouseEnter={lineup.length ? () => setPeekSeat(seat) : undefined}
onMouseLeave={() => setPeekSeat((s) => (s === seat ? null : s))}
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 && (
<div className="summon-pop">
<CardView card={lastEvent.card} size="sm" />
</div>
)}
{milling && lastEvent?.card && (
<div className="summon-pop mill-pop">
<CardView card={lastEvent.card} size="sm" dead />
</div>
)}
{peekSeat === seat && lineup.length > 0 && (
<div className={`deck-peek deck-peek-${dir}`}>
<div className="deck-peek-label">
{seat === youSeat ? 'Your' : 'Opponents'} 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>
)}
</div>
)
const foodsEl = (
<div className="pending-foods">
{s.pending.map((f) => (
<span key={f.id} className="food-chip" title={f.name}>
{artFor(f.name)}
</span>
))}
</div>
)
const unitEl = (
<div className="battle-unit-zone">
{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' : '',
]
.filter(Boolean)
.join(' ')}
>
<CardView
card={s.unit.card}
bonus={s.unit.bonus}
damage={s.unit.damage}
dead={s.unit.dying}
/>
{s.unit.foods.length > 0 && (
<div className="unit-foods">
{s.unit.foods.map((f) => (
<span key={f.id} title={f.name}>
{artFor(f.name)}
</span>
))}
</div>
)}
{pop && (
<div key={`pop-${step}`} className={pop.startsWith('') ? 'damage-pop' : 'fx-pop'}>
{pop}
</div>
)}
</div>
)}
</div>
)
return dir === 'left' ? (
<div className="battle-side">
{stackEl}
{foodsEl}
{unitEl}
</div>
) : (
<div className="battle-side">
{unitEl}
{foodsEl}
{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 ? '▶' : '⏸'}
</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>
{!done && (
<button className="btn btn-ghost btn-sm" onClick={() => setStep(events.length)}>
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>
{!done && lastEvent?.type === 'rock' &&
(lastEvent.dice && lastEvent.dice.length > 0 ? (
// Remount per rock event (keyed by step) so the tumble replays.
<DiceTray key={step} dice={lastEvent.dice} />
) : (
<div className={`rock-fly ${lastEvent.target === youSeat ? 'rock-fly-left' : 'rock-fly-right'}`}>
🪨
</div>
))}
<span className="battle-center-bolt"></span>
</div>
{renderSide(oppSeat, 'right')}
</div>
{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>
)
}
// 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
}