refactor(slay-the-spire-like): update combat targeting and effect logic

Refactor combat targeting types and effect application logic in the
Slay the Spire-like sample.

- Update `getEffectTargets` to use `IntentEffectTarget` and more
  descriptive target keys (e.g., `eachEnemy`, `randomEnemy`).
- Update `promptMainAction` to use `enemy` instead of `single` for
  card target types.
- Refactor `addInstantEffectTriggers` to remove unused effect loading
  and improve enemy/card instantiation logic
This commit is contained in:
hypercross 2026-04-22 19:35:37 +08:00
parent 38fd46618e
commit a5e2e4888e
7 changed files with 170 additions and 160 deletions

View File

@ -2,157 +2,167 @@ import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { addEntityEffect } from "@/samples/slay-the-spire-like/system/combat/effects"; import { addEntityEffect } from "@/samples/slay-the-spire-like/system/combat/effects";
import { moveToRegion } from "@/core/region"; import { moveToRegion } from "@/core/region";
import { CombatGameContext } from "@/samples/slay-the-spire-like/system/combat/types"; import { CombatGameContext } from "@/samples/slay-the-spire-like/system/combat/types";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import { GameCard } from "@/samples/slay-the-spire-like/system/deck"; import { GameCard } from "@/samples/slay-the-spire-like/system/deck";
import getEffects from "../effect.csv";
import getCards from "../card.csv"; import getCards from "../card.csv";
export function addInstantEffectTriggers(triggers: Triggers) { export function addInstantEffectTriggers(triggers: Triggers) {
const effects = getEffects(); const cards = getCards();
const cards = getCards();
function findEffect(id: string): EffectData { function findCard(id: string) {
const found = effects.find((e: EffectData) => e.id === id); return cards.find((c) => c.id === id);
if (found) return found; }
return { id, name: id, description: "", lifecycle: "instant" } as EffectData;
function createStatusCard(
draft: CombatGameContext["value"],
cardId: string,
): void {
const cardData = findCard(cardId);
if (!cardData) return;
const instanceId = `${cardId}-${draft.player.deck.regions.drawPile.childIds.length}-${draft.player.deck.regions.discardPile.childIds.length}`;
const card: GameCard = {
id: instanceId,
regionId: "",
position: [],
itemId: cardId,
cardData: {
id: cardData.id,
name: cardData.name,
desc: cardData.desc,
type: cardData.type,
costType: cardData.costType,
costCount: cardData.costCount,
targetType: cardData.targetType,
effects: cardData.effects,
},
};
draft.player.deck.cards[instanceId] = card;
moveToRegion(card, null, draft.player.deck.regions.drawPile);
}
function getAllEnemyDataFromState(state: CombatGameContext["value"]) {
const seen = new Set<string>();
const result: (typeof state.enemies)[number]["enemy"][] = [];
for (const enemy of state.enemies) {
if (!seen.has(enemy.enemy.id)) {
seen.add(enemy.enemy.id);
result.push(enemy.enemy);
}
} }
return result;
}
function findCard(id: string) { function summonEnemy(
return cards.find(c => c.id === id); draft: CombatGameContext["value"],
} enemyId: string,
hp: number,
function createStatusCard(draft: CombatGameContext["value"], cardId: string): void { ) {
const cardData = findCard(cardId); for (const enemyData of getAllEnemyDataFromState(draft)) {
if (!cardData) return; if (enemyData.id === enemyId) {
const existing = draft.enemies.find(
const instanceId = `${cardId}-${draft.player.deck.regions.drawPile.childIds.length}-${draft.player.deck.regions.discardPile.childIds.length}`; (e) => e.enemy.id === enemyId && !e.isAlive,
const card: GameCard = { );
id: instanceId, if (existing) {
regionId: "", existing.isAlive = true;
position: [], existing.hp = existing.maxHp;
itemId: cardId, return;
cardData: {
id: cardData.id,
name: cardData.name,
desc: cardData.desc,
type: cardData.type,
costType: cardData.costType,
costCount: cardData.costCount,
targetType: cardData.targetType,
effects: cardData.effects,
},
};
draft.player.deck.cards[instanceId] = card;
moveToRegion(card, null, draft.player.deck.regions.drawPile);
}
function getAllEnemyDataFromState(state: CombatGameContext["value"]) {
const seen = new Set<string>();
const result: typeof state.enemies[number]["enemy"][] = [];
for (const enemy of state.enemies) {
if (!seen.has(enemy.enemy.id)) {
seen.add(enemy.enemy.id);
result.push(enemy.enemy);
}
} }
return result; const intent =
} enemyData.intents.find((i) => i.initialIntent) ??
enemyData.intents[0];
function summonEnemy(draft: CombatGameContext["value"], enemyId: string, hp: number) { const instanceId = `${enemyData.id}-${draft.enemies.length}`;
for (const enemyData of getAllEnemyDataFromState(draft)) { const intents: Record<string, typeof intent> = {};
if (enemyData.id === enemyId) { for (const i of enemyData.intents) {
const existing = draft.enemies.find(e => e.enemy.id === enemyId && !e.isAlive); intents[i.id] = i;
if (existing) {
existing.isAlive = true;
existing.hp = existing.maxHp;
return;
}
const intent = enemyData.intents.find(i => i.initialIntent) ?? enemyData.intents[0];
const instanceId = `${enemyData.id}-${draft.enemies.length}`;
const intents: Record<string, typeof intent> = {};
for (const i of enemyData.intents) {
intents[i.id] = i;
}
draft.enemies.push({
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects: {},
intents,
currentIntent: intent,
});
break;
}
} }
draft.enemies.push({
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects: {},
intents,
currentIntent: intent,
});
break;
}
} }
}
triggers.onEffectApplied.use(async (ctx, next) => { triggers.onEffectApplied.use(async (ctx, next) => {
if (ctx.effect.id === "attack") { if (ctx.effect.id === "attack") {
await triggers.onDamage.execute(ctx.game, { await triggers.onDamage.execute(ctx.game, {
entityKey: ctx.entityKey, entityKey: ctx.entityKey,
amount: ctx.stacks, amount: ctx.stacks,
sourceEntityKey: ctx.sourceEntityKey ?? ctx.entityKey === "player" ? undefined : "player", sourceEntityKey:
}); (ctx.sourceEntityKey ?? ctx.entityKey === "player")
} else if (ctx.effect.id === "draw") { ? undefined
await triggers.onDraw.execute(ctx.game, { count: ctx.stacks }); : "player",
} else if (ctx.effect.id === "gainEnergy") { });
await ctx.game.produceAsync(draft => { } else if (ctx.effect.id === "draw") {
draft.player.energy += ctx.stacks; await triggers.onDraw.execute(ctx.game, { count: ctx.stacks });
}); } else if (ctx.effect.id === "gainEnergy") {
} else if (ctx.effect.id === "removeWound") { await ctx.game.produceAsync((draft) => {
await ctx.game.produceAsync(draft => { draft.player.energy += ctx.stacks;
const { cards, regions } = draft.player.deck; });
let removed = 0; } else if (ctx.effect.id === "removeWound") {
const allPileIds = [ await ctx.game.produceAsync((draft) => {
...regions.drawPile.childIds, const { cards, regions } = draft.player.deck;
...regions.discardPile.childIds, let removed = 0;
]; const allPileIds = [
for (const cardId of allPileIds) { ...regions.drawPile.childIds,
if (removed >= ctx.stacks) break; ...regions.discardPile.childIds,
const card = cards[cardId]; ];
if (card && card.cardData.id === "wound") { for (const cardId of allPileIds) {
const sourceRegion = card.regionId === "drawPile" ? regions.drawPile : regions.discardPile; if (removed >= ctx.stacks) break;
moveToRegion(card, sourceRegion, null); const card = cards[cardId];
delete cards[cardId]; if (card && card.cardData.id === "wound") {
removed++; const sourceRegion =
} card.regionId === "drawPile"
} ? regions.drawPile
}); : regions.discardPile;
} else if (ctx.effect.id === "venom") { moveToRegion(card, sourceRegion, null);
await ctx.game.produceAsync(draft => { delete cards[cardId];
createStatusCard(draft, "venom"); removed++;
}); }
} else if (ctx.effect.id === "vultureEye") {
await ctx.game.produceAsync(draft => {
createStatusCard(draft, "vultureEye");
});
} else if (ctx.effect.id === "static") {
await ctx.game.produceAsync(draft => {
createStatusCard(draft, "static");
});
} else if (ctx.effect.id === "summonMummy") {
await ctx.game.produceAsync(draft => {
summonEnemy(draft, "木乃伊", ctx.stacks);
});
} else if (ctx.effect.id === "summonSandwormLarva") {
await ctx.game.produceAsync(draft => {
summonEnemy(draft, "幼沙虫", ctx.stacks);
});
} else if (ctx.effect.id === "reviveMummy") {
await ctx.game.produceAsync(draft => {
const deadMummy = draft.enemies.find(e => e.enemy.id === "木乃伊" && !e.isAlive);
if (deadMummy) {
deadMummy.isAlive = true;
deadMummy.hp = deadMummy.maxHp;
}
});
} else if (ctx.effect.id === "curse") {
await ctx.game.produceAsync(draft => {
addEntityEffect(draft.player, ctx.effect, ctx.stacks);
});
} }
await next(); });
}); } else if (ctx.effect.id === "venom") {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "venom");
});
} else if (ctx.effect.id === "vultureEye") {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "vultureEye");
});
} else if (ctx.effect.id === "static") {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "static");
});
} else if (ctx.effect.id === "summonMummy") {
await ctx.game.produceAsync((draft) => {
summonEnemy(draft, "木乃伊", ctx.stacks);
});
} else if (ctx.effect.id === "summonSandwormLarva") {
await ctx.game.produceAsync((draft) => {
summonEnemy(draft, "幼沙虫", ctx.stacks);
});
} else if (ctx.effect.id === "reviveMummy") {
await ctx.game.produceAsync((draft) => {
const deadMummy = draft.enemies.find(
(e) => e.enemy.id === "木乃伊" && !e.isAlive,
);
if (deadMummy) {
deadMummy.isAlive = true;
deadMummy.hp = deadMummy.maxHp;
}
});
} else if (ctx.effect.id === "curse") {
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, ctx.effect, ctx.stacks);
});
}
await next();
});
} }

