324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
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;
|
|
}
|