From a469b4024acd4f56df75208303f726769cbdce25 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 17 Apr 2026 09:27:20 +0800 Subject: [PATCH] refactor: combat rewrite --- .../system/combat/effects.ts | 557 +----------------- .../system/combat/index.ts | 74 --- .../system/combat/procedure.ts | 309 ---------- .../system/combat/prompts.ts | 37 +- .../system/combat/state.ts | 254 -------- .../system/combat/triggers.ts | 59 +- .../system/combat/types.ts | 13 +- .../system/combat/utils.ts | 9 + 8 files changed, 134 insertions(+), 1178 deletions(-) delete mode 100644 src/samples/slay-the-spire-like/system/combat/index.ts delete mode 100644 src/samples/slay-the-spire-like/system/combat/procedure.ts delete mode 100644 src/samples/slay-the-spire-like/system/combat/state.ts create mode 100644 src/samples/slay-the-spire-like/system/combat/utils.ts diff --git a/src/samples/slay-the-spire-like/system/combat/effects.ts b/src/samples/slay-the-spire-like/system/combat/effects.ts index 5809fbd..68c54bb 100644 --- a/src/samples/slay-the-spire-like/system/combat/effects.ts +++ b/src/samples/slay-the-spire-like/system/combat/effects.ts @@ -1,537 +1,44 @@ -import { effectDesertData, cardDesertData } from "../data"; -import { createStatusCard } from "../deck/factory"; -import type { PlayerDeck, GameCard } from "../deck/types"; -import type { - BuffTable, - CombatEffectEntry, - CombatState, - EffectData, - EffectTarget, - EffectTiming, - EnemyState, - ItemBuff, - PlayerCombatState, -} from "./types"; -import { - drawCardsToHand, - addFatigueCards, - discardCard, - exhaustCard, - getEffectData, - discardHand, -} from "./state"; +import {CombatEntity, EffectTable} from "./types"; +import {EffectData} from "@/samples/slay-the-spire-like/system/types"; +import {PlayerEntity} from "@/samples/slay-the-spire-like/system/combat/types"; -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 addEffect(effects: EffectTable, effect: EffectData, stacks: number){ + let current = effects[effect.id]; + + if(!current) current = {data: effect, stacks}; + else current.stacks += stacks; + + if(current.stacks === 0 && effects[effect.id]) + delete effects[effect.id]; + else if(current.stacks !== 0 && !effects[effect.id]) + effects[effect.id] = current; } -export function applyDefend( - targetBuffs: BuffTable, - amount: number, -): void { - if (amount <= 0) return; - targetBuffs["defend"] = (targetBuffs["defend"] ?? 0) + amount; +export function addEntityEffect(entity: CombatEntity, effect: EffectData, stacks: number){ + addEffect(entity.effects, effect, stacks); } -export function applyBuff( - buffs: BuffTable, - effectId: string, - timing: EffectTiming, - stacks: number, -): void { - if (stacks <= 0) return; - buffs[effectId] = (buffs[effectId] ?? 0) + stacks; +export function addItemEffect(entity: PlayerEntity, itemKey: string, effect: EffectData, stacks: number){ + entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {}; + addEffect(entity.itemEffects[itemKey], effect, 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 function onEntityEffectUpkeep(entity: CombatEntity){ + for(const effect of Object.values(entity.effects)){ + const lifecycle = effect.data.lifecycle; + if(lifecycle === 'temporary') + addEntityEffect(entity, effect.data, -effect.stacks); + else if(lifecycle === 'lingering') + addEntityEffect(entity, effect.data, effect.stacks >= 0 ? -1 : 1); } } -export type ResolveEffectContext = { - state: CombatState; - rng: { nextInt: (n: number) => number }; -}; - -export function resolveEffect( - ctx: ResolveEffectContext, - target: EffectTarget, - effect: EffectData, - 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: EffectData, - 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 = 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.discardPile.push(card.id); - } -} - -function addStatusCardToDrawPile(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.drawPile.push(card.id); - } -} - -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); - } -} - -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.player.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++; +export function onPlayerItemEffectUpkeep(entity: PlayerEntity){ + for(const [itemKey, itemEffects] of Object.entries(entity.itemEffects)){ + for(const effect of Object.values(itemEffects)){ + const lifecycle = effect.data.lifecycle; + if(lifecycle === 'itemTemporary') + addItemEffect(entity, itemKey, effect.data, -effect.stacks); } } - - 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.onPlay 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.player.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.player.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.player.itemBuffs = state.player.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; -} +} \ No newline at end of file diff --git a/src/samples/slay-the-spire-like/system/combat/index.ts b/src/samples/slay-the-spire-like/system/combat/index.ts deleted file mode 100644 index 137db0a..0000000 --- a/src/samples/slay-the-spire-like/system/combat/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -export type { - BuffTable, - CombatEffectEntry, - CombatEntity, - CombatGameContext, - CombatPhase, - CombatResult, - CombatState, - EffectData, - EffectTarget, - EffectTiming, - EncounterData, - EnemyIntentData, - 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"; diff --git a/src/samples/slay-the-spire-like/system/combat/procedure.ts b/src/samples/slay-the-spire-like/system/combat/procedure.ts deleted file mode 100644 index a32607c..0000000 --- a/src/samples/slay-the-spire-like/system/combat/procedure.ts +++ /dev/null @@ -1,309 +0,0 @@ -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, dispatchCardDrawnTrigger } 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 { - const triggerRegistry = createCombatTriggerRegistry(); - - await game.produceAsync(async (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)) { - await game.produceAsync((state) => { - state.result = "defeat"; - state.phase = "combatEnd"; - }); - return "defeat"; - } - - if (areAllEnemiesDead(game.value)) { - await game.produceAsync((state) => { - state.result = "victory"; - state.phase = "combatEnd"; - state.loot = generateLoot(state); - }); - return "victory"; - } - } - - return game.value.result ?? "defeat"; -} - -async function runPlayerTurn( - game: CombatGameContext, - triggerRegistry: CombatTriggerRegistry, -): Promise { - const triggerCtx = createTriggerContext(game); - - await game.produceAsync(async (state) => { - updateBuffs(state.player.buffs); - state.player.damageTakenThisTurn = 0; - state.player.damagedThisTurn = false; - state.player.cardsDiscardedThisTurn = 0; - - 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); - await game.produceAsync(async (state) => { - playCard({ state, rng: game._rng }, action.cardId!, action.targetId); - }); - - if (areAllEnemiesDead(game.value)) { - return; - } - if (isPlayerDead(game.value)) { - return; - } - continue; - } - - break; - } - - await game.prompt<{ action: "end" }>( - prompts.endTurn, - () => { - return { action: "end" as const }; - }, - "player" - ); - - dispatchTrigger(createTriggerContext(game), "onTurnEnd", "player", triggerRegistry); - - await game.produceAsync(async (state) => { - for (const cardId of [...state.player.deck.hand]) { - state.player.cardsDiscardedThisTurn++; - } - discardHand(state.player.deck); - }); - - await game.produceAsync(async (state) => { - if (state.player.deck.drawPile.length === 0) { - reshuffleWithFatigue(state); - dispatchShuffleTrigger(createTriggerContext(game), triggerRegistry); - } - const drawn = drawCardsToHand(state.player.deck, 5); - for (const cardId of drawn) { - dispatchCardDrawnTrigger(createTriggerContext(game), cardId, triggerRegistry); - } - state.player.energy = state.player.maxEnergy; - }); - - await game.produceAsync(async (state) => { - state.phase = "enemyTurn"; - }); -} - -async function runEnemyTurn( - game: CombatGameContext, - triggerRegistry: CombatTriggerRegistry, -): Promise { - const state = game.value; - - await game.produceAsync(async (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); - } - - await game.produceAsync(async (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); - } - - await game.produceAsync(async (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.player.fatigueAddedCount }); - state.player.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; -} diff --git a/src/samples/slay-the-spire-like/system/combat/prompts.ts b/src/samples/slay-the-spire-like/system/combat/prompts.ts index 4d08927..21bfd9e 100644 --- a/src/samples/slay-the-spire-like/system/combat/prompts.ts +++ b/src/samples/slay-the-spire-like/system/combat/prompts.ts @@ -1,12 +1,37 @@ import { createPromptDef } from "@/core/game"; +import {CombatGameContext} from "./types"; export const prompts = { - playCard: createPromptDef<[string, string?]>( - "play-card [targetId:string]", + mainAction: createPromptDef<[string, string?]>( + "main-action [targetId:string]", "选择卡牌并指定目标" ), - endTurn: createPromptDef<[]>( - "end-turn", - "结束回合" - ), }; + +export async function promptMainAction(game: CombatGameContext){ + return await game.prompt(prompts.mainAction, (cardId, targetId) => { + if(cardId === 'end-turn') return { + action: 'end-turn' as 'end-turn' + }; + + const exists = game.value.player.deck.hand.includes(cardId); + if(!exists) throw `卡牌"${cardId}"不在手牌中`; + + const card = game.value.player.deck.cards[cardId]; + const {targetType} = card.cardData; + if(targetType === 'single'){ + if(!targetId) throw `请指定目标`; + const target = game.value.enemies.find(e => e.id === targetId); + if(!target) throw `目标"${targetId}"不存在`; + if(!target.isAlive) throw `目标"${targetId}"已死亡`; + }else if(targetType === 'none'){ + if(targetId) throw `目标"${targetId}"无效`; + } + + return { + action: 'play' as 'play', + cardId, + targetId + }; + }); +} \ No newline at end of file diff --git a/src/samples/slay-the-spire-like/system/combat/state.ts b/src/samples/slay-the-spire-like/system/combat/state.ts deleted file mode 100644 index 2051347..0000000 --- a/src/samples/slay-the-spire-like/system/combat/state.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { GridInventory } from "../grid-inventory/types"; -import type { GameItemMeta, PlayerState } from "../progress/types"; -import type { PlayerDeck } from "../deck/types"; -import { generateDeckFromInventory, createStatusCard } from "../deck/factory"; -import { enemyDesertData, effectDesertData, cardDesertData } from "../data"; -import { createRNG } from "@/utils/rng"; -import type { - BuffTable, - CombatState, - CombatPhase, - EffectData, - EffectTiming, - EnemyIntentData, - EnemyState, - EncounterData, - 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, - hp: number, - initBuffs: [EffectData, number][], - idCounter: { value: number }, -): EnemyState { - idCounter.value++; - const id = `enemy-${idCounter.value}`; - const maxHp = hp; - const currentHp = hp; - - const buffs: BuffTable = {}; - for (const [effect, stacks] of initBuffs) { - buffs[effect.id] = (buffs[effect.id] ?? 0) + stacks; - } - - const intentData = buildIntentLookup(templateId); - const initialIntent = findInitialIntent(templateId); - - return { - id, - templateId, - hp: currentHp, - maxHp, - buffs, - currentIntentId: initialIntent ?? "", - intentData, - isAlive: true, - hadDefendBroken: false, - }; -} - -function findInitialIntent(enemyTemplateId: string): string | undefined { - for (const row of enemyDesertData) { - if (row.enemy === enemyTemplateId && row.initialIntent) { - return row.intentId; - } - } - return undefined; -} - -function buildIntentLookup(enemyTemplateId: string): Record { - const lookup: Record = {}; - for (const row of enemyDesertData) { - if (row.enemy === enemyTemplateId) { - lookup[row.intentId] = row as unknown as EnemyIntentData; - } - } - return lookup; -} - -export function createPlayerCombatState( - playerState: PlayerState, - inventory: GridInventory, -): 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, - itemBuffs: [], - fatigueAddedCount: 0, - }; -} - -export function createCombatState( - playerState: PlayerState, - inventory: GridInventory, - encounter: EncounterData, -): CombatState { - const idCounter = { value: 0 }; - const player = createPlayerCombatState(playerState, inventory); - - const enemies: Record = {}; - const enemyOrder: string[] = []; - const enemyTemplateData: Record = {}; - - for (const enemyEntry of encounter.enemies as unknown as [string, number, number][]) { - const [enemyId, hp, bonusHp] = enemyEntry; - const enemyRow = enemyDesertData.find((e) => e.enemy === enemyId); - const initBuffs: [EffectData, number][] = []; - if (enemyRow) { - for (const [effect, stacks] of enemyRow.initBuffs as unknown as [EffectData, number][]) { - initBuffs.push([effect, stacks]); - } - } - - const totalHp = hp + bonusHp; - const enemyInstance = createEnemyInstance( - enemyId, - totalHp, - initBuffs, - idCounter, - ); - enemies[enemyInstance.id] = enemyInstance; - enemyOrder.push(enemyInstance.id); - enemyTemplateData[enemyInstance.templateId] = enemyRow as unknown as EnemyIntentData; - } - - shuffleDeck(player.deck.drawPile, createRNG(0)); - - drawCardsToHand(player.deck, INITIAL_HAND_SIZE); - - return { - enemies, - enemyOrder, - player, - phase: "playerTurn" as CombatPhase, - turnNumber: 1, - result: null, - loot: [], - 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; - const fatigueDef = cardDesertData.find((c) => c.id === "fatigue"); - if (!fatigueDef) return 0; - - for (let i = 0; i < count; i++) { - fatigueCounter.value++; - const card = createStatusCard( - `fatigue-${fatigueCounter.value}`, - fatigueDef.name, - fatigueDef.desc, - ); - 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): EnemyIntentData | 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]; - enemy.hadDefendBroken = false; - return; - } - - if (current.nextIntents.length > 0) { - const idx = Math.floor(Math.random() * current.nextIntents.length); - enemy.currentIntentId = current.nextIntents[idx]; - return; - } - - enemy.currentIntentId = current.intentId; -} - -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]]; - } -} - -export function getEffectTiming(effectId: string): EffectTiming | undefined { - const effect = effectDesertData.find(e => e.id === effectId); - return effect?.timing; -} - -export function getEffectData(effectId: string): EffectData | undefined { - return effectDesertData.find(e => e.id === effectId) as EffectData | undefined; -} - -export { INITIAL_HAND_SIZE, DEFAULT_MAX_ENERGY, FATIGUE_CARDS_PER_SHUFFLE }; diff --git a/src/samples/slay-the-spire-like/system/combat/triggers.ts b/src/samples/slay-the-spire-like/system/combat/triggers.ts index d9242f6..a36aff9 100644 --- a/src/samples/slay-the-spire-like/system/combat/triggers.ts +++ b/src/samples/slay-the-spire-like/system/combat/triggers.ts @@ -1,7 +1,14 @@ import {createMiddlewareChain} from "../utils/middleware"; import {CombatGameContext} from "./types"; +import {getAliveEnemies} from "@/samples/slay-the-spire-like/system/combat/utils"; +import { + onEntityEffectUpkeep, + onPlayerItemEffectUpkeep +} from "@/samples/slay-the-spire-like/system/combat/effects"; +import {promptMainAction} from "@/samples/slay-the-spire-like/system/combat/prompts"; -export type Triggers = { +type TriggerTypes = { + onCombatStart: {}, onTurnStart: { entityKey: "player" | string, }, onTurnEnd: { entityKey: "player" | string, }, onShuffle: { entityKey: "player" | string, }, @@ -11,8 +18,9 @@ export type Triggers = { onEffectApplied: { effectId: string, entityKey: "player" | string, stacks: number, }, } -export function createTriggers(){ +function createTriggers(){ return { + onCombatStart: createTrigger("onCombatStart"), onTurnStart: createTrigger("onTurnStart"), onTurnEnd: createTrigger("onTurnEnd"), onShuffle: createTrigger("onShuffle"), @@ -22,8 +30,49 @@ export function createTriggers(){ onEffectApplied: createTrigger("onEffectApplied"), } } +export type Triggers = ReturnType +export function createStartWith(build: (triggers: Triggers) => void){ + const triggers = createTriggers(); + build(triggers); + return async function(game: CombatGameContext){ + await triggers.onCombatStart.execute(game,{}); + + while(true){ + await triggers.onTurnStart.execute(game,{entityKey: "player"}); + await game.produceAsync(draft => { + onEntityEffectUpkeep(draft.player); + onPlayerItemEffectUpkeep(draft.player); + }); + while(true){ + const action = await promptMainAction(game); + if(action.action === "end-turn") break; + //TODO resolve action here + } + // TODO discard cards here + await triggers.onTurnEnd.execute(game,{entityKey: "player"}); + // TODO recover energy, draw new cards here + + for(const enemy of getAliveEnemies(game.value)){ + await triggers.onTurnStart.execute(game,{entityKey: enemy.id}); + } + await game.produceAsync(draft => { + for(const enemy of getAliveEnemies(game.value)){ + onEntityEffectUpkeep(enemy); + } + }); + // TODO execute enemy intent, then update with new one here + for(const enemy of getAliveEnemies(game.value)){ + await triggers.onTurnEnd.execute(game,{entityKey: enemy.id}); + } + } + } +} -export function createTrigger(event: TKey) { - type Ctx = Triggers[TKey] & { event: TKey, game: CombatGameContext }; - return createMiddlewareChain(); +function createTrigger(event: TKey) { + type Ctx = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext }; + const {use, execute} = createMiddlewareChain(); + return { + use, + execute: (game: CombatGameContext, ctx: TriggerTypes[TKey]) => execute({...ctx, event, game}), + } } \ No newline at end of file diff --git a/src/samples/slay-the-spire-like/system/combat/types.ts b/src/samples/slay-the-spire-like/system/combat/types.ts index f2cd0dd..011dc73 100644 --- a/src/samples/slay-the-spire-like/system/combat/types.ts +++ b/src/samples/slay-the-spire-like/system/combat/types.ts @@ -1,7 +1,8 @@ import type { PlayerDeck } from "../deck/types"; -import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/type"; +import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/types"; +import {EffectData} from "@/samples/slay-the-spire-like/system/types"; -export type EffectTable = Record; +export type EffectTable = Record; export type CombatEntity = { effects: EffectTable; @@ -28,9 +29,11 @@ export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd"; export type CombatResult = "victory" | "defeat"; export type LootEntry = { - type: "gold" | "item" | "relic"; - amount?: number; - itemId?: string; + type: "gold"; + amount: number; +} | { + type: "item", + itemId: string; }; export type CombatState = { diff --git a/src/samples/slay-the-spire-like/system/combat/utils.ts b/src/samples/slay-the-spire-like/system/combat/utils.ts new file mode 100644 index 0000000..c263707 --- /dev/null +++ b/src/samples/slay-the-spire-like/system/combat/utils.ts @@ -0,0 +1,9 @@ +import {CombatState} from "@/samples/slay-the-spire-like/system"; + +export function* getAliveEnemies(state: CombatState) { + for (let enemy of state.enemies) { + if (enemy.isAlive) { + yield enemy; + } + } +} \ No newline at end of file