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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user