feat: slay-the-spire-like combat procedures

This commit is contained in:
2026-04-16 14:00:49 +08:00
parent 976ee43ed3
commit 94c7c91745
13 changed files with 2820 additions and 0 deletions
@@ -0,0 +1,538 @@
import type { EffectDesert } from "../data/effectDesert.csv";
import type { StatusCardDesert } from "../data/statusCardDesert.csv";
import { effectDesertData, statusCardDesertData } from "../data";
import { createStatusCard } from "../deck/factory";
import type { PlayerDeck, GameCard } from "../deck/types";
import type {
BuffTable,
CombatEffectEntry,
CombatState,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
PlayerCombatState,
} from "./types";
import {
drawCardsToHand,
addFatigueCards,
discardCard,
exhaustCard,
getEffectData,
discardHand,
} from "./state";
export type DamageResult = {
damageDealt: number;
blockedByDefend: number;
targetDied: boolean;
};
export function applyDamage(
state: CombatState,
targetKey: "player" | string,
amount: number,
sourceKey?: "player" | string,
): DamageResult {
if (amount <= 0) return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
let actualDamage = amount;
let blockedByDefend = 0;
if (targetKey === "player") {
const defendStacks = state.player.buffs["defend"] ?? 0;
if (defendStacks > 0) {
blockedByDefend = Math.min(defendStacks, actualDamage);
actualDamage -= blockedByDefend;
state.player.buffs["defend"] = defendStacks - blockedByDefend;
if (state.player.buffs["defend"] === 0) {
delete state.player.buffs["defend"];
}
}
const damageReduce = state.player.buffs["damageReduce"] ?? 0;
if (damageReduce > 0 && actualDamage > 0) {
actualDamage = Math.max(0, actualDamage - damageReduce);
}
if (actualDamage > 0) {
state.player.hp = Math.max(0, state.player.hp - actualDamage);
state.player.damageTakenThisTurn += actualDamage;
state.player.damagedThisTurn = true;
}
if (blockedByDefend > 0 && defendStacks - blockedByDefend <= 0) {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (enemy.isAlive && enemy.buffs["defend"] !== undefined) {
// Not relevant for player, skip
}
}
}
return {
damageDealt: actualDamage,
blockedByDefend,
targetDied: state.player.hp <= 0,
};
}
const enemy = state.enemies[targetKey];
if (!enemy || !enemy.isAlive) {
return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
}
const defendStacks = enemy.buffs["defend"] ?? 0;
if (defendStacks > 0) {
blockedByDefend = Math.min(defendStacks, actualDamage);
actualDamage -= blockedByDefend;
enemy.buffs["defend"] = defendStacks - blockedByDefend;
if (enemy.buffs["defend"] === 0) {
delete enemy.buffs["defend"];
}
if (defendStacks > 0 && defendStacks - blockedByDefend <= 0) {
enemy.hadDefendBroken = true;
}
}
if (actualDamage > 0) {
enemy.hp = Math.max(0, enemy.hp - actualDamage);
if (enemy.hp <= 0) {
enemy.isAlive = false;
}
}
return {
damageDealt: actualDamage,
blockedByDefend,
targetDied: !enemy.isAlive,
};
}
export function applyDefend(
targetBuffs: BuffTable,
amount: number,
): void {
if (amount <= 0) return;
targetBuffs["defend"] = (targetBuffs["defend"] ?? 0) + amount;
}
export function applyBuff(
buffs: BuffTable,
effectId: string,
timing: EffectTiming,
stacks: number,
): void {
if (stacks <= 0) return;
buffs[effectId] = (buffs[effectId] ?? 0) + stacks;
}
export function removeBuff(buffs: BuffTable, effectId: string, stacks?: number): number {
const current = buffs[effectId] ?? 0;
if (stacks === undefined || stacks >= current) {
delete buffs[effectId];
return current;
}
buffs[effectId] = current - stacks;
return stacks;
}
export function updateBuffs(buffs: BuffTable): void {
const toDelete: string[] = [];
const toDecrement: string[] = [];
for (const [effectId] of Object.entries(buffs)) {
const effectData = getEffectData(effectId);
if (!effectData) continue;
switch (effectData.timing) {
case "temporary":
toDelete.push(effectId);
break;
case "lingering":
toDecrement.push(effectId);
break;
}
}
for (const id of toDelete) {
delete buffs[id];
}
for (const id of toDecrement) {
buffs[id] = (buffs[id] ?? 0) - 1;
if (buffs[id] <= 0) {
delete buffs[id];
}
}
}
export type ResolveEffectContext = {
state: CombatState;
rng: { nextInt: (n: number) => number };
};
export function resolveEffect(
ctx: ResolveEffectContext,
target: EffectTarget,
effect: EffectDesert,
stacks: number,
sourceKey?: "player" | string,
sourceCardId?: string,
): void {
const { state } = ctx;
const timing = effect.timing;
switch (timing) {
case "instant":
resolveInstantEffect(ctx, target, effect, stacks, sourceKey, sourceCardId);
break;
case "posture":
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
break;
case "temporary":
case "lingering":
case "permanent":
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
break;
case "card":
addStatusCardToDiscard(state, effect.id, stacks);
break;
case "cardDraw":
addStatusCardToDrawPile(state, effect.id, stacks);
break;
case "cardHand":
addStatusCardToHand(state, effect.id, stacks);
break;
case "item":
case "itemUntilPlayed":
applyItemBuff(state, effect.id, timing, stacks, sourceCardId);
break;
}
}
function applyBuffToTarget(
state: CombatState,
target: EffectTarget,
effectId: string,
stacks: number,
sourceKey?: "player" | string,
): void {
if (target === "self") {
if (sourceKey === "player") {
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
} else if (sourceKey && state.enemies[sourceKey]) {
applyBuff(state.enemies[sourceKey].buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
}
} else if (target === "player" || target === "team") {
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
} else if (target === "target" || target === "all" || target === "random") {
// For attack/defend effects, these are handled by resolveInstantEffect
}
}
function resolveInstantEffect(
ctx: ResolveEffectContext,
target: EffectTarget,
effect: EffectDesert,
stacks: number,
sourceKey?: "player" | string,
sourceCardId?: string,
): void {
const { state, rng } = ctx;
switch (effect.id) {
case "attack": {
const damageAmount = stacks;
if (target === "all") {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (enemy.isAlive) {
applyDamage(state, enemyId, damageAmount, sourceKey);
}
}
} else if (target === "random") {
const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive);
if (aliveEnemies.length > 0) {
const targetId = aliveEnemies[rng.nextInt(aliveEnemies.length)];
applyDamage(state, targetId, damageAmount, sourceKey);
}
} else if (target === "target") {
if (sourceKey && sourceKey !== "player" && state.enemies[sourceKey]?.isAlive) {
applyDamage(state, "player", damageAmount, sourceKey);
}
}
break;
}
case "defend": {
if (target === "self" && sourceKey === "player") {
applyDefend(state.player.buffs, stacks);
} else if (target === "self" && sourceKey && state.enemies[sourceKey]) {
applyDefend(state.enemies[sourceKey].buffs, stacks);
}
break;
}
case "draw": {
drawCardsToHand(state.player.deck, stacks);
break;
}
case "gainEnergy": {
state.player.energy += stacks;
break;
}
case "removeWound": {
removeWoundCards(state.player.deck, stacks);
break;
}
case "tailSting": {
applyDamage(state, "player", stacks, sourceKey);
break;
}
case "rollDamage": {
const rollStacks = sourceKey && sourceKey !== "player"
? state.enemies[sourceKey]?.buffs["roll"] ?? 0
: 0;
if (rollStacks >= 10) {
const damageFromRoll = Math.floor(rollStacks / 10) * 10;
applyDamage(state, "player", Math.floor(damageFromRoll / 10), sourceKey);
removeBuff(state.enemies[sourceKey!].buffs, "roll", damageFromRoll);
}
break;
}
case "crossbow": {
break;
}
case "discard": {
break;
}
case "summonMummy":
case "summonSandwormLarva":
case "reviveMummy": {
break;
}
case "drawChoice":
case "transformRandom": {
break;
}
default:
break;
}
}
function addStatusCardToDiscard(state: CombatState, effectId: string, count: number): void {
const cardDef = statusCardDesertData.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.discardPile.push(card.id);
}
}
function addStatusCardToDrawPile(state: CombatState, effectId: string, count: number): void {
const cardDef = statusCardDesertData.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.drawPile.push(card.id);
}
}
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
const cardDef = statusCardDesertData.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);
}
}
function applyItemBuff(
state: CombatState,
effectId: string,
timing: EffectTiming,
stacks: number,
sourceCardId?: string,
): void {
if (!sourceCardId) return;
const card = state.player.deck.cards[sourceCardId];
if (!card || !card.sourceItemId) return;
const itemBuff: ItemBuff = {
effectId,
stacks,
timing,
sourceItemId: card.sourceItemId,
targetItemId: card.sourceItemId,
};
state.itemBuffs.push(itemBuff);
}
function removeWoundCards(deck: PlayerDeck, count: number): void {
let removed = 0;
for (let i = deck.drawPile.length - 1; i >= 0 && removed < count; i--) {
const card = deck.cards[deck.drawPile[i]];
if (card && card.itemData === null && card.displayName === "伤口") {
delete deck.cards[deck.drawPile[i]];
deck.drawPile.splice(i, 1);
removed++;
}
}
for (let i = deck.discardPile.length - 1; i >= 0 && removed < count; i--) {
const card = deck.cards[deck.discardPile[i]];
if (card && card.itemData === null && card.displayName === "伤口") {
delete deck.cards[deck.discardPile[i]];
deck.discardPile.splice(i, 1);
removed++;
}
}
}
export function resolveCardEffects(
ctx: ResolveEffectContext,
cardId: string,
targetEnemyId?: string,
): void {
const { state } = ctx;
const card = state.player.deck.cards[cardId];
if (!card || !card.itemData) return;
const sourceKey: "player" | string = "player";
const effects = card.itemData.effects as unknown as CombatEffectEntry[];
for (const entry of effects) {
const [target, effect, stacks] = entry;
if (target === "target") {
if (targetEnemyId && state.enemies[targetEnemyId]?.isAlive) {
if (effect.id === "attack") {
const actualDamage = getModifiedAttackDamage(state, cardId, stacks);
applyDamage(state, targetEnemyId, actualDamage, "player");
continue;
}
}
}
resolveEffect(ctx, target, effect, stacks, sourceKey, cardId);
}
}
export function getModifiedAttackDamage(
state: CombatState,
cardId: string,
baseDamage: number,
): number {
let damage = baseDamage;
const attackBuff = state.itemBuffs
.filter(b => b.effectId === "attackBuff" || b.effectId === "attackBuffUntilPlay")
.filter(b => {
const card = state.player.deck.cards[cardId];
return card && card.sourceItemId === b.targetItemId;
})
.reduce((sum, b) => sum + b.stacks, 0);
damage += attackBuff;
return Math.max(0, damage);
}
export function getModifiedDefendAmount(
state: CombatState,
cardId: string,
baseDefend: number,
): number {
let defend = baseDefend;
const defendBuff = state.itemBuffs
.filter(b => b.effectId === "defendBuff" || b.effectId === "defendBuffUntilPlay")
.filter(b => {
const card = state.player.deck.cards[cardId];
return card && card.sourceItemId === b.targetItemId;
})
.reduce((sum, b) => sum + b.stacks, 0);
defend += defendBuff;
return Math.max(0, defend);
}
export function canPlayCard(
state: CombatState,
cardId: string,
): { canPlay: boolean; reason?: string } {
const card = state.player.deck.cards[cardId];
if (!card) return { canPlay: false, reason: "卡牌不存在" };
if (!card.itemData) return { canPlay: false, reason: "状态牌不可打出" };
const handIdx = state.player.deck.hand.indexOf(cardId);
if (handIdx < 0) return { canPlay: false, reason: "卡牌不在手牌中" };
if (card.itemData.costType === "energy") {
if (state.player.energy < card.itemData.costCount) {
return { canPlay: false, reason: "能量不足" };
}
}
return { canPlay: true };
}
export function playCard(
ctx: ResolveEffectContext,
cardId: string,
targetEnemyId?: string,
): { success: boolean; reason?: string } {
const { state } = ctx;
const check = canPlayCard(state, cardId);
if (!check.canPlay) return { success: false, reason: check.reason };
const card = state.player.deck.cards[cardId];
if (!card || !card.itemData) return { success: false, reason: "卡牌无效" };
if (card.itemData.costType === "energy") {
state.player.energy -= card.itemData.costCount;
}
resolveCardEffects(ctx, cardId, targetEnemyId);
if (card.itemData.costType === "uses") {
exhaustCard(state.player.deck, cardId);
} else {
discardCard(state.player.deck, cardId);
}
expireItemBuffsOnCardPlayed(state, cardId);
return { success: true };
}
function expireItemBuffsOnCardPlayed(state: CombatState, cardId: string): void {
const card = state.player.deck.cards[cardId];
if (!card || !card.sourceItemId) return;
state.itemBuffs = state.itemBuffs.filter(buff => {
if (buff.timing === "itemUntilPlayed" && buff.sourceItemId === card.sourceItemId) {
return false;
}
return true;
});
}
export function areAllEnemiesDead(state: CombatState): boolean {
return state.enemyOrder.every(id => !state.enemies[id].isAlive);
}
export function isPlayerDead(state: CombatState): boolean {
return state.player.hp <= 0;
}
@@ -0,0 +1,71 @@
export type {
BuffTable,
CombatEffectEntry,
CombatEntity,
CombatGameContext,
CombatPhase,
CombatResult,
CombatState,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
LootEntry,
PlayerCombatState,
} from "./types";
export {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
reshuffleDiscardIntoDraw,
addFatigueCards,
discardHand,
discardCard,
exhaustCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
getEffectTiming,
getEffectData,
INITIAL_HAND_SIZE,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
} from "./state";
export {
applyDamage,
applyDefend,
applyBuff,
removeBuff,
updateBuffs,
resolveEffect,
resolveCardEffects,
getModifiedAttackDamage,
getModifiedDefendAmount,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
} from "./effects";
export type {
TriggerContext,
BuffTriggerBehavior,
CombatTriggerRegistry,
TriggerEvent,
} from "./triggers";
export {
createCombatTriggerRegistry,
dispatchTrigger,
dispatchAttackedTrigger,
dispatchDamageTrigger,
dispatchOutgoingDamageTrigger,
dispatchIncomingDamageTrigger,
dispatchShuffleTrigger,
} from "./triggers";
export { prompts } from "./prompts";
export { runCombat } from "./procedure";
@@ -0,0 +1,323 @@
import type { IGameContext } from "@/core/game";
import type { CombatState, CombatResult, CombatGameContext, CombatEffectEntry } from "./types";
import type { CombatTriggerRegistry, TriggerContext } from "./triggers";
import { createCombatTriggerRegistry, dispatchTrigger, dispatchShuffleTrigger, dispatchOutgoingDamageTrigger, dispatchIncomingDamageTrigger, dispatchDamageTrigger } from "./triggers";
import { prompts } from "./prompts";
import {
drawCardsToHand,
addFatigueCards,
discardHand,
discardCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
} from "./state";
import {
applyDamage,
applyDefend,
updateBuffs,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
resolveCardEffects,
removeBuff,
} from "./effects";
export async function runCombat(
game: CombatGameContext,
): Promise<CombatResult> {
const triggerRegistry = createCombatTriggerRegistry();
game.produce(state => {
state.phase = "playerTurn";
state.player.energy = state.player.maxEnergy;
state.player.damageTakenThisTurn = 0;
state.player.damagedThisTurn = false;
state.player.cardsDiscardedThisTurn = 0;
});
while (true) {
const currentState = game.value;
if (currentState.result) {
return currentState.result;
}
if (currentState.phase === "playerTurn") {
await runPlayerTurn(game, triggerRegistry);
} else if (currentState.phase === "enemyTurn") {
await runEnemyTurn(game, triggerRegistry);
} else {
break;
}
if (isPlayerDead(game.value)) {
game.produce(state => {
state.result = "defeat";
state.phase = "combatEnd";
});
return "defeat";
}
if (areAllEnemiesDead(game.value)) {
game.produce(state => {
state.result = "victory";
state.phase = "combatEnd";
state.loot = generateLoot(state);
});
return "victory";
}
if (game.value.result === "fled") {
game.produce(state => {
state.phase = "combatEnd";
});
return "fled";
}
}
return game.value.result ?? "defeat";
}
async function runPlayerTurn(
game: CombatGameContext,
triggerRegistry: CombatTriggerRegistry,
): Promise<void> {
const triggerCtx = createTriggerContext(game);
game.produce(state => {
updateBuffs(state.player.buffs);
state.player.damageTakenThisTurn = 0;
state.player.damagedThisTurn = false;
state.player.cardsDiscardedThisTurn = 0;
if (state.player.buffs["energyNext"]) {
state.player.energy += state.player.buffs["energyNext"];
}
if (state.player.buffs["drawNext"]) {
drawCardsToHand(state.player.deck, state.player.buffs["drawNext"]);
}
if (state.player.buffs["defendNext"]) {
applyDefend(state.player.buffs, state.player.buffs["defendNext"]);
}
});
dispatchTrigger(triggerCtx, "onTurnStart", "player", triggerRegistry);
while (game.value.phase === "playerTurn") {
const action = await game.prompt<{ action: "play" | "end"; cardId?: string; targetId?: string }>(
prompts.playCard,
(cardId, targetId) => {
const state = game.value;
if (!cardId) throw "请选择卡牌";
const check = canPlayCard(state, cardId);
if (!check.canPlay) throw check.reason ?? "无法打出";
const card = state.player.deck.cards[cardId];
if (card?.itemData?.targetType === "single") {
const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive);
if (!targetId && aliveEnemies.length > 0) {
throw "请指定目标";
}
if (targetId && !state.enemies[targetId]?.isAlive) {
throw "目标无效";
}
}
return { action: "play" as const, cardId, targetId };
},
"player"
);
if (action.action === "play" && action.cardId) {
const ctx = createEffectContext(game);
game.produce(state => {
playCard({ state, rng: game._rng }, action.cardId!, action.targetId);
});
if (areAllEnemiesDead(game.value)) {
return;
}
if (isPlayerDead(game.value)) {
return;
}
continue;
}
break;
}
const endAction = await game.prompt<{ action: "end" }>(
prompts.endTurn,
() => {
return { action: "end" as const };
},
"player"
);
dispatchTrigger(createTriggerContext(game), "onTurnEnd", "player", triggerRegistry);
game.produce(state => {
for (const cardId of [...state.player.deck.hand]) {
state.player.cardsDiscardedThisTurn++;
}
discardHand(state.player.deck);
});
game.produce(state => {
if (state.player.deck.drawPile.length === 0) {
reshuffleWithFatigue(state);
dispatchShuffleTrigger(createTriggerContext(game), triggerRegistry);
}
drawCardsToHand(state.player.deck, 5);
state.player.energy = state.player.maxEnergy;
});
game.produce(state => {
state.phase = "enemyTurn";
});
}
async function runEnemyTurn(
game: CombatGameContext,
triggerRegistry: CombatTriggerRegistry,
): Promise<void> {
const state = game.value;
game.produce(state => {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (!enemy.isAlive) continue;
updateBuffs(enemy.buffs);
}
});
const triggerCtx = createTriggerContext(game);
for (const enemyId of game.value.enemyOrder) {
const enemy = game.value.enemies[enemyId];
if (!enemy.isAlive) continue;
dispatchTrigger(triggerCtx, "onTurnStart", enemyId, triggerRegistry);
}
game.produce(state => {
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
if (!enemy.isAlive) continue;
const intent = getEnemyCurrentIntent(enemy);
if (!intent) continue;
const effects = intent.effects as unknown as CombatEffectEntry[];
for (const entry of effects) {
const [target, effect, stacks] = entry;
if (effect.id === "attack") {
let damage = stacks;
damage = dispatchOutgoingDamageTrigger(createTriggerContext(game), enemyId, damage, triggerRegistry);
damage = dispatchIncomingDamageTrigger(createTriggerContext(game), "player", damage, triggerRegistry);
const result = applyDamage(state, "player", damage, enemyId);
if (result.damageDealt > 0) {
dispatchDamageTrigger(createTriggerContext(game), "player", result.damageDealt, triggerRegistry);
}
} else if (effect.id === "defend") {
if (target === "self") {
applyDefend(enemy.buffs, stacks);
}
} else {
resolveEnemyEffect(state, enemyId, target, effect, stacks);
}
}
advanceEnemyIntent(enemy);
}
});
for (const enemyId of game.value.enemyOrder) {
const enemy = game.value.enemies[enemyId];
if (!enemy.isAlive) continue;
dispatchTrigger(createTriggerContext(game), "onTurnEnd", enemyId, triggerRegistry);
}
game.produce(state => {
state.phase = "playerTurn";
state.turnNumber++;
});
}
function resolveEnemyEffect(
state: CombatState,
enemyId: string,
target: string,
effect: { id: string; timing: string },
stacks: number,
): void {
switch (effect.id) {
case "spike":
case "venom":
case "curse":
case "aim":
case "roll":
case "vultureEye":
case "tailSting":
case "energyDrain":
case "molt":
case "storm":
case "static":
case "charge":
case "discard":
state.enemies[enemyId].buffs[effect.id] = (state.enemies[enemyId].buffs[effect.id] ?? 0) + stacks;
break;
case "summonMummy":
case "summonSandwormLarva":
case "reviveMummy":
break;
default:
break;
}
}
function reshuffleWithFatigue(state: CombatState): void {
if (state.player.deck.discardPile.length === 0) return;
state.player.deck.drawPile.push(...state.player.deck.discardPile);
state.player.deck.discardPile = [];
addFatigueCards(state.player.deck, FATIGUE_CARDS_PER_SHUFFLE, { value: state.fatigueAddedCount });
state.fatigueAddedCount += FATIGUE_CARDS_PER_SHUFFLE;
for (let i = state.player.deck.drawPile.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[state.player.deck.drawPile[i], state.player.deck.drawPile[j]] = [state.player.deck.drawPile[j], state.player.deck.drawPile[i]];
}
}
function createTriggerContext(game: CombatGameContext): TriggerContext {
return {
state: game.value,
rng: game._rng,
};
}
function createEffectContext(game: CombatGameContext) {
return {
state: game.value,
rng: game._rng,
};
}
function generateLoot(state: CombatState): CombatState["loot"] {
const loot: CombatState["loot"] = [];
let totalGold = 0;
for (const enemyId of state.enemyOrder) {
const enemy = state.enemies[enemyId];
totalGold += Math.floor(enemy.maxHp * 0.5);
}
if (totalGold > 0) {
loot.push({ type: "gold", amount: totalGold });
}
return loot;
}
@@ -0,0 +1,12 @@
import { createPromptDef } from "@/core/game";
export const prompts = {
playCard: createPromptDef<[string, string?]>(
"play-card <cardId:string> [targetId:string]",
"选择卡牌并指定目标"
),
endTurn: createPromptDef<[]>(
"end-turn",
"结束回合"
),
};
@@ -0,0 +1,242 @@
import type { GridInventory } from "../grid-inventory/types";
import type { GameItemMeta, PlayerState } from "../progress/types";
import type { PlayerDeck } from "../deck/types";
import type { EnemyDesert } from "../data/enemyDesert.csv";
import type { EnemyIntentDesert } from "../data/enemyIntentDesert.csv";
import type { EncounterDesert } from "../data/encounterDesert.csv";
import type { EffectDesert } from "../data/effectDesert.csv";
import { generateDeckFromInventory, createStatusCard } from "../deck/factory";
import { enemyDesertData, enemyIntentDesertData, effectDesertData } from "../data";
import type {
BuffTable,
CombatState,
CombatPhase,
EnemyState,
PlayerCombatState,
ItemBuff,
LootEntry,
} from "./types";
const INITIAL_HAND_SIZE = 5;
const DEFAULT_MAX_ENERGY = 3;
const FATIGUE_CARDS_PER_SHUFFLE = 2;
export function createEnemyInstance(
templateId: string,
enemyData: EnemyDesert,
bonusHp: number,
idCounter: { value: number },
): EnemyState {
idCounter.value++;
const id = `enemy-${idCounter.value}`;
const maxHp = enemyData.initHp + bonusHp;
const hp = maxHp;
const buffs: BuffTable = {};
for (const [effect, stacks] of enemyData.initBuffs) {
buffs[effect.id] = (buffs[effect.id] ?? 0) + stacks;
}
const intentData = buildIntentLookup(templateId);
return {
id,
templateId,
hp,
maxHp,
buffs,
currentIntentId: enemyData.initialIntent,
intentData,
isAlive: true,
hadDefendBroken: false,
};
}
function buildIntentLookup(enemyTemplateId: string): Record<string, EnemyIntentDesert> {
const lookup: Record<string, EnemyIntentDesert> = {};
for (const intent of enemyIntentDesertData) {
if (intent.enemy.id === enemyTemplateId) {
lookup[intent.id] = intent;
}
}
return lookup;
}
export function createPlayerCombatState(
playerState: PlayerState,
inventory: GridInventory<GameItemMeta>,
): PlayerCombatState {
const deck = generateDeckFromInventory(inventory);
return {
hp: playerState.currentHp,
maxHp: playerState.maxHp,
energy: DEFAULT_MAX_ENERGY,
maxEnergy: DEFAULT_MAX_ENERGY,
buffs: {},
deck,
damageTakenThisTurn: 0,
damagedThisTurn: false,
cardsDiscardedThisTurn: 0,
};
}
export function createCombatState(
playerState: PlayerState,
inventory: GridInventory<GameItemMeta>,
encounter: EncounterDesert,
): CombatState {
const idCounter = { value: 0 };
const player = createPlayerCombatState(playerState, inventory);
const enemies: Record<string, EnemyState> = {};
const enemyOrder: string[] = [];
const enemyTemplateData: Record<string, EnemyDesert> = {};
for (const [enemyRef, bonusHp] of encounter.enemies) {
const enemyInstance = createEnemyInstance(
enemyRef.id,
enemyRef,
bonusHp,
idCounter,
);
enemies[enemyInstance.id] = enemyInstance;
enemyOrder.push(enemyInstance.id);
enemyTemplateData[enemyInstance.templateId] = enemyRef;
}
shuffleDeck(player.deck.drawPile, buildSimpleRNG(0));
drawCardsToHand(player.deck, INITIAL_HAND_SIZE);
return {
enemies,
enemyOrder,
player,
phase: "playerTurn" as CombatPhase,
turnNumber: 1,
result: null,
loot: [],
itemBuffs: [],
fatigueAddedCount: 0,
enemyTemplateData,
};
}
export function drawCardsToHand(deck: PlayerDeck, count: number): string[] {
const drawn: string[] = [];
for (let i = 0; i < count; i++) {
if (deck.drawPile.length === 0) {
reshuffleDiscardIntoDraw(deck);
}
if (deck.drawPile.length === 0) break;
const cardId = deck.drawPile.shift()!;
deck.hand.push(cardId);
drawn.push(cardId);
}
return drawn;
}
export function reshuffleDiscardIntoDraw(deck: PlayerDeck): void {
if (deck.discardPile.length === 0) return;
deck.drawPile.push(...deck.discardPile);
deck.discardPile = [];
for (let i = deck.drawPile.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck.drawPile[i], deck.drawPile[j]] = [deck.drawPile[j], deck.drawPile[i]];
}
}
export function addFatigueCards(deck: PlayerDeck, count: number, fatigueCounter: { value: number }): number {
let added = 0;
for (let i = 0; i < count; i++) {
fatigueCounter.value++;
const card = createStatusCard(
`fatigue-${fatigueCounter.value}`,
"疲劳",
"1费/消耗",
);
deck.cards[card.id] = card;
deck.drawPile.push(card.id);
added++;
}
return added;
}
export function discardHand(deck: PlayerDeck): void {
const handCards = [...deck.hand];
deck.discardPile.push(...handCards);
deck.hand = [];
}
export function discardCard(deck: PlayerDeck, cardId: string): void {
const handIdx = deck.hand.indexOf(cardId);
if (handIdx >= 0) {
deck.hand.splice(handIdx, 1);
deck.discardPile.push(cardId);
}
}
export function exhaustCard(deck: PlayerDeck, cardId: string): void {
const handIdx = deck.hand.indexOf(cardId);
if (handIdx >= 0) {
deck.hand.splice(handIdx, 1);
deck.exhaustPile.push(cardId);
}
}
export function getEnemyCurrentIntent(enemy: EnemyState): EnemyIntentDesert | undefined {
return enemy.intentData[enemy.currentIntentId];
}
export function advanceEnemyIntent(enemy: EnemyState): void {
const current = getEnemyCurrentIntent(enemy);
if (!current) return;
if (enemy.hadDefendBroken && current.brokenIntent.length > 0) {
const idx = Math.floor(Math.random() * current.brokenIntent.length);
enemy.currentIntentId = current.brokenIntent[idx].id;
enemy.hadDefendBroken = false;
return;
}
if (current.nextIntents.length > 0) {
const idx = Math.floor(Math.random() * current.nextIntents.length);
enemy.currentIntentId = current.nextIntents[idx].id;
return;
}
enemy.currentIntentId = current.id;
}
function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number }): void {
for (let i = drawPile.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[drawPile[i], drawPile[j]] = [drawPile[j], drawPile[i]];
}
}
function buildSimpleRNG(seed: number) {
let s = seed;
return {
nextInt(max: number) {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), s | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return Math.floor(((t ^ (t >>> 14)) >>> 0) / 4294967296 * max);
},
};
}
export function getEffectTiming(effectId: string): EffectDesert["timing"] | undefined {
const effect = effectDesertData.find(e => e.id === effectId);
return effect?.timing;
}
export function getEffectData(effectId: string): EffectDesert | undefined {
return effectDesertData.find(e => e.id === effectId);
}
export { INITIAL_HAND_SIZE, DEFAULT_MAX_ENERGY, FATIGUE_CARDS_PER_SHUFFLE };
@@ -0,0 +1,331 @@
import type { EffectDesert } from "../data/effectDesert.csv";
import { statusCardDesertData } from "../data";
import { createStatusCard } from "../deck/factory";
import type { BuffTable, CombatEffectEntry, CombatState } from "./types";
import { applyDamage, removeBuff } from "./effects";
export type TriggerContext = {
state: CombatState;
rng: { nextInt: (n: number) => 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;
};
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
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;
state.result = "fled";
}
}
}
},
},
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 = statusCardDesertData.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);
}
}
export type TriggerEvent =
| "onTurnStart"
| "onTurnEnd"
| "onAttacked"
| "onDamage"
| "modifyOutgoingDamage"
| "modifyIncomingDamage"
| "onShuffle"
| "onCardPlayed"
| "onCardDiscarded";
export function dispatchTrigger(
ctx: TriggerContext,
event: "onTurnStart" | "onTurnEnd",
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);
}
}
}
@@ -0,0 +1,77 @@
import type { EnemyDesert } from "../data/enemyDesert.csv";
import type { EnemyIntentDesert } from "../data/enemyIntentDesert.csv";
import type { EffectDesert } from "../data/effectDesert.csv";
import type { PlayerDeck, GameCard } from "../deck/types";
import type { PlayerState } from "../progress/types";
export type BuffTable = Record<string, number>;
export type EffectTiming = EffectDesert["timing"];
export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team";
export type ItemBuff = {
effectId: string;
stacks: number;
timing: EffectTiming;
sourceItemId: string;
targetItemId: string;
};
export type EnemyState = {
id: string;
templateId: string;
hp: number;
maxHp: number;
buffs: BuffTable;
currentIntentId: string;
intentData: Record<string, EnemyIntentDesert>;
isAlive: boolean;
hadDefendBroken: boolean;
};
export type PlayerCombatState = {
hp: number;
maxHp: number;
energy: number;
maxEnergy: number;
buffs: BuffTable;
deck: PlayerDeck;
damageTakenThisTurn: number;
damagedThisTurn: boolean;
cardsDiscardedThisTurn: number;
};
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat" | "fled";
export type LootEntry = {
type: "gold" | "item" | "relic";
amount?: number;
itemId?: string;
};
export type CombatState = {
enemies: Record<string, EnemyState>;
enemyOrder: string[];
player: PlayerCombatState;
phase: CombatPhase;
turnNumber: number;
result: CombatResult | null;
loot: LootEntry[];
itemBuffs: ItemBuff[];
fatigueAddedCount: number;
enemyTemplateData: Record<string, EnemyDesert>;
};
export type CombatEffectEntry = [EffectTarget, EffectDesert, number];
export type CombatEntity = {
buffs: BuffTable;
hp: number;
maxHp: number;
isAlive: boolean;
};
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;
@@ -8,3 +8,4 @@ wound, 伤口, 无效果,占用手牌和牌堆, true,
venom, 蛇毒, 弃掉超过1张蛇毒时受到6伤害, true, [self; venom; 1]
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, true, [self; curse; 1]
static, 静电, 在手里时受电击伤害+1, true, [self; static; 1]
fatigue, 疲劳, 1费/消耗,占用手牌, true,
1 # 状态牌:由某些效果创建,洗入玩家牌堆或手牌
8 curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, true, [self; curse; 1]
9 static, 静电, 在手里时受电击伤害+1, true, [self; static; 1]
10 fatigue, 疲劳, 1费/消耗,占用手牌, true,
11
+63
View File
@@ -82,3 +82,66 @@ export {
flipXTransform,
flipYTransform,
} from './utils/shape-collision';
// Combat
export type {
BuffTable,
CombatEffectEntry,
CombatEntity,
CombatGameContext,
CombatPhase,
CombatResult,
CombatState,
EffectTarget,
EffectTiming,
EnemyState,
ItemBuff,
LootEntry,
PlayerCombatState,
} from './combat';
export type {
TriggerContext,
BuffTriggerBehavior,
CombatTriggerRegistry,
TriggerEvent,
} from './combat';
export {
createCombatState,
createEnemyInstance,
createPlayerCombatState,
drawCardsToHand,
addFatigueCards,
discardHand,
discardCard,
exhaustCard,
getEnemyCurrentIntent,
advanceEnemyIntent,
getEffectTiming,
getEffectData,
INITIAL_HAND_SIZE,
DEFAULT_MAX_ENERGY,
FATIGUE_CARDS_PER_SHUFFLE,
applyDamage,
applyDefend,
applyBuff,
removeBuff,
updateBuffs,
resolveEffect,
resolveCardEffects,
getModifiedAttackDamage,
getModifiedDefendAmount,
canPlayCard,
playCard,
areAllEnemiesDead,
isPlayerDead,
createCombatTriggerRegistry,
dispatchTrigger,
dispatchAttackedTrigger,
dispatchDamageTrigger,
dispatchOutgoingDamageTrigger,
dispatchIncomingDamageTrigger,
dispatchShuffleTrigger,
runCombat,
} from './combat';