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 { 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 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();
function findEffect(id: string): EffectData {
@ -67,21 +66,21 @@ export function addCardEventTriggers(triggers: Triggers) {
if (!card) return;
const playedItemId = card.itemId;
const adjacent = getAdjacentItems<GameItemMeta>(
ctx.game.value.inventory,
playedItemId,
);
for (const [adjItemId] of adjacent) {
const adjacent = run.getNeighborItems(playedItemId);
for (const adjItemId of adjacent) {
const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
if (!adjEffects) continue;
const burn = adjEffects.burnForEnergy;
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) => {
const item = draft.inventory.items.get(adjItemId);
if (item) {
draft.inventory.items.delete(adjItemId);
}
draft.player.energy += burn.stacks;
delete draft.player.itemEffects[adjItemId];
});

View File

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

View File

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

View File

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

View File

@ -2,10 +2,9 @@ import type { PlayerDeck } from "../deck/types";
import {
EnemyData,
IntentData,
ItemData,
} 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 }>;
@ -46,7 +45,6 @@ export type LootEntry =
export type CombatState = {
enemies: EnemyEntity[];
player: PlayerEntity;
inventory: GridInventory<GameItemMeta>;
phase: CombatPhase;
turnNumber: number;
@ -55,5 +53,13 @@ export type CombatState = {
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 =
import("@/core/game").IGameContextExport<CombatState>;

View File

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