Narrate the battle in the event log, synced to the replay step.
This commit is contained in:
+72
-19
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<SetStateAction<number>>
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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<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 {
|
||||
entries: LogEntry[]
|
||||
youSeat: number
|
||||
|
||||
@@ -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<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 (
|
||||
<div className="table">
|
||||
<header className="topbar">
|
||||
@@ -64,11 +91,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
<ShopPhase 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} />}
|
||||
</main>
|
||||
{view.phase !== 'lobby' && (
|
||||
<EventLog entries={view.log ?? []} youSeat={view.youSeat} />
|
||||
<EventLog
|
||||
entries={view.log ?? []}
|
||||
youSeat={view.youSeat}
|
||||
battleLines={battleLines}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user