Add pets for tiers 1-3.

This commit is contained in:
Greyson Parrelli
2026-07-23 00:10:17 -04:00
parent 612a4e6227
commit 8d43bbf61e
14 changed files with 1915 additions and 406 deletions
+208 -102
View File
@@ -1,123 +1,217 @@
import { useEffect, useMemo, useState } from 'react'
import type { BattleEvent, BattleUnit, ClientMessage, GameView } from '../types'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import { CardView } from './CardView'
import { artFor } from '../petArt'
interface Props {
view: GameView
send: (msg: ClientMessage) => void
}
const STEP_MS = 1400
interface UnitState {
unit: BattleUnit
index: number
interface UnitVis {
card: Card
foods: Card[]
bonus: number
damage: number
dead: boolean // died in an earlier step (gone)
dying: boolean // died in the step just played (animate out)
dying: boolean
}
// applyEvents replays the first `step` events onto a seat's lineup.
function applyEvents(view: GameView, seat: number, step: number): UnitState[] {
const battle = view.battle!
const events = battle.events ?? []
const states: UnitState[] = (battle.lineups[seat] ?? []).map((u, i) => ({
unit: u,
index: i,
damage: 0,
dead: false,
dying: false,
}))
const seatPos = seat === 0 ? 0 : 1
for (let k = 0; k < step && k < events.length; k++) {
interface SideVis {
stack: number
pending: Card[] // revealed foods waiting for a pet
unit: UnitVis | null
}
// Milliseconds each event type stays on screen during playback.
const EVENT_MS: Record<BattleEvent['type'], number> = {
reveal: 800,
summon: 1000,
rock: 1200,
clash: 1400,
eat: 1000,
}
// 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]
const s = states[ev.units[seatPos]]
if (!s) continue
s.damage = ev.damage[seatPos]
if (ev.died[seatPos]) {
s.dying = k === step - 1
s.dead = k < step - 1
switch (ev.type) {
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: s.pending.filter((f) => f.food === 'apple').length,
damage: 0,
dying: false,
}
s.pending = []
}
break
}
case 'summon':
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 'eat': {
const u = sides[ev.seat!].unit
if (u) u.bonus = ev.bonus ?? u.bonus
break
}
}
}
return states
return sides
}
// BattlePhase plays back the battle log: front pets clash, damage numbers
// fly, the fallen fade out, then the round result lands.
// 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 done = step >= events.length
const lastEvent = step > 0 ? events[step - 1] : null
useEffect(() => {
if (done) return
const t = window.setTimeout(() => setStep((s) => s + 1), STEP_MS)
const delay = EVENT_MS[events[step].type] ?? 1000
const t = window.setTimeout(() => setStep((s) => s + 1), delay)
return () => window.clearTimeout(t)
}, [step, done])
}, [step, done, events])
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 yourLine = useMemo(
() => applyEvents(view, youSeat, step),
[view, youSeat, step],
)
const oppLine = useMemo(
() => applyEvents(view, oppSeat, step),
[view, oppSeat, step],
)
const lastEvent = step > 0 ? events[step - 1] : null
function renderSide(line: UnitState[], side: 'left' | 'right', seat: number) {
const seatPos = seat === 0 ? 0 : 1
const frontIdx = line.find((s) => !s.dead && !s.dying)?.index
return (
<div className={`battle-side battle-side-${side}`}>
{line
.filter((s) => !s.dead)
.map((s) => {
const isFront = s.index === frontIdx
const clashing =
!done && lastEvent !== null && lastEvent.units[seatPos] === s.index
return (
<div
key={`${s.unit.card.id}-${clashing ? step : 'idle'}`}
className={[
'battle-unit',
clashing ? `clash-${side}` : '',
s.dying ? 'unit-dying' : '',
isFront && !s.dying ? 'is-front' : '',
]
.filter(Boolean)
.join(' ')}
>
<CardView
card={s.unit.card}
bonus={s.unit.bonus}
damage={s.damage}
dead={s.dying}
/>
{clashing && (
<div className="damage-pop">
{lastEvent!.damage[seatPos] -
prevDamage(events, step - 1, seatPos, s.index)}
</div>
)}
</div>
)
})}
</div>
)
}
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 eating = lastEvent?.type === 'eat' && lastEvent.seat === seat
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat
const stackEl = (
<div className="stackpile">
{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>
)}
</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>
)}
{(lastEvent?.type === 'clash' || rockVictim) && !done && (
<div className="damage-pop">
{lastEvent?.type === 'rock'
? lastEvent.roll === 0
? 'miss!'
: `${lastEvent.roll}`
: `${clashDamageTaken(events, step - 1, seat)}`}
</div>
)}
{eating && <div className="eat-pop">🍎 +1</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">
@@ -136,11 +230,16 @@ export function BattlePhase({ view, send }: Props) {
</div>
<div className="battlefield">
{renderSide(yourLine, 'left', youSeat)}
{renderSide(youSeat, 'left')}
<div className="battle-center" aria-hidden>
{!done && lastEvent?.type === 'rock' && (
<div className={`rock-fly ${lastEvent.target === youSeat ? 'rock-fly-left' : 'rock-fly-right'}`}>
🪨
</div>
)}
<span className="battle-center-bolt"></span>
</div>
{renderSide(oppLine, 'right', oppSeat)}
{renderSide(oppSeat, 'right')}
</div>
{done && (
@@ -174,18 +273,25 @@ export function BattlePhase({ view, send }: Props) {
)
}
// prevDamage finds the damage a unit had before the given event, so the
// floating number shows just this clash's hit.
function prevDamage(
events: BattleEvent[],
upto: number,
seatPos: number,
unitIndex: number,
): number {
let dmg = 0
for (let k = 0; k < upto; k++) {
const ev = events[k]
if (ev.units[seatPos] === unitIndex) dmg = ev.damage[seatPos]
// 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.target === seat) {
before = e.damageAfter ?? 0
break
}
if (e.type === 'clash') {
before = e.damage?.[seat] ?? 0
break
}
}
return dmg
return after - before
}