refactor(slay-the-spire-like): use IRunContext for combat logic

Decouple combat systems from the inventory and progress state by
introducing `IRunContext`. This replaces direct access to
`GridInventory` and `GameItemMeta` with abstract methods for
retrieving item data, neighbors, and managing consumed uses.
This commit is contained in:
hypercross 2026-04-20 11:03:44 +08:00
parent 9bed2ca13e
commit 5019bc6324
6 changed files with 95 additions and 80 deletions

View File

@ -1,11 +1,10 @@
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers"; import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { getCombatEntity } from "@/samples/slay-the-spire-like/system/combat/effects"; import { getCombatEntity } from "@/samples/slay-the-spire-like/system/combat/effects";
import { getAdjacentItems } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress";
import { EffectData } from "@/samples/slay-the-spire-like/system/types"; import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import getEffects from "../effect.csv"; import getEffects from "../effect.csv";
import { IRunContext } from "@/samples/slay-the-spire-like/system/combat/types";
export function addCardEventTriggers(triggers: Triggers) { export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
const effects = getEffects(); const effects = getEffects();
function findEffect(id: string): EffectData { function findEffect(id: string): EffectData {
@ -67,21 +66,21 @@ export function addCardEventTriggers(triggers: Triggers) {
if (!card) return; if (!card) return;
const playedItemId = card.itemId; const playedItemId = card.itemId;
const adjacent = getAdjacentItems<GameItemMeta>( const adjacent = run.getNeighborItems(playedItemId);
ctx.game.value.inventory, for (const adjItemId of adjacent) {
playedItemId,
);
for (const [adjItemId] of adjacent) {
const adjEffects = ctx.game.value.player.itemEffects[adjItemId]; const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
if (!adjEffects) continue; if (!adjEffects) continue;
const burn = adjEffects.burnForEnergy; const burn = adjEffects.burnForEnergy;
if (!burn || burn.stacks <= 0) continue; if (!burn || burn.stacks <= 0) continue;
const item = run.getItemData(adjItemId);
const maxUses =
item?.card.costType === "energy" ? item.card.costCount : 0;
const consumed = run.getConsumedUses(adjItemId);
const toConsume = Math.min(maxUses - consumed, burn.stacks);
await run.setConsumedUsesAsync(adjItemId, consumed + toConsume);
await ctx.game.produceAsync((draft) => { await ctx.game.produceAsync((draft) => {
const item = draft.inventory.items.get(adjItemId);
if (item) {
draft.inventory.items.delete(adjItemId);
}
draft.player.energy += burn.stacks; draft.player.energy += burn.stacks;
delete draft.player.itemEffects[adjItemId]; delete draft.player.itemEffects[adjItemId];
}); });

View File

@ -3,6 +3,7 @@ import {
CombatGameContext, CombatGameContext,
CombatState, CombatState,
EffectTable, EffectTable,
IRunContext,
PlayerEntity, PlayerEntity,
} from "./types"; } from "./types";
import { import {
@ -12,8 +13,6 @@ import {
EffectData, EffectData,
EffectTarget, EffectTarget,
} from "@/samples/slay-the-spire-like/system/types"; } from "@/samples/slay-the-spire-like/system/types";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types";
import { GridInventory } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
export function addEffect( export function addEffect(
effects: EffectTable, effects: EffectTable,
@ -138,33 +137,32 @@ export function canPlayCard(
costType: CardData["costType"], costType: CardData["costType"],
costCount: number, costCount: number,
itemId: string, itemId: string,
inventory: GridInventory<GameItemMeta>, run: IRunContext,
): boolean { ): boolean {
if (costType === "energy") { if (costType === "energy") {
return player.energy >= costCount; return player.energy >= costCount;
} }
if (costType === "uses") { if (costType === "uses") {
const item = inventory.items.get(itemId); const item = run.getItemData(itemId);
if (!item || !item.meta) return false; if (!item) return false;
const depletion = item.meta.consumedUses ?? 0; const maxUses = item?.card.costType === "energy" ? item.card.costCount : 0;
return depletion < costCount; const consumed = run.getConsumedUses(itemId);
return consumed + costCount <= maxUses;
} }
return true; return true;
} }
export function payCardCost( export async function payCardCost(
player: PlayerEntity, player: PlayerEntity,
costType: CardData["costType"], costType: CardData["costType"],
costCount: number, costCount: number,
itemId: string, itemId: string,
inventory: GridInventory<GameItemMeta>, run: IRunContext,
): void { ): Promise<void> {
if (costType === "energy") { if (costType === "energy") {
player.energy -= costCount; player.energy -= costCount;
} else if (costType === "uses") { } else if (costType === "uses") {
const item = inventory.items.get(itemId); const consumed = run.getConsumedUses(itemId);
if (item && item.meta) { await run.setConsumedUsesAsync(itemId, consumed + costCount);
item.meta.consumedUses = (item.meta.consumedUses ?? 0) + costCount;
}
} }
} }

View File

@ -1,43 +1,56 @@
import { createPromptDef } from "@/core/game"; import { createPromptDef } from "@/core/game";
import {CombatGameContext} from "./types"; import { CombatGameContext, IRunContext } from "./types";
import {canPlayCard} from "@/samples/slay-the-spire-like/system/combat/effects"; import { canPlayCard } from "@/samples/slay-the-spire-like/system/combat/effects";
export const prompts = { export const prompts = {
mainAction: createPromptDef<[string, string?]>( mainAction: createPromptDef<[string, string?]>(
"main-action <cardId:string> [targetId:string]", "main-action <cardId:string> [targetId:string]",
"选择卡牌并指定目标" "选择卡牌并指定目标",
), ),
}; };
export async function promptMainAction(game: CombatGameContext){ export async function promptMainAction(
game: CombatGameContext,
run: IRunContext,
) {
return await game.prompt(prompts.mainAction, (cardId, targetId) => { return await game.prompt(prompts.mainAction, (cardId, targetId) => {
if(cardId === 'end-turn') return { if (cardId === "end-turn")
action: 'end-turn' as 'end-turn' return {
action: "end-turn" as "end-turn",
}; };
const exists = game.value.player.deck.regions.hand.childIds.includes(cardId); const exists =
if(!exists) throw `卡牌"${cardId}"不在手牌中`; game.value.player.deck.regions.hand.childIds.includes(cardId);
if (!exists) throw `卡牌"${cardId}"不在手牌中`;
const card = game.value.player.deck.cards[cardId]; const card = game.value.player.deck.cards[cardId];
const {cardData, itemId} = card; const { cardData, itemId } = card;
if(!canPlayCard(game.value.player, cardData.costType, cardData.costCount, itemId, game.value.inventory)){ if (
!canPlayCard(
game.value.player,
cardData.costType,
cardData.costCount,
itemId,
run,
)
) {
throw `无法支付卡牌"${cardId}"的费用`; throw `无法支付卡牌"${cardId}"的费用`;
} }
const {targetType} = cardData; const { targetType } = cardData;
if(targetType === 'single'){ if (targetType === "single") {
if(!targetId) throw `请指定目标`; if (!targetId) throw `请指定目标`;
const target = game.value.enemies.find(e => e.id === targetId); const target = game.value.enemies.find((e) => e.id === targetId);
if(!target) throw `目标"${targetId}"不存在`; if (!target) throw `目标"${targetId}"不存在`;
if(!target.isAlive) throw `目标"${targetId}"已死亡`; if (!target.isAlive) throw `目标"${targetId}"已死亡`;
}else if(targetType === 'none'){ } else if (targetType === "none") {
if(targetId) throw `目标"${targetId}"无效`; if (targetId) throw `目标"${targetId}"无效`;
} }
return { return {
action: 'play' as 'play', action: "play" as "play",
cardId, cardId,
targetId targetId,
}; };
}); });
} }

View File

@ -1,4 +1,4 @@
import { CombatGameContext } from "./types"; import { CombatGameContext, IRunContext } from "./types";
import { import {
addEntityEffect, addEntityEffect,
addItemEffect, addItemEffect,
@ -51,7 +51,7 @@ type TriggerTypes = {
onIntentUpdate: { enemyId: string }; onIntentUpdate: { enemyId: string };
}; };
export function createTriggers() { export function createTriggers(run: IRunContext) {
const triggers = { const triggers = {
onCombatStart: createTrigger("onCombatStart"), onCombatStart: createTrigger("onCombatStart"),
onTurnStart: createTrigger("onTurnStart", async (ctx) => { onTurnStart: createTrigger("onTurnStart", async (ctx) => {
@ -89,7 +89,7 @@ export function createTriggers() {
card.cardData.costType, card.cardData.costType,
card.cardData.costCount, card.cardData.costCount,
card.itemId, card.itemId,
draft.inventory, run,
); );
moveToRegion(card, regions.hand, regions.discardPile); moveToRegion(card, regions.hand, regions.discardPile);
onItemPlay(draft.player, card.itemId); onItemPlay(draft.player, card.itemId);
@ -176,11 +176,8 @@ export function createTriggers() {
if (ctx.effect.lifecycle.startsWith("item")) { if (ctx.effect.lifecycle.startsWith("item")) {
if (ctx.cardId) { if (ctx.cardId) {
const card = ctx.game.value.player.deck.cards[ctx.cardId]; const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = getAdjacentItems<GameItemMeta>( const nearby = run.getNeighborItems(card.itemId);
ctx.game.value.inventory, for (const itemId of nearby) {
card.itemId,
);
for (const itemId of nearby.keys()) {
await ctx.game.produceAsync((draft) => { await ctx.game.produceAsync((draft) => {
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks); addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
}); });
@ -269,9 +266,12 @@ export function createTriggers() {
return triggers; return triggers;
} }
export type Triggers = ReturnType<typeof createTriggers>; export type Triggers = ReturnType<typeof createTriggers>;
export function createStartWith(build: (triggers: Triggers) => void) { export function createStartWith(
const triggers = createTriggers(); build: (triggers: Triggers, run: IRunContext) => void,
build(triggers); run: IRunContext,
) {
const triggers = createTriggers(run);
build(triggers, run);
return async function (game: CombatGameContext) { return async function (game: CombatGameContext) {
await triggers.onCombatStart.execute(game, {}); await triggers.onCombatStart.execute(game, {});
@ -279,7 +279,7 @@ export function createStartWith(build: (triggers: Triggers) => void) {
while (true) { while (true) {
await triggers.onTurnStart.execute(game, { entityKey: "player" }); await triggers.onTurnStart.execute(game, { entityKey: "player" });
while (true) { while (true) {
const action = await promptMainAction(game); const action = await promptMainAction(game, run);
if (action.action === "end-turn") break; if (action.action === "end-turn") break;
if (action.action === "play") { if (action.action === "play") {
await triggers.onCardPlayed.execute(game, action); await triggers.onCardPlayed.execute(game, action);

View File

@ -2,10 +2,9 @@ import type { PlayerDeck } from "../deck/types";
import { import {
EnemyData, EnemyData,
IntentData, IntentData,
ItemData,
} from "@/samples/slay-the-spire-like/system/types"; } from "@/samples/slay-the-spire-like/system/types";
import { EffectData } from "@/samples/slay-the-spire-like/system/types"; import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import { GridInventory } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress";
export type EffectTable = Record<string, { data: EffectData; stacks: number }>; export type EffectTable = Record<string, { data: EffectData; stacks: number }>;
@ -46,7 +45,6 @@ export type LootEntry =
export type CombatState = { export type CombatState = {
enemies: EnemyEntity[]; enemies: EnemyEntity[];
player: PlayerEntity; player: PlayerEntity;
inventory: GridInventory<GameItemMeta>;
phase: CombatPhase; phase: CombatPhase;
turnNumber: number; turnNumber: number;
@ -55,5 +53,13 @@ export type CombatState = {
loot: LootEntry[]; loot: LootEntry[];
}; };
export interface IRunContext {
getItemData(id: string): ItemData | null;
getNeighborItems(id: string): Iterable<string>;
getConsumedUses(id: string): number;
setConsumedUsesAsync(id: string, uses: number): Promise<void>;
}
export type CombatGameContext = export type CombatGameContext =
import("@/core/game").IGameContextExport<CombatState>; import("@/core/game").IGameContextExport<CombatState>;

View File

@ -94,7 +94,6 @@ export function buildCombatState(runState: RunState): CombatState {
return { return {
enemies, enemies,
player, player,
inventory: runState.inventory,
phase: "playerTurn", phase: "playerTurn",
turnNumber: 1, turnNumber: 1,
result: null, result: null,