Files
super-auto-pets-board-game/web/src/components/BattlePhase.tsx
T

666 lines
22 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 { 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' : '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>,
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
}