Add pets for tiers 1-3.
This commit is contained in:
+208
-102
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user