View File

@ -10,7 +10,7 @@ import {
CardData, CardData,
CardEffectTarget, CardEffectTarget,
EffectData, EffectData,
EffectTarget, IntentEffectTarget,
} from "@/samples/slay-the-spire-like/system/types"; } from "@/samples/slay-the-spire-like/system/types";
export function addEffect( export function addEffect(
@ -103,25 +103,25 @@ export function* getAliveEnemies(state: CombatState) {
} }
export function* getEffectTargets( export function* getEffectTargets(
target: CardEffectTarget | EffectTarget, target: CardEffectTarget | IntentEffectTarget,
game: CombatGameContext, game: CombatGameContext,
targetId?: string, targetId?: string,
sourceEntityKey: "player" | string = "player", sourceEntityKey: "player" | string = "player",
) { ) {
if (target === "all" || target === "team") { if (target === "eachEnemy") {
for (const enemy of getAliveEnemies(game.value)) { for (const enemy of getAliveEnemies(game.value)) {
yield enemy; yield enemy;
} }
} else if (target === "self") { } else if (target === "user") {
const entity = getCombatEntity(game.value, sourceEntityKey); const entity = getCombatEntity(game.value, sourceEntityKey);
if (entity) yield entity; if (entity) yield entity;
} else if (target === "player") { } else if (target === "player") {
yield game.value.player; yield game.value.player;
} else if (target === "target") { } else if (target === "eachTarget") {
if (!targetId) return; if (!targetId) return;
const entity = getCombatEntity(game.value, targetId); const entity = getCombatEntity(game.value, targetId);
if (entity) yield entity; if (entity) yield entity;
} else if (target === "random") { } else if (target === "randomEnemy") {
const aliveEnemies = [...getAliveEnemies(game.value)]; const aliveEnemies = [...getAliveEnemies(game.value)];
if (aliveEnemies.length === 0) return; if (aliveEnemies.length === 0) return;
const index = game.rng.nextInt(aliveEnemies.length); const index = game.rng.nextInt(aliveEnemies.length);

View File

@ -38,12 +38,12 @@ export async function promptMainAction(
} }
const { targetType } = cardData; const { targetType } = cardData;
if (targetType === "single") { if (targetType === "enemy") {
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 === "enemies" || targetType === "player") {
if (targetId) throw `目标"${targetId}"无效`; if (targetId) throw `目标"${targetId}"无效`;
} }

View File

@ -33,6 +33,7 @@ import type {
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types"; import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape"; import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision"; import type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
import { CardEffect } from "@/samples/slay-the-spire-like/data/desert";
function createRunContext( function createRunContext(
items: Map<string, InventoryItem<GameItemMeta>>, items: Map<string, InventoryItem<GameItemMeta>>,
@ -63,7 +64,7 @@ function createEffect(
id: string, id: string,
lifecycle: EffectData["lifecycle"], lifecycle: EffectData["lifecycle"],
): EffectData { ): EffectData {
return { id, name: id, description: "", lifecycle }; return { id, name: id, description: "", lifecycle, emoji: "" };
} }
function createCard( function createCard(
@ -78,8 +79,8 @@ function createCard(
type: "item" as const, type: "item" as const,
costType, costType,
costCount, costCount,
targetType: "none" as const, targetType: "player" as const,
effects: [] as const, effects: [] as CardEffect[],
}; };
} }

View File

@ -6,7 +6,6 @@ import {
} from "@/core/game"; } from "@/core/game";
import { createRegion } from "@/core/region"; import { createRegion } from "@/core/region";
import { import {
createStartWith,
createTriggers, createTriggers,
Triggers, Triggers,
} from "@/samples/slay-the-spire-like/system/combat/triggers"; } from "@/samples/slay-the-spire-like/system/combat/triggers";
@ -30,6 +29,7 @@ import { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventor
import { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape"; import { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision"; import { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
import { import {
CardEffect,
getCards, getCards,
getEffects, getEffects,
getEncounters, getEncounters,
@ -48,7 +48,7 @@ function createEffect(
): EffectData { ): EffectData {
const found = effects.find((e) => e.id === id); const found = effects.find((e) => e.id === id);
if (found) return found; if (found) return found;
return { id, name: id, description: "", lifecycle }; return { id, name: id, description: "", lifecycle, emoji: "" };
} }
function createDeckRegions(): DeckRegions { function createDeckRegions(): DeckRegions {
@ -73,8 +73,8 @@ function createCard(
type: "item" as const, type: "item" as const,
costType, costType,
costCount, costCount,
targetType: "none" as const, targetType: "player" as const,
effects: [], effects: [] as CardEffect[],
}; };
return { return {
id, id,
@ -285,7 +285,7 @@ describe("desert triggers", () => {
}), }),
); );
const triggers = getTriggers(); const triggers = getTriggers();
const defendEffect = createEffect("defend", "posture"); const defendEffect = createEffect("defend", "temporary");
ctx._state.produce((draft) => { ctx._state.produce((draft) => {
draft.player.effects.defend = { data: defendEffect, stacks: 5 }; draft.player.effects.defend = { data: defendEffect, stacks: 5 };

View File

@ -33,7 +33,7 @@ function createTestCardData(id: string, name: string, desc: string): CardData {
type: "item", type: "item",
costType: "energy", costType: "energy",
costCount: 1, costCount: 1,
targetType: "single", targetType: "enemy",
effects: [], effects: [],
}; };
} }

View File

@ -12,7 +12,6 @@ import {
getItemAtCell, getItemAtCell,
getAdjacentItems, getAdjacentItems,
validatePlacement, validatePlacement,
type GridInventory,
type InventoryItem, type InventoryItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory"; } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { createItemIn } from "@/samples/slay-the-spire-like/system/grid-inventory/factory"; import { createItemIn } from "@/samples/slay-the-spire-like/system/grid-inventory/factory";
@ -36,7 +35,7 @@ function createTestCardData(id: string, name: string, desc: string): CardData {
type: "item", type: "item",
costType: "energy", costType: "energy",
costCount: 1, costCount: 1,
targetType: "single", targetType: "enemy",
effects: [], effects: [],
}; };
} }