From e3014e47a8a0377ce4486ca028b7fe2846688dd4 Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 16 Apr 2026 21:52:28 +0800 Subject: [PATCH] refactor: adjust implementation details for combat --- .../slay-the-spire-like/combat/effects.ts | 16 ++--- .../slay-the-spire-like/combat/procedure.ts | 59 +++++++------------ .../slay-the-spire-like/combat/state.ts | 36 ++++------- .../slay-the-spire-like/combat/triggers.ts | 52 +++++++++------- .../slay-the-spire-like/combat/types.ts | 13 ++-- 5 files changed, 78 insertions(+), 98 deletions(-) diff --git a/src/samples/slay-the-spire-like/combat/effects.ts b/src/samples/slay-the-spire-like/combat/effects.ts index 6d3d486..3b0b64e 100644 --- a/src/samples/slay-the-spire-like/combat/effects.ts +++ b/src/samples/slay-the-spire-like/combat/effects.ts @@ -375,7 +375,7 @@ function applyItemBuff( sourceItemId: card.sourceItemId, targetItemId: card.sourceItemId, }; - state.itemBuffs.push(itemBuff); + state.player.itemBuffs.push(itemBuff); } function removeWoundCards(deck: PlayerDeck, count: number): void { @@ -436,9 +436,9 @@ export function getModifiedAttackDamage( ): number { let damage = baseDamage; - const attackBuff = state.itemBuffs - .filter(b => b.effectId === "attackBuff" || b.effectId === "attackBuffUntilPlay") - .filter(b => { + const attackBuff = state.player.itemBuffs + .filter((b) => b.effectId === "attackBuff" || b.effectId === "attackBuffUntilPlay") + .filter((b) => { const card = state.player.deck.cards[cardId]; return card && card.sourceItemId === b.targetItemId; }) @@ -455,9 +455,9 @@ export function getModifiedDefendAmount( ): number { let defend = baseDefend; - const defendBuff = state.itemBuffs - .filter(b => b.effectId === "defendBuff" || b.effectId === "defendBuffUntilPlay") - .filter(b => { + const defendBuff = state.player.itemBuffs + .filter((b) => b.effectId === "defendBuff" || b.effectId === "defendBuffUntilPlay") + .filter((b) => { const card = state.player.deck.cards[cardId]; return card && card.sourceItemId === b.targetItemId; }) @@ -521,7 +521,7 @@ function expireItemBuffsOnCardPlayed(state: CombatState, cardId: string): void { const card = state.player.deck.cards[cardId]; if (!card || !card.sourceItemId) return; - state.itemBuffs = state.itemBuffs.filter(buff => { + state.player.itemBuffs = state.player.itemBuffs.filter((buff) => { if (buff.timing === "itemUntilPlayed" && buff.sourceItemId === card.sourceItemId) { return false; } diff --git a/src/samples/slay-the-spire-like/combat/procedure.ts b/src/samples/slay-the-spire-like/combat/procedure.ts index 99bbb4b..a32607c 100644 --- a/src/samples/slay-the-spire-like/combat/procedure.ts +++ b/src/samples/slay-the-spire-like/combat/procedure.ts @@ -1,7 +1,7 @@ import type { IGameContext } from "@/core/game"; import type { CombatState, CombatResult, CombatGameContext, CombatEffectEntry } from "./types"; import type { CombatTriggerRegistry, TriggerContext } from "./triggers"; -import { createCombatTriggerRegistry, dispatchTrigger, dispatchShuffleTrigger, dispatchOutgoingDamageTrigger, dispatchIncomingDamageTrigger, dispatchDamageTrigger } from "./triggers"; +import { createCombatTriggerRegistry, dispatchTrigger, dispatchShuffleTrigger, dispatchOutgoingDamageTrigger, dispatchIncomingDamageTrigger, dispatchDamageTrigger, dispatchCardDrawnTrigger } from "./triggers"; import { prompts } from "./prompts"; import { drawCardsToHand, @@ -30,8 +30,7 @@ export async function runCombat( ): Promise { const triggerRegistry = createCombatTriggerRegistry(); - // TODO prefer await game.produceAsync over game.produce since produceAsync can await ui animations - game.produce(state => { + await game.produceAsync(async (state) => { state.phase = "playerTurn"; state.player.energy = state.player.maxEnergy; state.player.damageTakenThisTurn = 0; @@ -55,7 +54,7 @@ export async function runCombat( } if (isPlayerDead(game.value)) { - game.produce(state => { + await game.produceAsync((state) => { state.result = "defeat"; state.phase = "combatEnd"; }); @@ -63,20 +62,13 @@ export async function runCombat( } if (areAllEnemiesDead(game.value)) { - game.produce(state => { + await game.produceAsync((state) => { state.result = "victory"; state.phase = "combatEnd"; state.loot = generateLoot(state); }); return "victory"; } - - if (game.value.result === "fled") { - game.produce(state => { - state.phase = "combatEnd"; - }); - return "fled"; - } } return game.value.result ?? "defeat"; @@ -88,26 +80,15 @@ async function runPlayerTurn( ): Promise { const triggerCtx = createTriggerContext(game); - game.produce(state => { + await game.produceAsync(async (state) => { updateBuffs(state.player.buffs); state.player.damageTakenThisTurn = 0; state.player.damagedThisTurn = false; state.player.cardsDiscardedThisTurn = 0; - // TODO avoid hardcoding here, prefer handling these in either the onTurnStart trigger or the onBuffUpdate trigger - if (state.player.buffs["energyNext"]) { - state.player.energy += state.player.buffs["energyNext"]; - } - if (state.player.buffs["drawNext"]) { - drawCardsToHand(state.player.deck, state.player.buffs["drawNext"]); - } - if (state.player.buffs["defendNext"]) { - applyDefend(state.player.buffs, state.player.buffs["defendNext"]); - } + dispatchTrigger(triggerCtx, "onTurnStart", "player", triggerRegistry); }); - dispatchTrigger(triggerCtx, "onTurnStart", "player", triggerRegistry); - while (game.value.phase === "playerTurn") { const action = await game.prompt<{ action: "play" | "end"; cardId?: string; targetId?: string }>( prompts.playCard, @@ -120,7 +101,7 @@ async function runPlayerTurn( const card = state.player.deck.cards[cardId]; if (card?.itemData?.targetType === "single") { - const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive); + const aliveEnemies = state.enemyOrder.filter((id) => state.enemies[id].isAlive); if (!targetId && aliveEnemies.length > 0) { throw "请指定目标"; } @@ -136,7 +117,7 @@ async function runPlayerTurn( if (action.action === "play" && action.cardId) { const ctx = createEffectContext(game); - game.produce(state => { + await game.produceAsync(async (state) => { playCard({ state, rng: game._rng }, action.cardId!, action.targetId); }); @@ -152,8 +133,7 @@ async function runPlayerTurn( break; } - // TODO end is an alternative action to be taken by the player. - const endAction = await game.prompt<{ action: "end" }>( + await game.prompt<{ action: "end" }>( prompts.endTurn, () => { return { action: "end" as const }; @@ -163,23 +143,26 @@ async function runPlayerTurn( dispatchTrigger(createTriggerContext(game), "onTurnEnd", "player", triggerRegistry); - game.produce(state => { + await game.produceAsync(async (state) => { for (const cardId of [...state.player.deck.hand]) { state.player.cardsDiscardedThisTurn++; } discardHand(state.player.deck); }); - game.produce(state => { + await game.produceAsync(async (state) => { if (state.player.deck.drawPile.length === 0) { reshuffleWithFatigue(state); dispatchShuffleTrigger(createTriggerContext(game), triggerRegistry); } - drawCardsToHand(state.player.deck, 5); + const drawn = drawCardsToHand(state.player.deck, 5); + for (const cardId of drawn) { + dispatchCardDrawnTrigger(createTriggerContext(game), cardId, triggerRegistry); + } state.player.energy = state.player.maxEnergy; }); - game.produce(state => { + await game.produceAsync(async (state) => { state.phase = "enemyTurn"; }); } @@ -190,7 +173,7 @@ async function runEnemyTurn( ): Promise { const state = game.value; - game.produce(state => { + await game.produceAsync(async (state) => { for (const enemyId of state.enemyOrder) { const enemy = state.enemies[enemyId]; if (!enemy.isAlive) continue; @@ -205,7 +188,7 @@ async function runEnemyTurn( dispatchTrigger(triggerCtx, "onTurnStart", enemyId, triggerRegistry); } - game.produce(state => { + await game.produceAsync(async (state) => { for (const enemyId of state.enemyOrder) { const enemy = state.enemies[enemyId]; if (!enemy.isAlive) continue; @@ -245,7 +228,7 @@ async function runEnemyTurn( dispatchTrigger(createTriggerContext(game), "onTurnEnd", enemyId, triggerRegistry); } - game.produce(state => { + await game.produceAsync(async (state) => { state.phase = "playerTurn"; state.turnNumber++; }); @@ -289,8 +272,8 @@ function reshuffleWithFatigue(state: CombatState): void { state.player.deck.drawPile.push(...state.player.deck.discardPile); state.player.deck.discardPile = []; - addFatigueCards(state.player.deck, FATIGUE_CARDS_PER_SHUFFLE, { value: state.fatigueAddedCount }); - state.fatigueAddedCount += FATIGUE_CARDS_PER_SHUFFLE; + addFatigueCards(state.player.deck, FATIGUE_CARDS_PER_SHUFFLE, { value: state.player.fatigueAddedCount }); + state.player.fatigueAddedCount += FATIGUE_CARDS_PER_SHUFFLE; for (let i = state.player.deck.drawPile.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); diff --git a/src/samples/slay-the-spire-like/combat/state.ts b/src/samples/slay-the-spire-like/combat/state.ts index b5848b0..017603c 100644 --- a/src/samples/slay-the-spire-like/combat/state.ts +++ b/src/samples/slay-the-spire-like/combat/state.ts @@ -5,7 +5,8 @@ import type { EnemyDesert } from "../data/enemyDesert.csv"; import type { EffectDesert } from "../data/effectDesert.csv"; import type { EncounterDesert } from "../data/encounterDesert.csv"; import { generateDeckFromInventory, createStatusCard } from "../deck/factory"; -import { enemyDesertData, effectDesertData } from "../data"; +import { enemyDesertData, effectDesertData, cardDesertData } from "../data"; +import { createRNG } from "@/utils/rng"; import type { BuffTable, CombatState, @@ -86,6 +87,8 @@ export function createPlayerCombatState( damageTakenThisTurn: 0, damagedThisTurn: false, cardsDiscardedThisTurn: 0, + itemBuffs: [], + fatigueAddedCount: 0, }; } @@ -101,9 +104,10 @@ export function createCombatState( const enemyOrder: string[] = []; const enemyTemplateData: Record = {}; - for (const [enemyId, hp, bonusHp] of encounter.enemies) { + for (const enemyEntry of encounter.enemies as unknown as [string, number, number][]) { + const [enemyId, hp, bonusHp] = enemyEntry; // Find initBuffs from enemyDesert (first row for this enemy type) - const enemyRow = enemyDesertData.find(e => e.enemy === enemyId); + const enemyRow = enemyDesertData.find((e) => e.enemy === enemyId); const initBuffs: [EffectDesert, number][] = []; if (enemyRow) { for (const [effect, stacks] of enemyRow.initBuffs) { @@ -123,7 +127,7 @@ export function createCombatState( enemyTemplateData[enemyInstance.templateId] = enemyRow!; } - shuffleDeck(player.deck.drawPile, buildSimpleRNG(0)); + shuffleDeck(player.deck.drawPile, createRNG(0)); drawCardsToHand(player.deck, INITIAL_HAND_SIZE); @@ -135,8 +139,6 @@ export function createCombatState( turnNumber: 1, result: null, loot: [], - itemBuffs: [], - fatigueAddedCount: 0, enemyTemplateData, }; } @@ -145,7 +147,6 @@ export function drawCardsToHand(deck: PlayerDeck, count: number): string[] { const drawn: string[] = []; for (let i = 0; i < count; i++) { if (deck.drawPile.length === 0) { - // TODO think we should shuffle Fatigue into the deck here. reshuffleDiscardIntoDraw(deck); } if (deck.drawPile.length === 0) break; @@ -171,13 +172,15 @@ export function reshuffleDiscardIntoDraw(deck: PlayerDeck): void { export function addFatigueCards(deck: PlayerDeck, count: number, fatigueCounter: { value: number }): number { let added = 0; + const fatigueDef = cardDesertData.find((c) => c.id === "fatigue"); + if (!fatigueDef) return 0; + for (let i = 0; i < count; i++) { fatigueCounter.value++; - // TODO avoid hard coding, expect a fatigue card to be in the CSV with a specific id. const card = createStatusCard( `fatigue-${fatigueCounter.value}`, - "疲劳", - "1费/消耗", + fatigueDef.name, + fatigueDef.desc, ); deck.cards[card.id] = card; deck.drawPile.push(card.id); @@ -239,19 +242,6 @@ function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number } } } -// TODO why? use @/utils/rng.ts instead? -function buildSimpleRNG(seed: number) { - let s = seed; - return { - nextInt(max: number) { - s = (s + 0x6d2b79f5) | 0; - let t = Math.imul(s ^ (s >>> 15), s | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return Math.floor(((t ^ (t >>> 14)) >>> 0) / 4294967296 * max); - }, - }; -} - export function getEffectTiming(effectId: string): EffectDesert["timing"] | undefined { const effect = effectDesertData.find(e => e.id === effectId); return effect?.timing; diff --git a/src/samples/slay-the-spire-like/combat/triggers.ts b/src/samples/slay-the-spire-like/combat/triggers.ts index 9eb5fd8..c654c09 100644 --- a/src/samples/slay-the-spire-like/combat/triggers.ts +++ b/src/samples/slay-the-spire-like/combat/triggers.ts @@ -9,9 +9,6 @@ export type TriggerContext = { rng: { nextInt: (n: number) => number }; }; -// TODO add an onCardDrawn trigger -// TODO refactor this to NOT implicitly correspond to an effect type, but generic event handler -// TODO also, refactor this to be async to support prompts and produceAsync export type BuffTriggerBehavior = { onTurnStart?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void; onTurnEnd?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void; @@ -22,9 +19,21 @@ export type BuffTriggerBehavior = { onShuffle?: (ctx: TriggerContext, stacks: number) => void; onCardPlayed?: (ctx: TriggerContext, cardId: string, stacks: number) => void; onCardDiscarded?: (ctx: TriggerContext, cardId: string, stacks: number) => void; + onCardDrawn?: (ctx: TriggerContext, cardId: string, stacks: number) => void; }; -// TODO refactor this to be keyed by event type +export type TriggerEvent = + | "onTurnStart" + | "onTurnEnd" + | "onAttacked" + | "onDamage" + | "modifyOutgoingDamage" + | "modifyIncomingDamage" + | "onShuffle" + | "onCardPlayed" + | "onCardDiscarded" + | "onCardDrawn"; + export type CombatTriggerRegistry = Record; export function createCombatTriggerRegistry(): CombatTriggerRegistry { @@ -121,7 +130,6 @@ export function createCombatTriggerRegistry(): CombatTriggerRegistry { const moltStacks = enemy.buffs["molt"] ?? 0; if (moltStacks >= enemy.maxHp) { enemy.isAlive = false; - state.result = "fled"; } } } @@ -141,7 +149,7 @@ export function createCombatTriggerRegistry(): CombatTriggerRegistry { const { state } = ctx; if (targetKey === "player" && damage > 0) { const vultureEnemies = state.enemyOrder.filter( - id => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫" + (id) => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫" ); if (vultureEnemies.length > 0) { for (const vultureId of vultureEnemies) { @@ -164,7 +172,7 @@ export function createCombatTriggerRegistry(): CombatTriggerRegistry { onCardDiscarded(ctx, cardId, stacks) { const { state } = ctx; state.player.cardsDiscardedThisTurn++; - const venomCards = state.player.deck.hand.filter(id => { + const venomCards = state.player.deck.hand.filter((id) => { const card = state.player.deck.cards[id]; return card && card.itemData === null && card.displayName === "蛇毒"; }); @@ -197,7 +205,7 @@ export function createCombatTriggerRegistry(): CombatTriggerRegistry { } function addStatusCardToHand(state: CombatState, effectId: string, count: number): void { - const cardDef = cardDesertData.find(c => c.id === effectId); + const cardDef = cardDesertData.find((c) => c.id === effectId); if (!cardDef) return; for (let i = 0; i < count; i++) { @@ -208,20 +216,9 @@ function addStatusCardToHand(state: CombatState, effectId: string, count: number } } -export type TriggerEvent = - | "onTurnStart" - | "onTurnEnd" - | "onAttacked" - | "onDamage" - | "modifyOutgoingDamage" - | "modifyIncomingDamage" - | "onShuffle" - | "onCardPlayed" - | "onCardDiscarded"; - export function dispatchTrigger( ctx: TriggerContext, - event: "onTurnStart" | "onTurnEnd", + event: TriggerEvent, entityKey: "player" | string, registry: CombatTriggerRegistry, ): void { @@ -333,3 +330,18 @@ export function dispatchShuffleTrigger( } } } + +export function dispatchCardDrawnTrigger( + ctx: TriggerContext, + cardId: string, + registry: CombatTriggerRegistry, +): void { + const buffs = ctx.state.player.buffs; + if (!buffs) return; + + for (const [buffId, stacks] of Object.entries(buffs)) { + const behavior = registry[buffId]; + if (!behavior?.onCardDrawn) continue; + behavior.onCardDrawn(ctx, cardId, stacks); + } +} diff --git a/src/samples/slay-the-spire-like/combat/types.ts b/src/samples/slay-the-spire-like/combat/types.ts index b375528..00d0ad4 100644 --- a/src/samples/slay-the-spire-like/combat/types.ts +++ b/src/samples/slay-the-spire-like/combat/types.ts @@ -1,4 +1,3 @@ -// TODO shouldn't rely on csv types. Use interfaces and expect csv types to match. import type { EnemyDesert } from "../data/enemyDesert.csv"; import type { EffectDesert } from "../data/effectDesert.csv"; import type { PlayerDeck, GameCard } from "../deck/types"; @@ -6,7 +5,7 @@ import type { PlayerState } from "../progress/types"; export type BuffTable = Record; -// TODO rename this to "lifecycle". Should use lifecycle in csv as well. +/** Lifecycle timing for effects - matches CSV timing column */ export type EffectTiming = EffectDesert["timing"]; export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team"; @@ -41,11 +40,13 @@ export type PlayerCombatState = { damageTakenThisTurn: number; damagedThisTurn: boolean; cardsDiscardedThisTurn: number; + itemBuffs: ItemBuff[]; + fatigueAddedCount: number; }; export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd"; -export type CombatResult = "victory" | "defeat" | "fled"; +export type CombatResult = "victory" | "defeat"; export type LootEntry = { type: "gold" | "item" | "relic"; @@ -59,14 +60,8 @@ export type CombatState = { player: PlayerCombatState; phase: CombatPhase; turnNumber: number; - // TODO: "fled" is a per-enemy state. Should remove it here and expand the isAlive property on enemy instead. - // TODO: CombatResult should just be "victory" "defeat" or null. result: CombatResult | null; loot: LootEntry[]; - // TODO: I think this belongs to the player combat state. - itemBuffs: ItemBuff[]; - // TODO: Also belongs to the player combat state. - fatigueAddedCount: number; enemyTemplateData: Record; };