2026-04-19 23:28:56 +08:00
|
|
|
import { CombatGameContext } from "./types";
|
2026-04-17 09:27:20 +08:00
|
|
|
import {
|
2026-04-19 23:28:56 +08:00
|
|
|
addEntityEffect,
|
|
|
|
|
addItemEffect,
|
|
|
|
|
getAliveEnemies,
|
|
|
|
|
onEntityPostureDamage,
|
|
|
|
|
onEntityEffectUpkeep,
|
|
|
|
|
onPlayerItemEffectUpkeep,
|
|
|
|
|
onItemDiscard,
|
|
|
|
|
onItemPlay,
|
|
|
|
|
payCardCost,
|
|
|
|
|
getCombatEntity,
|
|
|
|
|
getEffectTargets,
|
2026-04-17 09:27:20 +08:00
|
|
|
} from "@/samples/slay-the-spire-like/system/combat/effects";
|
2026-04-19 23:28:56 +08:00
|
|
|
import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/prompts";
|
|
|
|
|
import { moveToRegion, shuffle } from "@/core/region";
|
|
|
|
|
import { createMiddlewareChain } from "@/utils/middleware";
|
|
|
|
|
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
|
|
|
|
|
import { getAdjacentItems } from "@/samples/slay-the-spire-like/system/grid-inventory";
|
|
|
|
|
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress";
|
2026-04-16 14:00:49 +08:00
|
|
|
|
2026-04-17 09:27:20 +08:00
|
|
|
type TriggerTypes = {
|
2026-04-19 23:28:56 +08:00
|
|
|
onCombatStart: {};
|
|
|
|
|
onTurnStart: { entityKey: "player" | string };
|
|
|
|
|
onTurnEnd: { entityKey: "player" | string };
|
|
|
|
|
onShuffle: {};
|
|
|
|
|
onCardPlayed: {
|
|
|
|
|
cardId: string;
|
|
|
|
|
targetId?: string;
|
|
|
|
|
sourceEntityKey?: "player" | string;
|
|
|
|
|
};
|
|
|
|
|
onCardDiscarded: { cardId: string; sourceEntityKey?: "player" | string };
|
|
|
|
|
onCardDrawn: { cardId: string; sourceEntityKey?: "player" | string };
|
|
|
|
|
onDraw: { count: number };
|
|
|
|
|
onEffectApplied: {
|
|
|
|
|
effect: EffectData;
|
|
|
|
|
entityKey: "player" | string;
|
|
|
|
|
stacks: number;
|
|
|
|
|
cardId?: string;
|
|
|
|
|
sourceEntityKey?: "player" | string;
|
|
|
|
|
targetId?: string;
|
|
|
|
|
};
|
|
|
|
|
onHpChange: { entityKey: "player" | string; amount: number };
|
|
|
|
|
onDamage: {
|
|
|
|
|
entityKey: "player" | string;
|
|
|
|
|
amount: number;
|
|
|
|
|
prevented?: number;
|
|
|
|
|
sourceEntityKey?: "player" | string;
|
|
|
|
|
};
|
|
|
|
|
onEnemyIntent: { enemyId: string; sourceEntityKey?: "player" | string };
|
|
|
|
|
onIntentUpdate: { enemyId: string };
|
|
|
|
|
};
|
2026-04-16 14:00:49 +08:00
|
|
|
|
2026-04-19 23:28:56 +08:00
|
|
|
export function createTriggers() {
|
|
|
|
|
const triggers = {
|
|
|
|
|
onCombatStart: createTrigger("onCombatStart"),
|
|
|
|
|
onTurnStart: createTrigger("onTurnStart", async (ctx) => {
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
const entity = getCombatEntity(draft, ctx.entityKey);
|
|
|
|
|
if (entity) onEntityEffectUpkeep(entity);
|
|
|
|
|
if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player);
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
onTurnEnd: createTrigger("onTurnEnd", async (ctx) => {
|
|
|
|
|
if (ctx.entityKey !== "player") return;
|
|
|
|
|
const { regions } = ctx.game.value.player.deck;
|
|
|
|
|
for (const cardId of Object.values(regions.hand.childIds)) {
|
|
|
|
|
await triggers.onCardDiscarded.execute(ctx.game, { cardId });
|
|
|
|
|
}
|
|
|
|
|
await ctx.game.produceAsync(
|
|
|
|
|
(draft) => (draft.player.energy = draft.player.maxEnergy),
|
|
|
|
|
);
|
|
|
|
|
await triggers.onDraw.execute(ctx.game, { count: 5 });
|
|
|
|
|
}),
|
|
|
|
|
onShuffle: createTrigger("onShuffle", async (ctx) => {
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
const { cards, regions } = draft.player.deck;
|
|
|
|
|
for (const cardId of Object.values(regions.discardPile.childIds))
|
|
|
|
|
moveToRegion(cards[cardId], regions.discardPile, regions.drawPile);
|
|
|
|
|
shuffle(regions.drawPile, cards, ctx.game.rng);
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
onCardPlayed: createTrigger("onCardPlayed", async (ctx) => {
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
const { cards, regions } = draft.player.deck;
|
|
|
|
|
const card = cards[ctx.cardId];
|
|
|
|
|
payCardCost(
|
|
|
|
|
draft.player,
|
|
|
|
|
card.cardData.costType,
|
|
|
|
|
card.cardData.costCount,
|
|
|
|
|
card.itemId,
|
|
|
|
|
draft.inventory,
|
|
|
|
|
);
|
|
|
|
|
moveToRegion(card, regions.hand, regions.discardPile);
|
|
|
|
|
onItemPlay(draft.player, card.itemId);
|
|
|
|
|
});
|
|
|
|
|
const { cards, regions } = ctx.game.value.player.deck;
|
|
|
|
|
const card = cards[ctx.cardId];
|
|
|
|
|
const source = ctx.sourceEntityKey ?? "player";
|
|
|
|
|
for (const { trigger, target, effects } of card.cardData.effects) {
|
|
|
|
|
if (trigger !== "onPlay") continue;
|
|
|
|
|
for (const [effect, stacks] of effects)
|
|
|
|
|
for (const entity of getEffectTargets(target, ctx.game, ctx.targetId))
|
|
|
|
|
await triggers.onEffectApplied.execute(ctx.game, {
|
|
|
|
|
effect,
|
|
|
|
|
entityKey: entity.id,
|
|
|
|
|
stacks,
|
|
|
|
|
cardId: ctx.cardId,
|
|
|
|
|
sourceEntityKey: source,
|
|
|
|
|
targetId: ctx.targetId,
|
2026-04-17 11:06:09 +08:00
|
|
|
});
|
2026-04-19 23:28:56 +08:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
onCardDiscarded: createTrigger("onCardDiscarded", async (ctx) => {
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
const { cards, regions } = draft.player.deck;
|
|
|
|
|
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
|
|
|
|
|
onItemDiscard(draft.player, cards[ctx.cardId].itemId);
|
|
|
|
|
});
|
|
|
|
|
const { cards, regions } = ctx.game.value.player.deck;
|
|
|
|
|
const card = cards[ctx.cardId];
|
|
|
|
|
const source = ctx.sourceEntityKey ?? "player";
|
|
|
|
|
for (const { trigger, target, effects } of card.cardData.effects) {
|
|
|
|
|
if (trigger !== "onDiscard") continue;
|
|
|
|
|
for (const [effect, stacks] of effects)
|
|
|
|
|
for (const entity of getEffectTargets(target, ctx.game))
|
|
|
|
|
await triggers.onEffectApplied.execute(ctx.game, {
|
|
|
|
|
effect,
|
|
|
|
|
entityKey: entity.id,
|
|
|
|
|
stacks,
|
|
|
|
|
cardId: ctx.cardId,
|
|
|
|
|
sourceEntityKey: source,
|
2026-04-17 11:06:09 +08:00
|
|
|
});
|
2026-04-19 23:28:56 +08:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
onCardDrawn: createTrigger("onCardDrawn", async (ctx) => {
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
const { cards, regions } = draft.player.deck;
|
|
|
|
|
moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand);
|
|
|
|
|
});
|
|
|
|
|
const { cards, regions } = ctx.game.value.player.deck;
|
|
|
|
|
const card = cards[ctx.cardId];
|
|
|
|
|
const source = ctx.sourceEntityKey ?? "player";
|
|
|
|
|
for (const { trigger, target, effects } of card.cardData.effects) {
|
|
|
|
|
if (trigger !== "onDraw") continue;
|
|
|
|
|
for (const [effect, stacks] of effects)
|
|
|
|
|
for (const entity of getEffectTargets(target, ctx.game))
|
|
|
|
|
await triggers.onEffectApplied.execute(ctx.game, {
|
|
|
|
|
effect,
|
|
|
|
|
entityKey: entity.id,
|
|
|
|
|
stacks,
|
|
|
|
|
cardId: ctx.cardId,
|
|
|
|
|
sourceEntityKey: source,
|
2026-04-17 11:06:09 +08:00
|
|
|
});
|
2026-04-19 23:28:56 +08:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
onDraw: createTrigger("onDraw", async (ctx) => {
|
|
|
|
|
let toDraw = ctx.count;
|
|
|
|
|
while (toDraw > 0) {
|
|
|
|
|
let inDraw =
|
|
|
|
|
ctx.game.value.player.deck.regions.drawPile.childIds.length;
|
|
|
|
|
if (inDraw <= 0) await triggers.onShuffle.execute(ctx.game, {});
|
2026-04-17 11:06:09 +08:00
|
|
|
|
2026-04-19 23:28:56 +08:00
|
|
|
inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
|
|
|
|
|
if (inDraw <= 0) break;
|
2026-04-17 11:06:09 +08:00
|
|
|
|
2026-04-19 23:28:56 +08:00
|
|
|
const children = ctx.game.value.player.deck.regions.drawPile.childIds;
|
|
|
|
|
const cardId = children[children.length - 1];
|
|
|
|
|
await triggers.onCardDrawn.execute(ctx.game, { cardId });
|
|
|
|
|
toDraw--;
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
onEffectApplied: createTrigger("onEffectApplied", async (ctx) => {
|
|
|
|
|
if (ctx.effect.lifecycle === "instant") return;
|
2026-04-17 11:06:09 +08:00
|
|
|
|
2026-04-19 23:28:56 +08:00
|
|
|
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()) {
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
|
2026-04-17 11:57:07 +08:00
|
|
|
});
|
2026-04-19 23:28:56 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
const entity =
|
|
|
|
|
ctx.entityKey === "player"
|
|
|
|
|
? draft.player
|
|
|
|
|
: draft.enemies.find((e) => e.id === ctx.entityKey);
|
|
|
|
|
if (entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
onHpChange: createTrigger("onHpChange", async (ctx) => {
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
const entity =
|
|
|
|
|
ctx.entityKey === "player"
|
|
|
|
|
? draft.player
|
|
|
|
|
: draft.enemies.find((e) => e.id === ctx.entityKey);
|
|
|
|
|
if (!entity) return;
|
|
|
|
|
entity.hp += ctx.amount;
|
|
|
|
|
entity.isAlive = entity.hp > 0;
|
|
|
|
|
draft.result = !draft.player.isAlive
|
|
|
|
|
? "defeat"
|
|
|
|
|
: draft.enemies.every((e) => !e.isAlive)
|
|
|
|
|
? "victory"
|
|
|
|
|
: null;
|
|
|
|
|
});
|
|
|
|
|
if (ctx.game.value.result) throw ctx.game.value;
|
|
|
|
|
}),
|
|
|
|
|
onDamage: createTrigger("onDamage", async (ctx) => {
|
|
|
|
|
const entity =
|
|
|
|
|
ctx.entityKey === "player"
|
|
|
|
|
? ctx.game.value.player
|
|
|
|
|
: ctx.game.value.enemies.find((e) => e.id === ctx.entityKey);
|
|
|
|
|
if (!entity || !entity.isAlive) return;
|
|
|
|
|
const dealt = Math.min(
|
|
|
|
|
Math.max(0, entity.hp),
|
|
|
|
|
ctx.amount - (ctx.prevented || 0),
|
|
|
|
|
);
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
onEntityPostureDamage(entity, dealt);
|
|
|
|
|
});
|
|
|
|
|
await triggers.onHpChange.execute(ctx.game, {
|
|
|
|
|
entityKey: ctx.entityKey,
|
|
|
|
|
amount: -dealt,
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
onEnemyIntent: createTrigger("onEnemyIntent", async (ctx) => {
|
|
|
|
|
const enemy = ctx.game.value.enemies.find((e) => e.id === ctx.enemyId);
|
|
|
|
|
if (!enemy || !enemy.isAlive) return;
|
|
|
|
|
|
|
|
|
|
const intent = enemy.currentIntent;
|
|
|
|
|
if (!intent) return;
|
|
|
|
|
|
|
|
|
|
const source = ctx.sourceEntityKey ?? enemy.id;
|
|
|
|
|
for (const [target, effect, stacks] of intent.effects) {
|
|
|
|
|
for (const entity of getEffectTargets(target, ctx.game))
|
|
|
|
|
await triggers.onEffectApplied.execute(ctx.game, {
|
|
|
|
|
effect,
|
|
|
|
|
entityKey: entity.id,
|
|
|
|
|
stacks,
|
|
|
|
|
sourceEntityKey: source,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
onIntentUpdate: createTrigger("onIntentUpdate", async (ctx) => {
|
|
|
|
|
await ctx.game.produceAsync((draft) => {
|
|
|
|
|
const enemy = draft.enemies.find((e) => e.id === ctx.enemyId);
|
|
|
|
|
if (!enemy) return;
|
|
|
|
|
|
|
|
|
|
const intent = enemy.currentIntent;
|
|
|
|
|
if (!intent) return;
|
|
|
|
|
|
|
|
|
|
const nextIntents = intent.nextIntents;
|
|
|
|
|
if (nextIntents.length > 0) {
|
|
|
|
|
const nextIndex = ctx.game.rng.nextInt(nextIntents.length);
|
|
|
|
|
enemy.currentIntent = nextIntents[nextIndex];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
return triggers;
|
2026-04-16 14:00:49 +08:00
|
|
|
}
|
2026-04-19 23:28:56 +08:00
|
|
|
export type Triggers = ReturnType<typeof createTriggers>;
|
|
|
|
|
export function createStartWith(build: (triggers: Triggers) => void) {
|
|
|
|
|
const triggers = createTriggers();
|
|
|
|
|
build(triggers);
|
|
|
|
|
return async function (game: CombatGameContext) {
|
|
|
|
|
await triggers.onCombatStart.execute(game, {});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
while (true) {
|
|
|
|
|
await triggers.onTurnStart.execute(game, { entityKey: "player" });
|
|
|
|
|
while (true) {
|
|
|
|
|
const action = await promptMainAction(game);
|
|
|
|
|
if (action.action === "end-turn") break;
|
|
|
|
|
if (action.action === "play") {
|
|
|
|
|
await triggers.onCardPlayed.execute(game, action);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await triggers.onTurnEnd.execute(game, { entityKey: "player" });
|
2026-04-17 11:06:09 +08:00
|
|
|
|
2026-04-19 23:28:56 +08:00
|
|
|
for (const enemy of getAliveEnemies(game.value)) {
|
|
|
|
|
await triggers.onTurnStart.execute(game, { entityKey: enemy.id });
|
2026-04-17 09:27:20 +08:00
|
|
|
}
|
2026-04-19 23:28:56 +08:00
|
|
|
for (const enemy of getAliveEnemies(game.value)) {
|
|
|
|
|
await triggers.onEnemyIntent.execute(game, { enemyId: enemy.id });
|
|
|
|
|
await triggers.onIntentUpdate.execute(game, { enemyId: enemy.id });
|
|
|
|
|
}
|
|
|
|
|
for (const enemy of getAliveEnemies(game.value)) {
|
|
|
|
|
await triggers.onTurnEnd.execute(game, { entityKey: enemy.id });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (e === game.value) return game.value.result;
|
|
|
|
|
throw e;
|
2026-04-17 09:27:20 +08:00
|
|
|
}
|
2026-04-19 23:28:56 +08:00
|
|
|
};
|
2026-04-17 09:27:20 +08:00
|
|
|
}
|
2026-04-16 21:52:28 +08:00
|
|
|
|
2026-04-19 23:28:56 +08:00
|
|
|
type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & {
|
|
|
|
|
event: TKey;
|
|
|
|
|
game: CombatGameContext;
|
|
|
|
|
};
|
|
|
|
|
function createTrigger<TKey extends keyof TriggerTypes>(
|
|
|
|
|
event: TKey,
|
|
|
|
|
fallback?: (ctx: TriggerContext<TKey>) => Promise<void>,
|
|
|
|
|
) {
|
|
|
|
|
const { use, execute } = createMiddlewareChain<TriggerContext<TKey>, void>(
|
|
|
|
|
fallback,
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
use,
|
|
|
|
|
execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => {
|
|
|
|
|
const param = { ...ctx, game, event };
|
|
|
|
|
await execute(param);
|
|
|
|
|
return param;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|