refactor: middle ware triggers
This commit is contained in:
parent
3dc566c2fd
commit
7d8684a16f
|
|
@ -1,346 +1,29 @@
|
|||
import { cardDesertData } from "../data";
|
||||
import { createStatusCard } from "../deck/factory";
|
||||
import type { BuffTable, CombatEffectEntry, CombatState } from "./types";
|
||||
import { applyDamage, removeBuff } from "./effects";
|
||||
import {createMiddlewareChain} from "../utils/middleware";
|
||||
import {CombatGameContext} from "./types";
|
||||
|
||||
export type TriggerContext = {
|
||||
state: CombatState;
|
||||
rng: { nextInt: (n: number) => number };
|
||||
};
|
||||
export type Triggers = {
|
||||
onTurnStart: { entityKey: "player" | string, },
|
||||
onTurnEnd: { entityKey: "player" | string, },
|
||||
onShuffle: { entityKey: "player" | string, },
|
||||
onCardPlayed: { cardId: string, },
|
||||
onCardDiscarded: { cardId: string, },
|
||||
onCardDrawn: { cardId: string, },
|
||||
onEffectApplied: { effectId: string, entityKey: "player" | string, stacks: number, },
|
||||
}
|
||||
|
||||
export type BuffTriggerBehavior = {
|
||||
onTurnStart?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
|
||||
onTurnEnd?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
|
||||
onAttacked?: (ctx: TriggerContext, attackerKey: "player" | string, defenderKey: "player" | string, damage: number, stacks: number) => number;
|
||||
onDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => void;
|
||||
modifyOutgoingDamage?: (ctx: TriggerContext, sourceKey: "player" | string, damage: number, stacks: number) => number;
|
||||
modifyIncomingDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => number;
|
||||
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;
|
||||
};
|
||||
|
||||
export type TriggerEvent =
|
||||
| "onTurnStart"
|
||||
| "onTurnEnd"
|
||||
| "onAttacked"
|
||||
| "onDamage"
|
||||
| "modifyOutgoingDamage"
|
||||
| "modifyIncomingDamage"
|
||||
| "onShuffle"
|
||||
| "onCardPlayed"
|
||||
| "onCardDiscarded"
|
||||
| "onCardDrawn";
|
||||
|
||||
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
|
||||
|
||||
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
|
||||
export function createTriggers(){
|
||||
return {
|
||||
spike: {
|
||||
onAttacked(ctx, attackerKey, _defenderKey, damage, stacks) {
|
||||
const { state } = ctx;
|
||||
applyDamage(state, attackerKey, stacks, _defenderKey);
|
||||
return damage;
|
||||
},
|
||||
},
|
||||
aim: {
|
||||
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
|
||||
if (stacks > 0) return damage * 2;
|
||||
return damage;
|
||||
},
|
||||
onDamage(ctx, targetKey, damage, stacks) {
|
||||
const { state } = ctx;
|
||||
const entity = targetKey === "player" ? null : state.enemies[targetKey];
|
||||
if (entity) {
|
||||
const loss = Math.min(stacks, damage);
|
||||
removeBuff(entity.buffs, "aim", loss);
|
||||
}
|
||||
},
|
||||
},
|
||||
charge: {
|
||||
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
|
||||
if (stacks > 0) {
|
||||
return damage * 2;
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
modifyIncomingDamage(_ctx, _targetKey, damage, stacks) {
|
||||
if (stacks > 0) {
|
||||
return damage * 2;
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
onDamage(ctx, targetKey, damage, stacks) {
|
||||
const { state } = ctx;
|
||||
const entity = targetKey === "player"
|
||||
? { buffs: state.player.buffs } as { buffs: BuffTable }
|
||||
: state.enemies[targetKey];
|
||||
if (entity) {
|
||||
const loss = Math.min(stacks, damage);
|
||||
removeBuff(entity.buffs, "charge", loss);
|
||||
}
|
||||
},
|
||||
},
|
||||
roll: {
|
||||
modifyOutgoingDamage(ctx, sourceKey, damage, stacks) {
|
||||
if (stacks >= 10) {
|
||||
const { state } = ctx;
|
||||
const entity = sourceKey === "player"
|
||||
? { buffs: state.player.buffs } as { buffs: BuffTable }
|
||||
: state.enemies[sourceKey];
|
||||
if (entity) {
|
||||
const spendable = Math.floor(stacks / 10) * 10;
|
||||
const bonusDamage = Math.floor(spendable / 10);
|
||||
removeBuff(entity.buffs, "roll", spendable);
|
||||
return damage + bonusDamage;
|
||||
}
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
},
|
||||
tailSting: {
|
||||
onTurnEnd(ctx, entityKey, stacks) {
|
||||
const { state } = ctx;
|
||||
if (entityKey !== "player" && state.enemies[entityKey]?.isAlive) {
|
||||
applyDamage(state, "player", stacks, entityKey);
|
||||
}
|
||||
},
|
||||
},
|
||||
energyDrain: {
|
||||
onDamage(ctx, targetKey, _damage, _stacks) {
|
||||
const { state } = ctx;
|
||||
if (targetKey === "player" && state.player.damagedThisTurn === false) {
|
||||
// This is the first damage; mark it.
|
||||
// actual energy drain happens in onTurnStart check
|
||||
}
|
||||
},
|
||||
onTurnStart(ctx, entityKey, _stacks) {
|
||||
// energyDrain: first damage each turn loses 1 energy
|
||||
// We just mark that the enemy has this; actual drain is in onDamage
|
||||
},
|
||||
},
|
||||
molt: {
|
||||
onDamage(ctx, targetKey, _damage, _stacks) {
|
||||
const { state } = ctx;
|
||||
if (targetKey !== "player") {
|
||||
const enemy = state.enemies[targetKey];
|
||||
if (enemy && enemy.isAlive) {
|
||||
const moltStacks = enemy.buffs["molt"] ?? 0;
|
||||
if (moltStacks >= enemy.maxHp) {
|
||||
enemy.isAlive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
storm: {
|
||||
onAttacked(ctx, attackerKey, defenderKey, damage, stacks) {
|
||||
const { state } = ctx;
|
||||
if (defenderKey !== "player" && state.enemies[defenderKey]?.isAlive) {
|
||||
addStatusCardToHand(state, "static", 1);
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
},
|
||||
vultureEye: {
|
||||
onDamage(ctx, targetKey, damage, stacks) {
|
||||
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 === "秃鹫"
|
||||
);
|
||||
if (vultureEnemies.length > 0) {
|
||||
for (const vultureId of vultureEnemies) {
|
||||
const vulture = state.enemies[vultureId];
|
||||
const intent = vulture.intentData["attack"];
|
||||
if (intent) {
|
||||
const effects = intent.effects as unknown as CombatEffectEntry[];
|
||||
for (const entry of effects) {
|
||||
if (entry[0] === "player" && entry[1].id === "attack") {
|
||||
applyDamage(state, "player", entry[2], vultureId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
venom: {
|
||||
onCardDiscarded(ctx, cardId, stacks) {
|
||||
const { state } = ctx;
|
||||
state.player.cardsDiscardedThisTurn++;
|
||||
const venomCards = state.player.deck.hand.filter((id) => {
|
||||
const card = state.player.deck.cards[id];
|
||||
return card && card.itemData === null && card.displayName === "蛇毒";
|
||||
});
|
||||
if (state.player.cardsDiscardedThisTurn > 1 && venomCards.length > 0) {
|
||||
applyDamage(state, "player", 6, undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
static: {
|
||||
modifyIncomingDamage(_ctx, targetKey, damage, stacks) {
|
||||
if (targetKey === "player") {
|
||||
return damage + stacks;
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
},
|
||||
discard: {
|
||||
onShuffle(ctx, stacks) {
|
||||
// Bandit: shuffle discards random item cards
|
||||
// Simplified: mark the effect for the procedure to handle
|
||||
},
|
||||
},
|
||||
curse: {
|
||||
onDamage(ctx, targetKey, damage, stacks) {
|
||||
// Curse: when attacked, item attack -1 until card from that item is discarded
|
||||
// This is handled via itemBuffs in effects
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
|
||||
const cardDef = cardDesertData.find((c) => c.id === effectId);
|
||||
if (!cardDef) return;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
||||
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
||||
state.player.deck.cards[card.id] = card;
|
||||
state.player.deck.hand.push(card.id);
|
||||
onTurnStart: createTrigger("onTurnStart"),
|
||||
onTurnEnd: createTrigger("onTurnEnd"),
|
||||
onShuffle: createTrigger("onShuffle"),
|
||||
onCardPlayed: createTrigger("onCardPlayed"),
|
||||
onCardDiscarded: createTrigger("onCardDiscarded"),
|
||||
onCardDrawn: createTrigger("onCardDrawn"),
|
||||
onEffectApplied: createTrigger("onEffectApplied"),
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchTrigger(
|
||||
ctx: TriggerContext,
|
||||
event: TriggerEvent,
|
||||
entityKey: "player" | string,
|
||||
registry: CombatTriggerRegistry,
|
||||
): void {
|
||||
const buffs = entityKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[entityKey]?.buffs;
|
||||
if (!buffs) return;
|
||||
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior) continue;
|
||||
const handler = behavior[event];
|
||||
if (handler) {
|
||||
handler(ctx, entityKey, stacks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchAttackedTrigger(
|
||||
ctx: TriggerContext,
|
||||
attackerKey: "player" | string,
|
||||
defenderKey: "player" | string,
|
||||
damage: number,
|
||||
registry: CombatTriggerRegistry,
|
||||
): number {
|
||||
const buffs = defenderKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[defenderKey]?.buffs;
|
||||
if (!buffs) return damage;
|
||||
|
||||
let modifiedDamage = damage;
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.onAttacked) continue;
|
||||
modifiedDamage = behavior.onAttacked(ctx, attackerKey, defenderKey, modifiedDamage, stacks);
|
||||
}
|
||||
return modifiedDamage;
|
||||
}
|
||||
|
||||
export function dispatchDamageTrigger(
|
||||
ctx: TriggerContext,
|
||||
targetKey: "player" | string,
|
||||
damage: number,
|
||||
registry: CombatTriggerRegistry,
|
||||
): void {
|
||||
const buffs = targetKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[targetKey]?.buffs;
|
||||
if (!buffs) return;
|
||||
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.onDamage) continue;
|
||||
behavior.onDamage(ctx, targetKey, damage, stacks);
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchOutgoingDamageTrigger(
|
||||
ctx: TriggerContext,
|
||||
sourceKey: "player" | string,
|
||||
damage: number,
|
||||
registry: CombatTriggerRegistry,
|
||||
): number {
|
||||
const buffs = sourceKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[sourceKey]?.buffs;
|
||||
if (!buffs) return damage;
|
||||
|
||||
let modifiedDamage = damage;
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.modifyOutgoingDamage) continue;
|
||||
modifiedDamage = behavior.modifyOutgoingDamage(ctx, sourceKey, modifiedDamage, stacks);
|
||||
}
|
||||
return modifiedDamage;
|
||||
}
|
||||
|
||||
export function dispatchIncomingDamageTrigger(
|
||||
ctx: TriggerContext,
|
||||
targetKey: "player" | string,
|
||||
damage: number,
|
||||
registry: CombatTriggerRegistry,
|
||||
): number {
|
||||
const buffs = targetKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[targetKey]?.buffs;
|
||||
if (!buffs) return damage;
|
||||
|
||||
let modifiedDamage = damage;
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.modifyIncomingDamage) continue;
|
||||
modifiedDamage = behavior.modifyIncomingDamage(ctx, targetKey, modifiedDamage, stacks);
|
||||
}
|
||||
return modifiedDamage;
|
||||
}
|
||||
|
||||
export function dispatchShuffleTrigger(
|
||||
ctx: TriggerContext,
|
||||
registry: CombatTriggerRegistry,
|
||||
): void {
|
||||
for (const enemyId of ctx.state.enemyOrder) {
|
||||
const enemy = ctx.state.enemies[enemyId];
|
||||
if (!enemy.isAlive) continue;
|
||||
for (const [buffId, stacks] of Object.entries(enemy.buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.onShuffle) continue;
|
||||
behavior.onShuffle(ctx, stacks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
export function createTrigger<TKey extends keyof Triggers>(event: TKey) {
|
||||
type Ctx = Triggers[TKey] & { event: TKey, game: CombatGameContext };
|
||||
return createMiddlewareChain<Ctx>();
|
||||
}
|
||||
|
|
@ -1,71 +1,30 @@
|
|||
import type { PlayerDeck, GameCard } from "../deck/types";
|
||||
import type { PlayerState } from "../progress/types";
|
||||
import type { PlayerDeck } from "../deck/types";
|
||||
import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/type";
|
||||
|
||||
export type BuffTable = Record<string, number>;
|
||||
export type EffectTable = Record<string, number>;
|
||||
|
||||
export type EffectTiming = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "card" | "cardDraw" | "cardHand" | "item" | "itemUntilPlayed";
|
||||
|
||||
export type EffectData = {
|
||||
readonly id: string;
|
||||
readonly timing: EffectTiming;
|
||||
};
|
||||
|
||||
export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team";
|
||||
|
||||
export type EnemyIntentData = {
|
||||
readonly enemy: string;
|
||||
readonly intentId: string;
|
||||
readonly initialIntent: boolean;
|
||||
readonly nextIntents: readonly string[];
|
||||
readonly brokenIntent: readonly string[];
|
||||
readonly initBuffs: readonly [EffectData, number];
|
||||
readonly effects: readonly ["self" | "player" | "team", EffectData, number];
|
||||
};
|
||||
|
||||
export type EncounterData = {
|
||||
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly enemies: readonly [string, number, number];
|
||||
readonly dialogue: string;
|
||||
};
|
||||
|
||||
export type ItemBuff = {
|
||||
effectId: string;
|
||||
stacks: number;
|
||||
timing: EffectTiming;
|
||||
sourceItemId: string;
|
||||
targetItemId: string;
|
||||
};
|
||||
|
||||
export type EnemyState = {
|
||||
id: string;
|
||||
templateId: string;
|
||||
export type CombatEntity = {
|
||||
effects: EffectTable;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
buffs: BuffTable;
|
||||
currentIntentId: string;
|
||||
intentData: Record<string, EnemyIntentData>;
|
||||
isAlive: boolean;
|
||||
hadDefendBroken: boolean;
|
||||
};
|
||||
|
||||
export type PlayerCombatState = {
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
export type PlayerEntity = CombatEntity & {
|
||||
energy: number;
|
||||
maxEnergy: number;
|
||||
buffs: BuffTable;
|
||||
deck: PlayerDeck;
|
||||
damageTakenThisTurn: number;
|
||||
damagedThisTurn: boolean;
|
||||
cardsDiscardedThisTurn: number;
|
||||
itemBuffs: ItemBuff[];
|
||||
fatigueAddedCount: number;
|
||||
itemEffects: Record<string, EffectTable>;
|
||||
}
|
||||
|
||||
export type EnemyEntity = CombatEntity & {
|
||||
id: string;
|
||||
enemy: EnemyData;
|
||||
intents: Record<string, IntentData>;
|
||||
currentIntentId: string;
|
||||
};
|
||||
|
||||
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
|
||||
|
||||
export type CombatResult = "victory" | "defeat";
|
||||
|
||||
export type LootEntry = {
|
||||
|
|
@ -75,23 +34,14 @@ export type LootEntry = {
|
|||
};
|
||||
|
||||
export type CombatState = {
|
||||
enemies: Record<string, EnemyState>;
|
||||
enemyOrder: string[];
|
||||
player: PlayerCombatState;
|
||||
enemies: EnemyEntity[];
|
||||
player: PlayerEntity;
|
||||
|
||||
phase: CombatPhase;
|
||||
turnNumber: number;
|
||||
result: CombatResult | null;
|
||||
|
||||
loot: LootEntry[];
|
||||
enemyTemplateData: Record<string, EnemyIntentData>;
|
||||
};
|
||||
|
||||
export type CombatEffectEntry = [EffectTarget, EffectData, number];
|
||||
|
||||
export type CombatEntity = {
|
||||
buffs: BuffTable;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
isAlive: boolean;
|
||||
};
|
||||
|
||||
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
type Middleware<TContext, TReturn> = (
|
||||
context: TContext,
|
||||
next: () => Promise<TReturn>
|
||||
) => Promise<TReturn>;
|
||||
|
||||
export type MiddlewareChain<TContext, TReturn> = {
|
||||
use: (middleware: Middleware<TContext, TReturn>) => void;
|
||||
execute: (context: TContext) => Promise<TReturn>;
|
||||
};
|
||||
|
||||
export function createMiddlewareChain<TContext extends object, TReturn=TContext>(
|
||||
fallback?: (context: TContext) => Promise<TReturn>
|
||||
): MiddlewareChain<TContext, TReturn> {
|
||||
const middlewares: Middleware<TContext, TReturn>[] = [];
|
||||
|
||||
return {
|
||||
use(middleware: Middleware<TContext, TReturn>) {
|
||||
middlewares.push(middleware);
|
||||
},
|
||||
async execute(context: TContext) {
|
||||
let index = 0;
|
||||
|
||||
async function dispatch(ctx: TContext): Promise<TReturn> {
|
||||
if (index >= middlewares.length) {
|
||||
return fallback ? fallback(ctx) : ctx as unknown as TReturn;
|
||||
}
|
||||
const current = middlewares[index++];
|
||||
return current(ctx, () => dispatch(ctx));
|
||||
}
|
||||
|
||||
return dispatch(context);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue