From f7b59a1790860142086b2f7c6a9b32c080e5d337 Mon Sep 17 00:00:00 2001 From: hyper Date: Thu, 16 Apr 2026 19:27:25 +0800 Subject: [PATCH] refactor: bunch of reorg updates --- src/samples/slay-the-spire-like/TODO.md | 2 + .../slay-the-spire-like/combat/effects.ts | 12 ++-- .../slay-the-spire-like/combat/procedure.ts | 3 + .../slay-the-spire-like/combat/state.ts | 70 ++++++++++++------- .../slay-the-spire-like/combat/triggers.ts | 4 +- .../slay-the-spire-like/combat/types.ts | 3 +- .../data/cardDesert.csv.d.ts | 20 ++++++ .../data/effectDesert.csv.d.ts | 11 +++ .../data/encounterDesert.csv.d.ts | 12 ++++ .../data/enemyDesert.csv.d.ts | 16 +++++ .../data/heroItemFighter1.csv.d.ts | 14 ++++ src/samples/slay-the-spire-like/index.ts | 4 +- tsup.samples.config.ts | 2 +- 13 files changed, 136 insertions(+), 37 deletions(-) create mode 100644 src/samples/slay-the-spire-like/TODO.md create mode 100644 src/samples/slay-the-spire-like/data/cardDesert.csv.d.ts create mode 100644 src/samples/slay-the-spire-like/data/effectDesert.csv.d.ts create mode 100644 src/samples/slay-the-spire-like/data/encounterDesert.csv.d.ts create mode 100644 src/samples/slay-the-spire-like/data/enemyDesert.csv.d.ts create mode 100644 src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts diff --git a/src/samples/slay-the-spire-like/TODO.md b/src/samples/slay-the-spire-like/TODO.md new file mode 100644 index 0000000..7f69cb6 --- /dev/null +++ b/src/samples/slay-the-spire-like/TODO.md @@ -0,0 +1,2 @@ +# 《背包爬塔》肉鸽 + diff --git a/src/samples/slay-the-spire-like/combat/effects.ts b/src/samples/slay-the-spire-like/combat/effects.ts index 7021b42..6d3d486 100644 --- a/src/samples/slay-the-spire-like/combat/effects.ts +++ b/src/samples/slay-the-spire-like/combat/effects.ts @@ -1,6 +1,6 @@ import type { EffectDesert } from "../data/effectDesert.csv"; -import type { StatusCardDesert } from "../data/statusCardDesert.csv"; -import { effectDesertData, statusCardDesertData } from "../data"; +import type { CardDesert } from "../data/cardDesert.csv"; +import { effectDesertData, cardDesertData } from "../data"; import { createStatusCard } from "../deck/factory"; import type { PlayerDeck, GameCard } from "../deck/types"; import type { @@ -321,7 +321,7 @@ function resolveInstantEffect( } function addStatusCardToDiscard(state: CombatState, effectId: string, count: number): void { - const cardDef = statusCardDesertData.find(c => c.id === effectId); + const cardDef = cardDesertData.find(c => c.id === effectId); if (!cardDef) return; for (let i = 0; i < count; i++) { @@ -333,7 +333,7 @@ function addStatusCardToDiscard(state: CombatState, effectId: string, count: num } function addStatusCardToDrawPile(state: CombatState, effectId: string, count: number): void { - const cardDef = statusCardDesertData.find(c => c.id === effectId); + const cardDef = cardDesertData.find(c => c.id === effectId); if (!cardDef) return; for (let i = 0; i < count; i++) { @@ -345,7 +345,7 @@ function addStatusCardToDrawPile(state: CombatState, effectId: string, count: nu } function addStatusCardToHand(state: CombatState, effectId: string, count: number): void { - const cardDef = statusCardDesertData.find(c => c.id === effectId); + const cardDef = cardDesertData.find(c => c.id === effectId); if (!cardDef) return; for (let i = 0; i < count; i++) { @@ -411,7 +411,7 @@ export function resolveCardEffects( const sourceKey: "player" | string = "player"; - const effects = card.itemData.effects as unknown as CombatEffectEntry[]; + const effects = card.itemData.onPlay as unknown as CombatEffectEntry[]; for (const entry of effects) { const [target, effect, stacks] = entry; diff --git a/src/samples/slay-the-spire-like/combat/procedure.ts b/src/samples/slay-the-spire-like/combat/procedure.ts index d65f86a..99bbb4b 100644 --- a/src/samples/slay-the-spire-like/combat/procedure.ts +++ b/src/samples/slay-the-spire-like/combat/procedure.ts @@ -30,6 +30,7 @@ export async function runCombat( ): Promise { const triggerRegistry = createCombatTriggerRegistry(); + // TODO prefer await game.produceAsync over game.produce since produceAsync can await ui animations game.produce(state => { state.phase = "playerTurn"; state.player.energy = state.player.maxEnergy; @@ -93,6 +94,7 @@ async function runPlayerTurn( 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"]; } @@ -150,6 +152,7 @@ async function runPlayerTurn( break; } + // TODO end is an alternative action to be taken by the player. const endAction = await game.prompt<{ action: "end" }>( prompts.endTurn, () => { diff --git a/src/samples/slay-the-spire-like/combat/state.ts b/src/samples/slay-the-spire-like/combat/state.ts index 782d9b2..b5848b0 100644 --- a/src/samples/slay-the-spire-like/combat/state.ts +++ b/src/samples/slay-the-spire-like/combat/state.ts @@ -2,11 +2,10 @@ import type { GridInventory } from "../grid-inventory/types"; import type { GameItemMeta, PlayerState } from "../progress/types"; import type { PlayerDeck } from "../deck/types"; import type { EnemyDesert } from "../data/enemyDesert.csv"; -import type { EnemyIntentDesert } from "../data/enemyIntentDesert.csv"; -import type { EncounterDesert } from "../data/encounterDesert.csv"; import type { EffectDesert } from "../data/effectDesert.csv"; +import type { EncounterDesert } from "../data/encounterDesert.csv"; import { generateDeckFromInventory, createStatusCard } from "../deck/factory"; -import { enemyDesertData, enemyIntentDesertData, effectDesertData } from "../data"; +import { enemyDesertData, effectDesertData } from "../data"; import type { BuffTable, CombatState, @@ -23,40 +22,50 @@ const FATIGUE_CARDS_PER_SHUFFLE = 2; export function createEnemyInstance( templateId: string, - enemyData: EnemyDesert, - bonusHp: number, + hp: number, + initBuffs: [EffectDesert, number][], idCounter: { value: number }, ): EnemyState { idCounter.value++; const id = `enemy-${idCounter.value}`; - const maxHp = enemyData.initHp + bonusHp; - const hp = maxHp; + const maxHp = hp; + const currentHp = hp; const buffs: BuffTable = {}; - for (const [effect, stacks] of enemyData.initBuffs) { + 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, + hp: currentHp, maxHp, buffs, - currentIntentId: enemyData.initialIntent, + currentIntentId: initialIntent ?? "", intentData, isAlive: true, hadDefendBroken: false, }; } -function buildIntentLookup(enemyTemplateId: string): Record { - const lookup: Record = {}; - for (const intent of enemyIntentDesertData) { - if (intent.enemy.id === enemyTemplateId) { - lookup[intent.id] = intent; +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; } } return lookup; @@ -92,16 +101,26 @@ export function createCombatState( const enemyOrder: string[] = []; const enemyTemplateData: Record = {}; - for (const [enemyRef, bonusHp] of encounter.enemies) { + for (const [enemyId, hp, bonusHp] of encounter.enemies) { + // Find initBuffs from enemyDesert (first row for this enemy type) + const enemyRow = enemyDesertData.find(e => e.enemy === enemyId); + const initBuffs: [EffectDesert, number][] = []; + if (enemyRow) { + for (const [effect, stacks] of enemyRow.initBuffs) { + initBuffs.push([effect, stacks]); + } + } + + const totalHp = hp + bonusHp; const enemyInstance = createEnemyInstance( - enemyRef.id, - enemyRef, - bonusHp, + enemyId, + totalHp, + initBuffs, idCounter, ); enemies[enemyInstance.id] = enemyInstance; enemyOrder.push(enemyInstance.id); - enemyTemplateData[enemyInstance.templateId] = enemyRef; + enemyTemplateData[enemyInstance.templateId] = enemyRow!; } shuffleDeck(player.deck.drawPile, buildSimpleRNG(0)); @@ -126,6 +145,7 @@ 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; @@ -153,6 +173,7 @@ export function addFatigueCards(deck: PlayerDeck, count: number, fatigueCounter: let added = 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}`, "疲劳", @@ -187,7 +208,7 @@ export function exhaustCard(deck: PlayerDeck, cardId: string): void { } } -export function getEnemyCurrentIntent(enemy: EnemyState): EnemyIntentDesert | undefined { +export function getEnemyCurrentIntent(enemy: EnemyState): EnemyDesert | undefined { return enemy.intentData[enemy.currentIntentId]; } @@ -197,18 +218,18 @@ export function advanceEnemyIntent(enemy: EnemyState): void { if (enemy.hadDefendBroken && current.brokenIntent.length > 0) { const idx = Math.floor(Math.random() * current.brokenIntent.length); - enemy.currentIntentId = current.brokenIntent[idx].id; + 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].id; + enemy.currentIntentId = current.nextIntents[idx]; return; } - enemy.currentIntentId = current.id; + enemy.currentIntentId = current.intentId; } function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number }): void { @@ -218,6 +239,7 @@ function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number } } } +// TODO why? use @/utils/rng.ts instead? function buildSimpleRNG(seed: number) { let s = seed; return { diff --git a/src/samples/slay-the-spire-like/combat/triggers.ts b/src/samples/slay-the-spire-like/combat/triggers.ts index 84e1117..9eb5fd8 100644 --- a/src/samples/slay-the-spire-like/combat/triggers.ts +++ b/src/samples/slay-the-spire-like/combat/triggers.ts @@ -1,5 +1,5 @@ import type { EffectDesert } from "../data/effectDesert.csv"; -import { statusCardDesertData } from "../data"; +import { cardDesertData } from "../data"; import { createStatusCard } from "../deck/factory"; import type { BuffTable, CombatEffectEntry, CombatState } from "./types"; import { applyDamage, removeBuff } from "./effects"; @@ -197,7 +197,7 @@ export function createCombatTriggerRegistry(): CombatTriggerRegistry { } function addStatusCardToHand(state: CombatState, effectId: string, count: number): void { - const cardDef = statusCardDesertData.find(c => c.id === effectId); + const cardDef = cardDesertData.find(c => c.id === effectId); if (!cardDef) return; for (let i = 0; i < count; i++) { diff --git a/src/samples/slay-the-spire-like/combat/types.ts b/src/samples/slay-the-spire-like/combat/types.ts index 60048fb..b375528 100644 --- a/src/samples/slay-the-spire-like/combat/types.ts +++ b/src/samples/slay-the-spire-like/combat/types.ts @@ -1,6 +1,5 @@ // TODO shouldn't rely on csv types. Use interfaces and expect csv types to match. import type { EnemyDesert } from "../data/enemyDesert.csv"; -import type { EnemyIntentDesert } from "../data/enemyIntentDesert.csv"; import type { EffectDesert } from "../data/effectDesert.csv"; import type { PlayerDeck, GameCard } from "../deck/types"; import type { PlayerState } from "../progress/types"; @@ -27,7 +26,7 @@ export type EnemyState = { maxHp: number; buffs: BuffTable; currentIntentId: string; - intentData: Record; + intentData: Record; isAlive: boolean; hadDefendBroken: boolean; }; diff --git a/src/samples/slay-the-spire-like/data/cardDesert.csv.d.ts b/src/samples/slay-the-spire-like/data/cardDesert.csv.d.ts new file mode 100644 index 0000000..6f9afba --- /dev/null +++ b/src/samples/slay-the-spire-like/data/cardDesert.csv.d.ts @@ -0,0 +1,20 @@ +import type { EffectDesert } from './effectDesert.csv'; + +type CardDesertTable = readonly { + readonly id: string; + readonly name: string; + readonly desc: string; + readonly type: "item" | "status"; + readonly costType: "energy" | "uses" | "none"; + readonly costCount: number; + readonly targetType: "single" | "none"; + readonly unplayable: boolean; + readonly onPlay: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number]; + readonly onDraw: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number]; + readonly onDiscard: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number]; +}[]; + +export type CardDesert = CardDesertTable[number]; + +declare function getData(): CardDesertTable; +export default getData; diff --git a/src/samples/slay-the-spire-like/data/effectDesert.csv.d.ts b/src/samples/slay-the-spire-like/data/effectDesert.csv.d.ts new file mode 100644 index 0000000..1847600 --- /dev/null +++ b/src/samples/slay-the-spire-like/data/effectDesert.csv.d.ts @@ -0,0 +1,11 @@ +type EffectDesertTable = readonly { + readonly id: string; + readonly name: string; + readonly description: string; + readonly timing: "instant" | "temporary" | "lingering" | "permanent" | "posture" | "card" | "cardDraw" | "cardHand" | "item" | "itemUntilPlayed"; +}[]; + +export type EffectDesert = EffectDesertTable[number]; + +declare function getData(): EffectDesertTable; +export default getData; diff --git a/src/samples/slay-the-spire-like/data/encounterDesert.csv.d.ts b/src/samples/slay-the-spire-like/data/encounterDesert.csv.d.ts new file mode 100644 index 0000000..fc2069c --- /dev/null +++ b/src/samples/slay-the-spire-like/data/encounterDesert.csv.d.ts @@ -0,0 +1,12 @@ +type EncounterDesertTable = readonly { + readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio"; + readonly name: string; + readonly description: string; + readonly enemies: readonly [string, number, number]; + readonly dialogue: string; +}[]; + +export type EncounterDesert = EncounterDesertTable[number]; + +declare function getData(): EncounterDesertTable; +export default getData; diff --git a/src/samples/slay-the-spire-like/data/enemyDesert.csv.d.ts b/src/samples/slay-the-spire-like/data/enemyDesert.csv.d.ts new file mode 100644 index 0000000..fc5c5ab --- /dev/null +++ b/src/samples/slay-the-spire-like/data/enemyDesert.csv.d.ts @@ -0,0 +1,16 @@ +import type { EffectDesert } from './effectDesert.csv'; + +type EnemyDesertTable = readonly { + readonly enemy: string; + readonly intentId: string; + readonly initialIntent: boolean; + readonly nextIntents: readonly string[]; + readonly brokenIntent: readonly string[]; + readonly initBuffs: readonly [EffectDesert, readonly stacks: number]; + readonly effects: readonly ["self" | "player" | "team", EffectDesert, number]; +}[]; + +export type EnemyDesert = EnemyDesertTable[number]; + +declare function getData(): EnemyDesertTable; +export default getData; diff --git a/src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts b/src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts new file mode 100644 index 0000000..daabda1 --- /dev/null +++ b/src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts @@ -0,0 +1,14 @@ +import type { CardDesert } from './cardDesert.csv'; + +type HeroItemFighter1Table = readonly { + readonly type: string; + readonly name: string; + readonly shape: string; + readonly card: CardDesert; + readonly price: number; +}[]; + +export type HeroItemFighter1 = HeroItemFighter1Table[number]; + +declare function getData(): HeroItemFighter1Table; +export default getData; diff --git a/src/samples/slay-the-spire-like/index.ts b/src/samples/slay-the-spire-like/index.ts index 60df7b1..008db88 100644 --- a/src/samples/slay-the-spire-like/index.ts +++ b/src/samples/slay-the-spire-like/index.ts @@ -1,7 +1,7 @@ // Data -export { heroItemFighter1Data, encounterDesertData } from './data'; +export { heroItemFighter1Data, encounterDesertData, enemyDesertData, effectDesertData, cardDesertData } from './data'; export { default as encounterDesertCsv } from './data/encounterDesert.csv'; -export type { EncounterDesert } from './data/encounterDesert.csv'; +export type { EncounterDesert, CardDesert } from './data'; // Deck export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './deck'; diff --git a/tsup.samples.config.ts b/tsup.samples.config.ts index 0e50c87..c967b12 100644 --- a/tsup.samples.config.ts +++ b/tsup.samples.config.ts @@ -66,5 +66,5 @@ export default defineConfig({ sourcemap: true, outDir: 'dist/samples', external: ['@preact/signals-core', 'mutative', 'inline-schema', 'boardgame-core'], - esbuildPlugins: [csvLoader(), rewriteBoardgameImports(), yarnSpinnerPlugin()], + esbuildPlugins: [csvLoader({ writeToDisk: true }), rewriteBoardgameImports(), yarnSpinnerPlugin()], });