Narrate the battle in the event log, synced to the replay step.

This commit is contained in:
Greyson Parrelli
2026-07-23 08:07:17 -04:00
parent 506c6e5b55
commit 63b64bc1d8
5 changed files with 151 additions and 26 deletions
+72 -19
View File
@@ -1,5 +1,7 @@
package game package game
import "fmt"
// BattleUnit is a pet in play with its attached foods applied. Power // BattleUnit is a pet in play with its attached foods applied. Power
// (attack) is unaffected by damage; a unit dies when Damage >= Power. // (attack) is unaffected by damage; a unit dies when Damage >= Power.
type BattleUnit struct { type BattleUnit struct {
@@ -100,6 +102,10 @@ type BattleEvent struct {
TargetDied bool `json:"targetDied,omitempty"` TargetDied bool `json:"targetDied,omitempty"`
// eat: the pet's power bonus after eating. // eat: the pet's power bonus after eating.
Bonus int `json:"bonus"` Bonus int `json:"bonus"`
// Text is a human-readable description of this step for the event log,
// e.g. "Ant's faint effect summons a Bee." Empty for steps not worth a
// line (they still animate).
Text string `json:"text,omitempty"`
} }
// BattleResult is the full, public record of one round's battle. // BattleResult is the full, public record of one round's battle.
@@ -194,6 +200,8 @@ func (g *Game) resolveBattle() {
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)} res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
sides := make([]*battleSide, n) sides := make([]*battleSide, n)
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) } emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
// pname is the owning player's display name for a seat, for log text.
pname := func(seat int) string { return g.Players[seat].Name }
for _, p := range g.Players { for _, p := range g.Players {
s := &battleSide{stack: append([]Card(nil), p.Deck...)} s := &battleSide{stack: append([]Card(nil), p.Deck...)}
@@ -222,17 +230,19 @@ func (g *Game) resolveBattle() {
for range e.count() { for range e.count() {
apple := g.newApple() apple := g.newApple()
sides[p.Seat].pending = append(sides[p.Seat].pending, apple) sides[p.Seat].pending = append(sides[p.Seat].pending, apple)
emit(BattleEvent{Type: "prep", Seat: p.Seat, Card: &apple}) emit(BattleEvent{Type: "prep", Seat: p.Seat, Card: &apple,
Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(p.Seat))})
} }
} }
} }
} }
} }
summon := func(seat int, c Card) { summon := func(seat int, c Card, cause string) {
s := sides[seat] s := sides[seat]
s.stack = append([]Card{c}, s.stack...) s.stack = append([]Card{c}, s.stack...)
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c}) emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
Text: fmt.Sprintf("%s summons %s %s.", cause, article(c.Name), c.Name)})
} }
mintFor := func(kind string) Card { mintFor := func(kind string) Card {
if kind == "bee" { if kind == "bee" {
@@ -297,13 +307,13 @@ func (g *Game) resolveBattle() {
target = (seat + 1) % n target = (seat + 1) % n
} }
for range effectCount(e, s, u) { for range effectCount(e, s, u) {
summon(target, mintFor(e.Card)) summon(target, mintFor(e.Card), fmt.Sprintf("%s's faint effect", u.Card.Name))
} }
case ActionRecycleApples: case ActionRecycleApples:
recycled := 0 recycled := 0
for _, f := range u.Foods { for _, f := range u.Foods {
if f.Food == FoodApple && recycled < e.count() { if f.Food == FoodApple && recycled < e.count() {
summon(seat, f) summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name))
recycled++ recycled++
} }
} }
@@ -334,7 +344,8 @@ func (g *Game) resolveBattle() {
healed := min(effectCount(e, os, os.unit), os.unit.Damage) healed := min(effectCount(e, os, os.unit), os.unit.Damage)
if healed > 0 { if healed > 0 {
os.unit.Damage -= healed os.unit.Damage -= healed
emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage}) emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage,
Text: fmt.Sprintf("%s heals %d after an enemy faints.", os.unit.Card.Name, healed)})
} }
} }
} }
@@ -355,10 +366,11 @@ func (g *Game) resolveBattle() {
u.Foods = append(u.Foods, g.newApple()) u.Foods = append(u.Foods, g.newApple())
u.Bonus++ u.Bonus++
} }
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus}) emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
Text: fmt.Sprintf("%s eats an apple after being hurt (now +%d).", u.Card.Name, u.Bonus)})
case ActionSummonTop: case ActionSummonTop:
for range effectCount(e, sides[seat], u) { for range effectCount(e, sides[seat], u) {
summon(seat, mintFor(e.Card)) summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
} }
case ActionShieldSelf: case ActionShieldSelf:
u.Shield += e.count() u.Shield += e.count()
@@ -381,12 +393,26 @@ func (g *Game) resolveBattle() {
} }
dealt, blocked := hitUnit(target, roll) dealt, blocked := hitUnit(target, roll)
died := !tu.Alive() died := !tu.Alive()
thrower := pname(from)
if su := sides[from].unit; su != nil {
thrower = su.Card.Name
}
var rockTxt string
switch {
case roll == 0:
rockTxt = fmt.Sprintf("%s's rocks miss %s.", thrower, tu.Card.Name)
case died:
rockTxt = fmt.Sprintf("%s pelts %s for %d — it faints.", thrower, tu.Card.Name, roll)
default:
rockTxt = fmt.Sprintf("%s pelts %s for %d.", thrower, tu.Card.Name, roll)
}
emit(BattleEvent{ emit(BattleEvent{
Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces, Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces,
DamageAfter: tu.Damage, TargetDied: died, DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt,
}) })
if blocked { if blocked {
emit(BattleEvent{Type: "shield", Seat: target}) emit(BattleEvent{Type: "shield", Seat: target,
Text: fmt.Sprintf("%s blocks the rocks with a shield.", tu.Card.Name)})
} }
if died { if died {
faint(target, tu) faint(target, tu)
@@ -425,7 +451,8 @@ func (g *Game) resolveBattle() {
c := s.stack[0] c := s.stack[0]
s.stack = s.stack[1:] s.stack = s.stack[1:]
if c.IsFood() { if c.IsFood() {
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c}) emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c,
Text: fmt.Sprintf("%s's %s is set aside for the next pet.", pname(seat), c.Name)})
s.pending = append(s.pending, c) s.pending = append(s.pending, c)
continue continue
} }
@@ -437,7 +464,11 @@ func (g *Game) resolveBattle() {
} }
// Pets carry their starting bonus (foods + auras) in the // Pets carry their starting bonus (foods + auras) in the
// reveal event so clients can display it directly. // reveal event so clients can display it directly.
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus}) revealTxt := fmt.Sprintf("%s's %s enters the fray.", pname(seat), c.Name)
if u.Bonus > 0 {
revealTxt = fmt.Sprintf("%s's %s enters the fray (+%d).", pname(seat), c.Name, u.Bonus)
}
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus, Text: revealTxt})
s.pending = nil s.pending = nil
s.unit = u s.unit = u
newlyPlayed[seat] = true newlyPlayed[seat] = true
@@ -543,7 +574,8 @@ func (g *Game) resolveBattle() {
tu.Bonus -= appleCount(tu.Foods) tu.Bonus -= appleCount(tu.Foods)
tu.Foods = nil tu.Foods = nil
died := !tu.Alive() died := !tu.Alive()
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died}) emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died,
Text: fmt.Sprintf("%s strips %s's apples away.", q.unit.Card.Name, tu.Card.Name)})
if died { if died {
faint(t, tu) faint(t, tu)
sides[t].unit = nil sides[t].unit = nil
@@ -573,7 +605,8 @@ func (g *Game) resolveBattle() {
tu.Bonus -= moved tu.Bonus -= moved
q.unit.Bonus += moved q.unit.Bonus += moved
died := !tu.Alive() died := !tu.Alive()
emit(BattleEvent{Type: "steal", Seat: q.seat, Target: t, Count: moved, TargetDied: died}) emit(BattleEvent{Type: "steal", Seat: q.seat, Target: t, Count: moved, TargetDied: died,
Text: fmt.Sprintf("%s steals %d apple%s from %s.", q.unit.Card.Name, moved, plural(moved), tu.Card.Name)})
if died { if died {
faint(t, tu) faint(t, tu)
sides[t].unit = nil sides[t].unit = nil
@@ -588,11 +621,12 @@ func (g *Game) resolveBattle() {
break break
} }
ts.stack = ts.stack[1:] ts.stack = ts.stack[1:]
emit(BattleEvent{Type: "mill", Seat: t, Card: &top}) emit(BattleEvent{Type: "mill", Seat: t, Card: &top,
Text: fmt.Sprintf("%s burns %s off %s's deck.", q.unit.Card.Name, top.Name, pname(t))})
} }
case ActionSummonTop: case ActionSummonTop:
for range effectCount(q.effect, sides[q.seat], q.unit) { for range effectCount(q.effect, sides[q.seat], q.unit) {
summon(q.seat, mintFor(q.effect.Card)) summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name))
} }
case ActionEatApple: case ActionEatApple:
count := effectCount(q.effect, sides[q.seat], q.unit) count := effectCount(q.effect, sides[q.seat], q.unit)
@@ -601,7 +635,8 @@ func (g *Game) resolveBattle() {
q.unit.Foods = append(q.unit.Foods, g.newApple()) q.unit.Foods = append(q.unit.Foods, g.newApple())
q.unit.Bonus++ q.unit.Bonus++
} }
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus}) emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
Text: fmt.Sprintf("%s eats an apple (now +%d).", q.unit.Card.Name, q.unit.Bonus)})
} }
} }
} }
@@ -622,16 +657,29 @@ func (g *Game) resolveBattle() {
if dealtB > 0 && ua.hasKnockout() { if dealtB > 0 && ua.hasKnockout() {
ub.Damage = max(ub.Damage, ub.Power()) ub.Damage = max(ub.Damage, ub.Power())
} }
clashTxt := fmt.Sprintf("%s's %s and %s's %s trade blows.",
pname(0), ua.Card.Name, pname(1), ub.Card.Name)
switch da, db := !ua.Alive(), !ub.Alive(); {
case da && db:
clashTxt += " Both faint."
case da:
clashTxt += fmt.Sprintf(" %s faints.", ua.Card.Name)
case db:
clashTxt += fmt.Sprintf(" %s faints.", ub.Card.Name)
}
emit(BattleEvent{ emit(BattleEvent{
Type: "clash", Type: "clash",
Damage: []int{ua.Damage, ub.Damage}, Damage: []int{ua.Damage, ub.Damage},
Died: []bool{!ua.Alive(), !ub.Alive()}, Died: []bool{!ua.Alive(), !ub.Alive()},
Text: clashTxt,
}) })
if blockedA { if blockedA {
emit(BattleEvent{Type: "shield", Seat: 0}) emit(BattleEvent{Type: "shield", Seat: 0,
Text: fmt.Sprintf("%s blocks the hit with a shield.", ua.Card.Name)})
} }
if blockedB { if blockedB {
emit(BattleEvent{Type: "shield", Seat: 1}) emit(BattleEvent{Type: "shield", Seat: 1,
Text: fmt.Sprintf("%s blocks the hit with a shield.", ub.Card.Name)})
} }
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && !blockedA && !blockedB { if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && !blockedA && !blockedB {
break // stalemate: nothing can ever change break // stalemate: nothing can ever change
@@ -676,6 +724,11 @@ func (g *Game) resolveBattle() {
g.Battle = res g.Battle = res
g.Phase = PhaseBattle g.Phase = PhaseBattle
if winner < 0 {
g.logf(-1, "⚔️", "Round %d battle ends in a draw.", g.Round)
} else {
g.logf(winner, "⚔️", "%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)
}
for _, p := range g.Players { for _, p := range g.Players {
p.Ready = false p.Ready = false
} }
+5 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types' import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import { CardView } from './CardView' import { CardView } from './CardView'
@@ -8,6 +9,9 @@ import { artFor } from '../petArt'
interface Props { interface Props {
view: GameView view: GameView
send: (msg: ClientMessage) => void 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 { interface UnitVis {
@@ -171,10 +175,9 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st
// BattlePhase plays back the battle log: cards flip off each deck, rocks // BattlePhase plays back the battle log: cards flip off each deck, rocks
// fly, pets clash, the fallen fade out, then the round result lands. // fly, pets clash, the fallen fade out, then the round result lands.
export function BattlePhase({ view, send }: Props) { export function BattlePhase({ view, send, step, setStep }: Props) {
const battle = view.battle! const battle = view.battle!
const events = battle.events ?? [] const events = battle.events ?? []
const [step, setStep] = useState(0)
const [acked, setAcked] = useState(false) const [acked, setAcked] = useState(false)
// The deck-peek popover lives inside .battlefield, which sets overflow-x // The deck-peek popover lives inside .battlefield, which sets overflow-x
// (and thus overflow-y) to auto — so an absolutely-positioned popover gets // (and thus overflow-y) to auto — so an absolutely-positioned popover gets
+36 -2
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { LogEntry } from '../types' import type { BattleEvent, LogEntry } from '../types'
// A single battle line, derived from the battle event stream and fed in by // A single battle line, derived from the battle event stream and fed in by
// BattlePhase so the log stays in step with the replay controls. // Table so the log stays in step with the replay controls.
export interface BattleLogLine { export interface BattleLogLine {
key: string key: string
icon?: string icon?: string
@@ -10,6 +10,40 @@ export interface BattleLogLine {
seat: number // -1 = neutral/system seat: number // -1 = neutral/system
} }
const BATTLE_ICONS: Record<BattleEvent['type'], string> = {
prep: '🍎',
reveal: '➡️',
summon: '✨',
mill: '🔥',
rock: '🪨',
clash: '⚔️',
shield: '🛡️',
strip: '💨',
steal: '🍎',
eat: '🍎',
heal: '💚',
}
// battleLogLines turns the battle events revealed up to `step` into readable
// log lines. Only events the backend gave text to produce a line; the rest
// still animate but stay out of the narration.
export function battleLogLines(events: BattleEvent[], step: number): BattleLogLine[] {
const lines: BattleLogLine[] = []
for (let i = 0; i < step && i < events.length; i++) {
const ev = events[i]
if (!ev.text) continue
lines.push({
key: `b${i}`,
icon: BATTLE_ICONS[ev.type],
text: ev.text,
// A clash names both pets, so it stays neutral; everything else takes
// its acting seat's colour.
seat: ev.type === 'clash' ? -1 : ev.seat ?? -1,
})
}
return lines
}
interface Props { interface Props {
entries: LogEntry[] entries: LogEntry[]
youSeat: number youSeat: number
+36 -3
View File
@@ -1,3 +1,5 @@
import { useMemo, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { useGame } from '../useGame' import { useGame } from '../useGame'
import type { Session } from '../types' import type { Session } from '../types'
import { Lobby } from './Lobby' import { Lobby } from './Lobby'
@@ -6,7 +8,7 @@ import { ArrangePhase } from './ArrangePhase'
import { BattlePhase } from './BattlePhase' import { BattlePhase } from './BattlePhase'
import { GameOver } from './GameOver' import { GameOver } from './GameOver'
import { DebugPanel } from './DebugPanel' import { DebugPanel } from './DebugPanel'
import { EventLog } from './EventLog' import { EventLog, battleLogLines } from './EventLog'
// Table connects to the game and routes to the right phase screen. // Table connects to the game and routes to the right phase screen.
export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) { export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) {
@@ -23,6 +25,31 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
const you = view.players[view.youSeat] const you = view.players[view.youSeat]
const opponents = view.players.filter((p) => p.seat !== view.youSeat) const opponents = view.players.filter((p) => p.seat !== view.youSeat)
// Battle replay step lives here (not inside BattlePhase) so the event log,
// a sibling, can render the battle narration up to the same step. Deriving
// the effective step from the current battle round resets it to 0 whenever a
// new battle arrives, without a separate effect.
const battleRound = view.battle?.round ?? -1
const [stepState, setStepState] = useState<{ round: number; step: number }>({
round: -1,
step: 0,
})
const step = stepState.round === battleRound ? stepState.step : 0
const setStep: Dispatch<SetStateAction<number>> = (upd) =>
setStepState((prev) => {
const cur = prev.round === battleRound ? prev.step : 0
const next = typeof upd === 'function' ? (upd as (n: number) => number)(cur) : upd
return { round: battleRound, step: next }
})
const battleLines = useMemo(
() =>
view.phase === 'battle' && view.battle?.events
? battleLogLines(view.battle.events, step)
: undefined,
[view.phase, view.battle, step],
)
return ( return (
<div className="table"> <div className="table">
<header className="topbar"> <header className="topbar">
@@ -64,11 +91,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<ShopPhase view={view} you={you} send={send} /> <ShopPhase view={view} you={you} send={send} />
)} )}
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />} {view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
{view.phase === 'battle' && <BattlePhase view={view} send={send} />} {view.phase === 'battle' && (
<BattlePhase view={view} send={send} step={step} setStep={setStep} />
)}
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />} {view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
</main> </main>
{view.phase !== 'lobby' && ( {view.phase !== 'lobby' && (
<EventLog entries={view.log ?? []} youSeat={view.youSeat} /> <EventLog
entries={view.log ?? []}
youSeat={view.youSeat}
battleLines={battleLines}
/>
)} )}
</div> </div>
+2
View File
@@ -64,6 +64,8 @@ export interface BattleEvent {
targetDied?: boolean targetDied?: boolean
// eat (new bonus total) / reveal (starting bonus) // eat (new bonus total) / reveal (starting bonus)
bonus?: number bonus?: number
// human-readable description of this step for the event log
text?: string
} }
export interface BattleResult { export interface BattleResult {