refactor: combat rewrite
This commit is contained in:
parent
7d8684a16f
commit
a469b4024a
|
|
@ -1,537 +1,44 @@
|
||||||
import { effectDesertData, cardDesertData } from "../data";
|
import {CombatEntity, EffectTable} from "./types";
|
||||||
import { createStatusCard } from "../deck/factory";
|
import {EffectData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
import type { PlayerDeck, GameCard } from "../deck/types";
|
import {PlayerEntity} from "@/samples/slay-the-spire-like/system/combat/types";
|
||||||
import type {
|
|
||||||
BuffTable,
|
|
||||||
CombatEffectEntry,
|
|
||||||
CombatState,
|
|
||||||
EffectData,
|
|
||||||
EffectTarget,
|
|
||||||
EffectTiming,
|
|
||||||
EnemyState,
|
|
||||||
ItemBuff,
|
|
||||||
PlayerCombatState,
|
|
||||||
} from "./types";
|
|
||||||
import {
|
|
||||||
drawCardsToHand,
|
|
||||||
addFatigueCards,
|
|
||||||
discardCard,
|
|
||||||
exhaustCard,
|
|
||||||
getEffectData,
|
|
||||||
discardHand,
|
|
||||||
} from "./state";
|
|
||||||
|
|
||||||
export type DamageResult = {
|
export function addEffect(effects: EffectTable, effect: EffectData, stacks: number){
|
||||||
damageDealt: number;
|
let current = effects[effect.id];
|
||||||
blockedByDefend: number;
|
|
||||||
targetDied: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function applyDamage(
|
if(!current) current = {data: effect, stacks};
|
||||||
state: CombatState,
|
else current.stacks += stacks;
|
||||||
targetKey: "player" | string,
|
|
||||||
amount: number,
|
|
||||||
sourceKey?: "player" | string,
|
|
||||||
): DamageResult {
|
|
||||||
if (amount <= 0) return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
|
|
||||||
|
|
||||||
let actualDamage = amount;
|
if(current.stacks === 0 && effects[effect.id])
|
||||||
let blockedByDefend = 0;
|
delete effects[effect.id];
|
||||||
|
else if(current.stacks !== 0 && !effects[effect.id])
|
||||||
if (targetKey === "player") {
|
effects[effect.id] = current;
|
||||||
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(
|
export function addEntityEffect(entity: CombatEntity, effect: EffectData, stacks: number){
|
||||||
targetBuffs: BuffTable,
|
addEffect(entity.effects, effect, stacks);
|
||||||
amount: number,
|
|
||||||
): void {
|
|
||||||
if (amount <= 0) return;
|
|
||||||
targetBuffs["defend"] = (targetBuffs["defend"] ?? 0) + amount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyBuff(
|
export function addItemEffect(entity: PlayerEntity, itemKey: string, effect: EffectData, stacks: number){
|
||||||
buffs: BuffTable,
|
entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {};
|
||||||
effectId: string,
|
addEffect(entity.itemEffects[itemKey], effect, stacks);
|
||||||
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 {
|
export function onEntityEffectUpkeep(entity: CombatEntity){
|
||||||
const current = buffs[effectId] ?? 0;
|
for(const effect of Object.values(entity.effects)){
|
||||||
if (stacks === undefined || stacks >= current) {
|
const lifecycle = effect.data.lifecycle;
|
||||||
delete buffs[effectId];
|
if(lifecycle === 'temporary')
|
||||||
return current;
|
addEntityEffect(entity, effect.data, -effect.stacks);
|
||||||
}
|
else if(lifecycle === 'lingering')
|
||||||
buffs[effectId] = current - stacks;
|
addEntityEffect(entity, effect.data, effect.stacks >= 0 ? -1 : 1);
|
||||||
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 = {
|
export function onPlayerItemEffectUpkeep(entity: PlayerEntity){
|
||||||
state: CombatState;
|
for(const [itemKey, itemEffects] of Object.entries(entity.itemEffects)){
|
||||||
rng: { nextInt: (n: number) => number };
|
for(const effect of Object.values(itemEffects)){
|
||||||
};
|
const lifecycle = effect.data.lifecycle;
|
||||||
|
if(lifecycle === 'itemTemporary')
|
||||||
export function resolveEffect(
|
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
|
||||||
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++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
@ -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<CombatResult> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,37 @@
|
||||||
import { createPromptDef } from "@/core/game";
|
import { createPromptDef } from "@/core/game";
|
||||||
|
import {CombatGameContext} from "./types";
|
||||||
|
|
||||||
export const prompts = {
|
export const prompts = {
|
||||||
playCard: createPromptDef<[string, string?]>(
|
mainAction: createPromptDef<[string, string?]>(
|
||||||
"play-card <cardId:string> [targetId:string]",
|
"main-action <cardId:string> [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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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<string, EnemyIntentData> {
|
|
||||||
const lookup: Record<string, EnemyIntentData> = {};
|
|
||||||
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<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,
|
|
||||||
itemBuffs: [],
|
|
||||||
fatigueAddedCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCombatState(
|
|
||||||
playerState: PlayerState,
|
|
||||||
inventory: GridInventory<GameItemMeta>,
|
|
||||||
encounter: EncounterData,
|
|
||||||
): CombatState {
|
|
||||||
const idCounter = { value: 0 };
|
|
||||||
const player = createPlayerCombatState(playerState, inventory);
|
|
||||||
|
|
||||||
const enemies: Record<string, EnemyState> = {};
|
|
||||||
const enemyOrder: string[] = [];
|
|
||||||
const enemyTemplateData: Record<string, EnemyIntentData> = {};
|
|
||||||
|
|
||||||
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 };
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import {createMiddlewareChain} from "../utils/middleware";
|
import {createMiddlewareChain} from "../utils/middleware";
|
||||||
import {CombatGameContext} from "./types";
|
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, },
|
onTurnStart: { entityKey: "player" | string, },
|
||||||
onTurnEnd: { entityKey: "player" | string, },
|
onTurnEnd: { entityKey: "player" | string, },
|
||||||
onShuffle: { entityKey: "player" | string, },
|
onShuffle: { entityKey: "player" | string, },
|
||||||
|
|
@ -11,8 +18,9 @@ export type Triggers = {
|
||||||
onEffectApplied: { effectId: string, entityKey: "player" | string, stacks: number, },
|
onEffectApplied: { effectId: string, entityKey: "player" | string, stacks: number, },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTriggers(){
|
function createTriggers(){
|
||||||
return {
|
return {
|
||||||
|
onCombatStart: createTrigger("onCombatStart"),
|
||||||
onTurnStart: createTrigger("onTurnStart"),
|
onTurnStart: createTrigger("onTurnStart"),
|
||||||
onTurnEnd: createTrigger("onTurnEnd"),
|
onTurnEnd: createTrigger("onTurnEnd"),
|
||||||
onShuffle: createTrigger("onShuffle"),
|
onShuffle: createTrigger("onShuffle"),
|
||||||
|
|
@ -22,8 +30,49 @@ export function createTriggers(){
|
||||||
onEffectApplied: createTrigger("onEffectApplied"),
|
onEffectApplied: createTrigger("onEffectApplied"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export type Triggers = ReturnType<typeof createTriggers>
|
||||||
|
export function createStartWith(build: (triggers: Triggers) => void){
|
||||||
|
const triggers = createTriggers();
|
||||||
|
build(triggers);
|
||||||
|
return async function(game: CombatGameContext){
|
||||||
|
await triggers.onCombatStart.execute(game,{});
|
||||||
|
|
||||||
export function createTrigger<TKey extends keyof Triggers>(event: TKey) {
|
while(true){
|
||||||
type Ctx = Triggers[TKey] & { event: TKey, game: CombatGameContext };
|
await triggers.onTurnStart.execute(game,{entityKey: "player"});
|
||||||
return createMiddlewareChain<Ctx>();
|
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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrigger<TKey extends keyof TriggerTypes>(event: TKey) {
|
||||||
|
type Ctx = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext };
|
||||||
|
const {use, execute} = createMiddlewareChain<Ctx>();
|
||||||
|
return {
|
||||||
|
use,
|
||||||
|
execute: (game: CombatGameContext, ctx: TriggerTypes[TKey]) => execute({...ctx, event, game}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import type { PlayerDeck } from "../deck/types";
|
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<string, number>;
|
export type EffectTable = Record<string, {data: EffectData, stacks: number}>;
|
||||||
|
|
||||||
export type CombatEntity = {
|
export type CombatEntity = {
|
||||||
effects: EffectTable;
|
effects: EffectTable;
|
||||||
|
|
@ -28,9 +29,11 @@ export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
|
||||||
export type CombatResult = "victory" | "defeat";
|
export type CombatResult = "victory" | "defeat";
|
||||||
|
|
||||||
export type LootEntry = {
|
export type LootEntry = {
|
||||||
type: "gold" | "item" | "relic";
|
type: "gold";
|
||||||
amount?: number;
|
amount: number;
|
||||||
itemId?: string;
|
} | {
|
||||||
|
type: "item",
|
||||||
|
itemId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CombatState = {
|
export type CombatState = {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue