From 63b64bc1d89ad6724baacb3305d2ada97bb992d7 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 08:07:17 -0400 Subject: [PATCH] Narrate the battle in the event log, synced to the replay step. --- internal/game/battle.go | 91 +++++++++++++++++++++++------- web/src/components/BattlePhase.tsx | 7 ++- web/src/components/EventLog.tsx | 38 ++++++++++++- web/src/components/Table.tsx | 39 ++++++++++++- web/src/types.ts | 2 + 5 files changed, 151 insertions(+), 26 deletions(-) diff --git a/internal/game/battle.go b/internal/game/battle.go index b755cce..59c22f1 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -1,5 +1,7 @@ package game +import "fmt" + // BattleUnit is a pet in play with its attached foods applied. Power // (attack) is unaffected by damage; a unit dies when Damage >= Power. type BattleUnit struct { @@ -100,6 +102,10 @@ type BattleEvent struct { TargetDied bool `json:"targetDied,omitempty"` // eat: the pet's power bonus after eating. 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. @@ -194,6 +200,8 @@ func (g *Game) resolveBattle() { res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)} sides := make([]*battleSide, n) 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 { s := &battleSide{stack: append([]Card(nil), p.Deck...)} @@ -222,17 +230,19 @@ func (g *Game) resolveBattle() { for range e.count() { apple := g.newApple() 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.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 { if kind == "bee" { @@ -297,13 +307,13 @@ func (g *Game) resolveBattle() { target = (seat + 1) % n } 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: recycled := 0 for _, f := range u.Foods { if f.Food == FoodApple && recycled < e.count() { - summon(seat, f) + summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name)) recycled++ } } @@ -334,7 +344,8 @@ func (g *Game) resolveBattle() { healed := min(effectCount(e, os, os.unit), os.unit.Damage) if healed > 0 { 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.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: 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: u.Shield += e.count() @@ -381,12 +393,26 @@ func (g *Game) resolveBattle() { } dealt, blocked := hitUnit(target, roll) 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{ Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces, - DamageAfter: tu.Damage, TargetDied: died, + DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt, }) 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 { faint(target, tu) @@ -425,7 +451,8 @@ func (g *Game) resolveBattle() { c := s.stack[0] s.stack = s.stack[1:] 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) continue } @@ -437,7 +464,11 @@ func (g *Game) resolveBattle() { } // Pets carry their starting bonus (foods + auras) in the // 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.unit = u newlyPlayed[seat] = true @@ -543,7 +574,8 @@ func (g *Game) resolveBattle() { tu.Bonus -= appleCount(tu.Foods) tu.Foods = nil 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 { faint(t, tu) sides[t].unit = nil @@ -573,7 +605,8 @@ func (g *Game) resolveBattle() { tu.Bonus -= moved q.unit.Bonus += moved 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 { faint(t, tu) sides[t].unit = nil @@ -588,11 +621,12 @@ func (g *Game) resolveBattle() { break } 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: 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: 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.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() { 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{ Type: "clash", Damage: []int{ua.Damage, ub.Damage}, Died: []bool{!ua.Alive(), !ub.Alive()}, + Text: clashTxt, }) 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 { - 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 { break // stalemate: nothing can ever change @@ -676,6 +724,11 @@ func (g *Game) resolveBattle() { g.Battle = res 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 { p.Ready = false } diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 1fcfb82..458b973 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import type { Dispatch, SetStateAction } from 'react' import { createPortal } from 'react-dom' import type { BattleEvent, Card, ClientMessage, GameView } from '../types' import { CardView } from './CardView' @@ -8,6 +9,9 @@ import { artFor } from '../petArt' 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> } 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 // 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 events = battle.events ?? [] - const [step, setStep] = useState(0) 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 diff --git a/web/src/components/EventLog.tsx b/web/src/components/EventLog.tsx index 67e4eff..ea2ef47 100644 --- a/web/src/components/EventLog.tsx +++ b/web/src/components/EventLog.tsx @@ -1,8 +1,8 @@ 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 -// 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 { key: string icon?: string @@ -10,6 +10,40 @@ export interface BattleLogLine { seat: number // -1 = neutral/system } +const BATTLE_ICONS: Record = { + 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 { entries: LogEntry[] youSeat: number diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index 41c4e5e..5e1e524 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -1,3 +1,5 @@ +import { useMemo, useState } from 'react' +import type { Dispatch, SetStateAction } from 'react' import { useGame } from '../useGame' import type { Session } from '../types' import { Lobby } from './Lobby' @@ -6,7 +8,7 @@ import { ArrangePhase } from './ArrangePhase' import { BattlePhase } from './BattlePhase' import { GameOver } from './GameOver' 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. 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 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> = (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 (
@@ -64,11 +91,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v )} {view.phase === 'arrange' && } - {view.phase === 'battle' && } + {view.phase === 'battle' && ( + + )} {view.phase === 'gameover' && } {view.phase !== 'lobby' && ( - + )}
diff --git a/web/src/types.ts b/web/src/types.ts index 84e73a5..42617e4 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -64,6 +64,8 @@ export interface BattleEvent { targetDied?: boolean // eat (new bonus total) / reveal (starting bonus) bonus?: number + // human-readable description of this step for the event log + text?: string } export interface BattleResult {