refactor: adjust implementation details for combat

This commit is contained in:
hypercross 2026-04-16 21:52:28 +08:00
parent f7b59a1790
commit e3014e47a8
5 changed files with 78 additions and 98 deletions

View File

@ -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;
}

View File

@ -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<CombatResult> {
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,25 +80,14 @@ async function runPlayerTurn(
): Promise<void> {
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);
});
while (game.value.phase === "playerTurn") {
const action = await game.prompt<{ action: "play" | "end"; cardId?: string; targetId?: string }>(
@ -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<void> {
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));

View File

@ -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<string, EnemyDesert> = {};
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;

View File

@ -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<string, BuffTriggerBehavior>;
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);
}
}

View File

@ -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<string, number>;
// 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<string, EnemyDesert>;
};