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.
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
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' : '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>
|
||
)}
|
||
</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
|
||
}
|