refactor: adjust implementation details for combat
This commit is contained in:
parent
f7b59a1790
commit
e3014e47a8
|
|
@ -375,7 +375,7 @@ function applyItemBuff(
|
|||
sourceItemId: card.sourceItemId,
|
||||
targetItemId: card.sourceItemId,
|
||||
};
|
||||
state.itemBuffs.push(itemBuff);
|
||||
state.player.itemBuffs.push(itemBuff);
|
||||
}
|
||||
|
||||
function removeWoundCards(deck: PlayerDeck, count: number): void {
|
||||
|
|
@ -436,9 +436,9 @@ export function getModifiedAttackDamage(
|
|||
): number {
|
||||
let damage = baseDamage;
|
||||
|
||||
const attackBuff = state.itemBuffs
|
||||
.filter(b => b.effectId === "attackBuff" || b.effectId === "attackBuffUntilPlay")
|
||||
.filter(b => {
|
||||
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;
|
||||
})
|
||||
|
|
@ -455,9 +455,9 @@ export function getModifiedDefendAmount(
|
|||
): number {
|
||||
let defend = baseDefend;
|
||||
|
||||
const defendBuff = state.itemBuffs
|
||||
.filter(b => b.effectId === "defendBuff" || b.effectId === "defendBuffUntilPlay")
|
||||
.filter(b => {
|
||||
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;
|
||||
})
|
||||
|
|
@ -521,7 +521,7 @@ function expireItemBuffsOnCardPlayed(state: CombatState, cardId: string): void {
|
|||
const card = state.player.deck.cards[cardId];
|
||||
if (!card || !card.sourceItemId) return;
|
||||
|
||||
state.itemBuffs = state.itemBuffs.filter(buff => {
|
||||
state.player.itemBuffs = state.player.itemBuffs.filter((buff) => {
|
||||
if (buff.timing === "itemUntilPlayed" && buff.sourceItemId === card.sourceItemId) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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 { createCombatTriggerRegistry, dispatchTrigger, dispatchShuffleTrigger, dispatchOutgoingDamageTrigger, dispatchIncomingDamageTrigger, dispatchDamageTrigger, dispatchCardDrawnTrigger } from "./triggers";
|
||||
import { prompts } from "./prompts";
|
||||
import {
|
||||
drawCardsToHand,
|
||||
|
|
@ -30,8 +30,7 @@ export async function runCombat(
|
|||
): Promise<CombatResult> {
|
||||
const triggerRegistry = createCombatTriggerRegistry();
|
||||
|
||||
// TODO prefer await game.produceAsync over game.produce since produceAsync can await ui animations
|
||||
game.produce(state => {
|
||||
await game.produceAsync(async (state) => {
|
||||
state.phase = "playerTurn";
|
||||
state.player.energy = state.player.maxEnergy;
|
||||
state.player.damageTakenThisTurn = 0;
|
||||
|
|
@ -55,7 +54,7 @@ export async function runCombat(
|
|||
}
|
||||
|
||||
if (isPlayerDead(game.value)) {
|
||||
game.produce(state => {
|
||||
await game.produceAsync((state) => {
|
||||
state.result = "defeat";
|
||||
state.phase = "combatEnd";
|
||||
});
|
||||
|
|
@ -63,20 +62,13 @@ export async function runCombat(
|
|||
}
|
||||
|
||||
if (areAllEnemiesDead(game.value)) {
|
||||
game.produce(state => {
|
||||
await game.produceAsync((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";
|
||||
|
|
@ -88,26 +80,15 @@ async function runPlayerTurn(
|
|||
): Promise<void> {
|
||||
const triggerCtx = createTriggerContext(game);
|
||||
|
||||
game.produce(state => {
|
||||
await game.produceAsync(async (state) => {
|
||||
updateBuffs(state.player.buffs);
|
||||
state.player.damageTakenThisTurn = 0;
|
||||
state.player.damagedThisTurn = false;
|
||||
state.player.cardsDiscardedThisTurn = 0;
|
||||
|
||||
// TODO avoid hardcoding here, prefer handling these in either the onTurnStart trigger or the onBuffUpdate trigger
|
||||
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);
|
||||
});
|
||||
|
||||
dispatchTrigger(triggerCtx, "onTurnStart", "player", triggerRegistry);
|
||||
|
||||
while (game.value.phase === "playerTurn") {
|
||||
const action = await game.prompt<{ action: "play" | "end"; cardId?: string; targetId?: string }>(
|
||||
prompts.playCard,
|
||||
|
|
@ -120,7 +101,7 @@ async function runPlayerTurn(
|
|||
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (card?.itemData?.targetType === "single") {
|
||||
const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive);
|
||||
const aliveEnemies = state.enemyOrder.filter((id) => state.enemies[id].isAlive);
|
||||
if (!targetId && aliveEnemies.length > 0) {
|
||||
throw "请指定目标";
|
||||
}
|
||||
|
|
@ -136,7 +117,7 @@ async function runPlayerTurn(
|
|||
|
||||
if (action.action === "play" && action.cardId) {
|
||||
const ctx = createEffectContext(game);
|
||||
game.produce(state => {
|
||||
await game.produceAsync(async (state) => {
|
||||
playCard({ state, rng: game._rng }, action.cardId!, action.targetId);
|
||||
});
|
||||
|
||||
|
|
@ -152,8 +133,7 @@ async function runPlayerTurn(
|
|||
break;
|
||||
}
|
||||
|
||||
// TODO end is an alternative action to be taken by the player.
|
||||
const endAction = await game.prompt<{ action: "end" }>(
|
||||
await game.prompt<{ action: "end" }>(
|
||||
prompts.endTurn,
|
||||
() => {
|
||||
return { action: "end" as const };
|
||||
|
|
@ -163,23 +143,26 @@ async function runPlayerTurn(
|
|||
|
||||
dispatchTrigger(createTriggerContext(game), "onTurnEnd", "player", triggerRegistry);
|
||||
|
||||
game.produce(state => {
|
||||
await game.produceAsync(async (state) => {
|
||||
for (const cardId of [...state.player.deck.hand]) {
|
||||
state.player.cardsDiscardedThisTurn++;
|
||||
}
|
||||
discardHand(state.player.deck);
|
||||
});
|
||||
|
||||
game.produce(state => {
|
||||
await game.produceAsync(async (state) => {
|
||||
if (state.player.deck.drawPile.length === 0) {
|
||||
reshuffleWithFatigue(state);
|
||||
dispatchShuffleTrigger(createTriggerContext(game), triggerRegistry);
|
||||
}
|
||||
drawCardsToHand(state.player.deck, 5);
|
||||
const drawn = drawCardsToHand(state.player.deck, 5);
|
||||
for (const cardId of drawn) {
|
||||
dispatchCardDrawnTrigger(createTriggerContext(game), cardId, triggerRegistry);
|
||||
}
|
||||
state.player.energy = state.player.maxEnergy;
|
||||
});
|
||||
|
||||
game.produce(state => {
|
||||
await game.produceAsync(async (state) => {
|
||||
state.phase = "enemyTurn";
|
||||
});
|
||||
}
|
||||
|
|
@ -190,7 +173,7 @@ async function runEnemyTurn(
|
|||
): Promise<void> {
|
||||
const state = game.value;
|
||||
|
||||
game.produce(state => {
|
||||
await game.produceAsync(async (state) => {
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
const enemy = state.enemies[enemyId];
|
||||
if (!enemy.isAlive) continue;
|
||||
|
|
@ -205,7 +188,7 @@ async function runEnemyTurn(
|
|||
dispatchTrigger(triggerCtx, "onTurnStart", enemyId, triggerRegistry);
|
||||
}
|
||||
|
||||
game.produce(state => {
|
||||
await game.produceAsync(async (state) => {
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
const enemy = state.enemies[enemyId];
|
||||
if (!enemy.isAlive) continue;
|
||||
|
|
@ -245,7 +228,7 @@ async function runEnemyTurn(
|
|||
dispatchTrigger(createTriggerContext(game), "onTurnEnd", enemyId, triggerRegistry);
|
||||
}
|
||||
|
||||
game.produce(state => {
|
||||
await game.produceAsync(async (state) => {
|
||||
state.phase = "playerTurn";
|
||||
state.turnNumber++;
|
||||
});
|
||||
|
|
@ -289,8 +272,8 @@ function reshuffleWithFatigue(state: CombatState): void {
|
|||
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;
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import type { EnemyDesert } from "../data/enemyDesert.csv";
|
|||
import type { EffectDesert } from "../data/effectDesert.csv";
|
||||
import type { EncounterDesert } from "../data/encounterDesert.csv";
|
||||
import { generateDeckFromInventory, createStatusCard } from "../deck/factory";
|
||||
import { enemyDesertData, effectDesertData } from "../data";
|
||||
import { enemyDesertData, effectDesertData, cardDesertData } from "../data";
|
||||
import { createRNG } from "@/utils/rng";
|
||||
import type {
|
||||
BuffTable,
|
||||
CombatState,
|
||||
|
|
@ -86,6 +87,8 @@ export function createPlayerCombatState(
|
|||
damageTakenThisTurn: 0,
|
||||
damagedThisTurn: false,
|
||||
cardsDiscardedThisTurn: 0,
|
||||
itemBuffs: [],
|
||||
fatigueAddedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -101,9 +104,10 @@ export function createCombatState(
|
|||
const enemyOrder: string[] = [];
|
||||
const enemyTemplateData: Record<string, EnemyDesert> = {};
|
||||
|
||||
for (const [enemyId, hp, bonusHp] of encounter.enemies) {
|
||||
for (const enemyEntry of encounter.enemies as unknown as [string, number, number][]) {
|
||||
const [enemyId, hp, bonusHp] = enemyEntry;
|
||||
// Find initBuffs from enemyDesert (first row for this enemy type)
|
||||
const enemyRow = enemyDesertData.find(e => e.enemy === enemyId);
|
||||
const enemyRow = enemyDesertData.find((e) => e.enemy === enemyId);
|
||||
const initBuffs: [EffectDesert, number][] = [];
|
||||
if (enemyRow) {
|
||||
for (const [effect, stacks] of enemyRow.initBuffs) {
|
||||
|
|
@ -123,7 +127,7 @@ export function createCombatState(
|
|||
enemyTemplateData[enemyInstance.templateId] = enemyRow!;
|
||||
}
|
||||
|
||||
shuffleDeck(player.deck.drawPile, buildSimpleRNG(0));
|
||||
shuffleDeck(player.deck.drawPile, createRNG(0));
|
||||
|
||||
drawCardsToHand(player.deck, INITIAL_HAND_SIZE);
|
||||
|
||||
|
|
@ -135,8 +139,6 @@ export function createCombatState(
|
|||
turnNumber: 1,
|
||||
result: null,
|
||||
loot: [],
|
||||
itemBuffs: [],
|
||||
fatigueAddedCount: 0,
|
||||
enemyTemplateData,
|
||||
};
|
||||
}
|
||||
|
|
@ -145,7 +147,6 @@ export function drawCardsToHand(deck: PlayerDeck, count: number): string[] {
|
|||
const drawn: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (deck.drawPile.length === 0) {
|
||||
// TODO think we should shuffle Fatigue into the deck here.
|
||||
reshuffleDiscardIntoDraw(deck);
|
||||
}
|
||||
if (deck.drawPile.length === 0) break;
|
||||
|
|
@ -171,13 +172,15 @@ export function reshuffleDiscardIntoDraw(deck: PlayerDeck): void {
|
|||
|
||||
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++;
|
||||
// TODO avoid hard coding, expect a fatigue card to be in the CSV with a specific id.
|
||||
const card = createStatusCard(
|
||||
`fatigue-${fatigueCounter.value}`,
|
||||
"疲劳",
|
||||
"1费/消耗",
|
||||
fatigueDef.name,
|
||||
fatigueDef.desc,
|
||||
);
|
||||
deck.cards[card.id] = card;
|
||||
deck.drawPile.push(card.id);
|
||||
|
|
@ -239,19 +242,6 @@ function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number }
|
|||
}
|
||||
}
|
||||
|
||||
// TODO why? use @/utils/rng.ts instead?
|
||||
function buildSimpleRNG(seed: number) {
|
||||
let s = seed;
|
||||
return {
|
||||
nextInt(max: number) {
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), s | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return Math.floor(((t ^ (t >>> 14)) >>> 0) / 4294967296 * max);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectTiming(effectId: string): EffectDesert["timing"] | undefined {
|
||||
const effect = effectDesertData.find(e => e.id === effectId);
|
||||
return effect?.timing;
|
||||
|
|
|
|||
|
|
@ -9,9 +9,6 @@ export type TriggerContext = {
|
|||
rng: { nextInt: (n: number) => number };
|
||||
};
|
||||
|
||||
// TODO add an onCardDrawn trigger
|
||||
// TODO refactor this to NOT implicitly correspond to an effect type, but generic event handler
|
||||
// TODO also, refactor this to be async to support prompts and produceAsync
|
||||
export type BuffTriggerBehavior = {
|
||||
onTurnStart?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
|
||||
onTurnEnd?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
|
||||
|
|
@ -22,9 +19,21 @@ export type BuffTriggerBehavior = {
|
|||
onShuffle?: (ctx: TriggerContext, stacks: number) => void;
|
||||
onCardPlayed?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
|
||||
onCardDiscarded?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
|
||||
onCardDrawn?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
|
||||
};
|
||||
|
||||
// TODO refactor this to be keyed by event type
|
||||
export type TriggerEvent =
|
||||
| "onTurnStart"
|
||||
| "onTurnEnd"
|
||||
| "onAttacked"
|
||||
| "onDamage"
|
||||
| "modifyOutgoingDamage"
|
||||
| "modifyIncomingDamage"
|
||||
| "onShuffle"
|
||||
| "onCardPlayed"
|
||||
| "onCardDiscarded"
|
||||
| "onCardDrawn";
|
||||
|
||||
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
|
||||
|
||||
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
|
||||
|
|
@ -121,7 +130,6 @@ export function createCombatTriggerRegistry(): CombatTriggerRegistry {
|
|||
const moltStacks = enemy.buffs["molt"] ?? 0;
|
||||
if (moltStacks >= enemy.maxHp) {
|
||||
enemy.isAlive = false;
|
||||
state.result = "fled";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +149,7 @@ export function createCombatTriggerRegistry(): CombatTriggerRegistry {
|
|||
const { state } = ctx;
|
||||
if (targetKey === "player" && damage > 0) {
|
||||
const vultureEnemies = state.enemyOrder.filter(
|
||||
id => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫"
|
||||
(id) => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫"
|
||||
);
|
||||
if (vultureEnemies.length > 0) {
|
||||
for (const vultureId of vultureEnemies) {
|
||||
|
|
@ -164,7 +172,7 @@ export function createCombatTriggerRegistry(): CombatTriggerRegistry {
|
|||
onCardDiscarded(ctx, cardId, stacks) {
|
||||
const { state } = ctx;
|
||||
state.player.cardsDiscardedThisTurn++;
|
||||
const venomCards = state.player.deck.hand.filter(id => {
|
||||
const venomCards = state.player.deck.hand.filter((id) => {
|
||||
const card = state.player.deck.cards[id];
|
||||
return card && card.itemData === null && card.displayName === "蛇毒";
|
||||
});
|
||||
|
|
@ -197,7 +205,7 @@ export function createCombatTriggerRegistry(): CombatTriggerRegistry {
|
|||
}
|
||||
|
||||
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
|
||||
const cardDef = cardDesertData.find(c => c.id === effectId);
|
||||
const cardDef = cardDesertData.find((c) => c.id === effectId);
|
||||
if (!cardDef) return;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
|
|
@ -208,20 +216,9 @@ function addStatusCardToHand(state: CombatState, effectId: string, count: number
|
|||
}
|
||||
}
|
||||
|
||||
export type TriggerEvent =
|
||||
| "onTurnStart"
|
||||
| "onTurnEnd"
|
||||
| "onAttacked"
|
||||
| "onDamage"
|
||||
| "modifyOutgoingDamage"
|
||||
| "modifyIncomingDamage"
|
||||
| "onShuffle"
|
||||
| "onCardPlayed"
|
||||
| "onCardDiscarded";
|
||||
|
||||
export function dispatchTrigger(
|
||||
ctx: TriggerContext,
|
||||
event: "onTurnStart" | "onTurnEnd",
|
||||
event: TriggerEvent,
|
||||
entityKey: "player" | string,
|
||||
registry: CombatTriggerRegistry,
|
||||
): void {
|
||||
|
|
@ -333,3 +330,18 @@ export function dispatchShuffleTrigger(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchCardDrawnTrigger(
|
||||
ctx: TriggerContext,
|
||||
cardId: string,
|
||||
registry: CombatTriggerRegistry,
|
||||
): void {
|
||||
const buffs = ctx.state.player.buffs;
|
||||
if (!buffs) return;
|
||||
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.onCardDrawn) continue;
|
||||
behavior.onCardDrawn(ctx, cardId, stacks);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// TODO shouldn't rely on csv types. Use interfaces and expect csv types to match.
|
||||
import type { EnemyDesert } from "../data/enemyDesert.csv";
|
||||
import type { EffectDesert } from "../data/effectDesert.csv";
|
||||
import type { PlayerDeck, GameCard } from "../deck/types";
|
||||
|
|
@ -6,7 +5,7 @@ import type { PlayerState } from "../progress/types";
|
|||
|
||||
export type BuffTable = Record<string, number>;
|
||||
|
||||
// TODO rename this to "lifecycle". Should use lifecycle in csv as well.
|
||||
/** Lifecycle timing for effects - matches CSV timing column */
|
||||
export type EffectTiming = EffectDesert["timing"];
|
||||
|
||||
export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team";
|
||||
|
|
@ -41,11 +40,13 @@ export type PlayerCombatState = {
|
|||
damageTakenThisTurn: number;
|
||||
damagedThisTurn: boolean;
|
||||
cardsDiscardedThisTurn: number;
|
||||
itemBuffs: ItemBuff[];
|
||||
fatigueAddedCount: number;
|
||||
};
|
||||
|
||||
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
|
||||
|
||||
export type CombatResult = "victory" | "defeat" | "fled";
|
||||
export type CombatResult = "victory" | "defeat";
|
||||
|
||||
export type LootEntry = {
|
||||
type: "gold" | "item" | "relic";
|
||||
|
|
@ -59,14 +60,8 @@ export type CombatState = {
|
|||
player: PlayerCombatState;
|
||||
phase: CombatPhase;
|
||||
turnNumber: number;
|
||||
// TODO: "fled" is a per-enemy state. Should remove it here and expand the isAlive property on enemy instead.
|
||||
// TODO: CombatResult should just be "victory" "defeat" or null.
|
||||
result: CombatResult | null;
|
||||
loot: LootEntry[];
|
||||
// TODO: I think this belongs to the player combat state.
|
||||
itemBuffs: ItemBuff[];
|
||||
// TODO: Also belongs to the player combat state.
|
||||
fatigueAddedCount: number;
|
||||
enemyTemplateData: Record<string, EnemyDesert>;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue