diff --git a/src/samples/slay-the-spire-like/index.ts b/src/samples/slay-the-spire-like/index.ts index 836d083..363fd80 100644 --- a/src/samples/slay-the-spire-like/index.ts +++ b/src/samples/slay-the-spire-like/index.ts @@ -38,6 +38,8 @@ export type { InventoryItem, MutationResult, PlacementResult, + GameItem, + GameItemMeta, } from "./system/grid-inventory"; export { createGridInventory, @@ -69,43 +71,8 @@ export { } from "./system/map"; // Progress / Run -export type { - EncounterResult, - EncounterState, - GameItem, - GameItemMeta, - PlayerState, - RunMutationResult, - RunState, -} from "./system/progress"; -export { - assignEncounterToNode, - assignEncountersFromPool, - assignAllEncounters, - buildCombatState, - createEnemyEntities, - getCurrentEncounterData, - isCombatEncounter, - startEncounter, - resolveCombatEncounter, - createRunState, - canMoveTo, - moveToNode, - resolveEncounter, - isEncounterResolved, - damagePlayer, - healPlayer, - setMaxHp, - addGold, - spendGold, - addItem, - removeItem, - getCurrentNode, - getReachableChildren, - getUnresolvedChildren, - isAtStartNode, - isAtEndNode, -} from "./system/progress"; +export type { EncounterState, RunState } from "./system/encounter"; +export { buildCombatState } from "./system/encounter"; // Combat export type { 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 7a0e3f4..d804a01 100644 --- a/src/samples/slay-the-spire-like/system/combat/triggers.ts +++ b/src/samples/slay-the-spire-like/system/combat/triggers.ts @@ -16,8 +16,6 @@ import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/pr import { moveToRegion, shuffle } from "@/core/region"; import { createMiddlewareChain } from "@/utils/middleware"; import { EffectData } from "@/samples/slay-the-spire-like/system/types"; -import { getAdjacentItems } from "@/samples/slay-the-spire-like/system/grid-inventory"; -import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress"; type TriggerTypes = { onCombatStart: {}; diff --git a/src/samples/slay-the-spire-like/system/deck/factory.ts b/src/samples/slay-the-spire-like/system/deck/factory.ts index 6129e79..c4022af 100644 --- a/src/samples/slay-the-spire-like/system/deck/factory.ts +++ b/src/samples/slay-the-spire-like/system/deck/factory.ts @@ -1,66 +1,71 @@ -import {moveToRegion } from '@/core/region'; -import { createRegion } from '@/core/region'; -import type { GridInventory } from '../grid-inventory/types'; -import type { GameItemMeta } from '../progress/types'; -import type { CardData } from '../types'; -import type {DeckRegions, GameCard, PlayerDeck} from './types'; +import { moveToRegion } from "@/core/region"; +import { createRegion } from "@/core/region"; +import type { GameItemMeta, GridInventory } from "../grid-inventory/types"; +import type { CardData } from "../types"; +import type { DeckRegions, GameCard, PlayerDeck } from "./types"; function generateCardId(itemId: string, cellIndex: number): string { - return `card-${itemId}-${cellIndex}`; + return `card-${itemId}-${cellIndex}`; } -function createCard( itemId: string, cardData: CardData, cellIndex: number): GameCard { - return { - id: generateCardId(itemId, cellIndex), - regionId: '', - position: [], - itemId, - cardData - }; +function createCard( + itemId: string, + cardData: CardData, + cellIndex: number, +): GameCard { + return { + id: generateCardId(itemId, cellIndex), + regionId: "", + position: [], + itemId, + cardData, + }; } function createDeckRegions(): DeckRegions { - return { - drawPile: createRegion('drawPile', []), - hand: createRegion('hand', []), - discardPile: createRegion('discardPile', []), - exhaustPile: createRegion('exhaustPile', []), - }; + return { + drawPile: createRegion("drawPile", []), + hand: createRegion("hand", []), + discardPile: createRegion("discardPile", []), + exhaustPile: createRegion("exhaustPile", []), + }; } -function generateDeckFromInventory(inventory: GridInventory): PlayerDeck { - const cards: Record = {}; - const regions = createDeckRegions(); +function generateDeckFromInventory( + inventory: GridInventory, +): PlayerDeck { + const cards: Record = {}; + const regions = createDeckRegions(); - for (const item of inventory.items.values()) { - const itemData = item.meta?.itemData; - if (!itemData) continue; + for (const item of inventory.items.values()) { + const itemData = item.meta?.itemData; + if (!itemData) continue; - const count = item.shape.count; - for (let i = 0; i < count; i++) { - const card = createCard(item.id, itemData.card, i); - cards[card.id] = card; - moveToRegion(card, null, regions.drawPile); - } + const count = item.shape.count; + for (let i = 0; i < count; i++) { + const card = createCard(item.id, itemData.card, i); + cards[card.id] = card; + moveToRegion(card, null, regions.drawPile); } + } - return { - cards, - regions - }; + return { + cards, + regions, + }; } function createPlayerDeck(): PlayerDeck { - return { - cards: {}, - regions: createDeckRegions(), - }; + return { + cards: {}, + regions: createDeckRegions(), + }; } export { - generateDeckFromInventory, - createCard, - createPlayerDeck, - createDeckRegions, - generateCardId, + generateDeckFromInventory, + createCard, + createPlayerDeck, + createDeckRegions, + generateCardId, }; diff --git a/src/samples/slay-the-spire-like/system/encounter/combat.ts b/src/samples/slay-the-spire-like/system/encounter/combat.ts new file mode 100644 index 0000000..bf65206 --- /dev/null +++ b/src/samples/slay-the-spire-like/system/encounter/combat.ts @@ -0,0 +1,107 @@ +import { + CombatState, + EffectTable, + EnemyEntity, + PlayerEntity, +} from "../combat/types"; +import { generateDeckFromInventory } from "../deck"; +import { GridInventory } from "../grid-inventory"; +import { GameItemMeta } from "../grid-inventory/types"; +import { EffectData, EncounterData, EnemyData, IntentData } from "../types"; +import { CombatEncounterState, RunState } from "./types"; + +export function buildCombatState( + runState: RunState, + inventory: GridInventory, + encounter: CombatEncounterState, +): CombatState { + const deck = generateDeckFromInventory(inventory); + const player = createPlayerEntity(runState, deck); + const enemies = createEnemyEntities(encounter.data); + + return { + enemies, + player, + phase: "playerTurn", + turnNumber: 1, + result: null, + loot: [], + }; +} + +function createEnemyEntities(encounter: EncounterData): EnemyEntity[] { + const enemies: EnemyEntity[] = []; + let instanceCounter = 0; + + for (const [enemyData, hp, encounterBuffs] of encounter.enemies) { + const instanceId = `${enemyData.id}-${instanceCounter++}`; + const intents = buildIntentMap(enemyData); + const initialIntent = findInitialIntent(enemyData); + const effects = buildEffectTable(encounterBuffs); + + const entity: EnemyEntity = { + id: instanceId, + enemy: enemyData, + hp, + maxHp: hp, + isAlive: true, + effects, + intents, + currentIntent: initialIntent, + }; + enemies.push(entity); + } + + return enemies; +} + +/** + * Builds a map of intent ID -> IntentData for an enemy. + */ +function buildIntentMap(enemy: EnemyData): Record { + const intents: Record = {}; + for (const intent of enemy.intents) { + intents[intent.id] = intent; + } + return intents; +} + +/** + * Finds the initial intent ID for an enemy. + */ +function findInitialIntent(enemy: EnemyData): IntentData { + for (const intent of enemy.intents) { + if (intent.initialIntent) { + return intent; + } + } + if (enemy.intents.length === 0) { + throw new Error(`Enemy "${enemy.id}" has no intents`); + } + return enemy.intents[0]; +} + +function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable { + const table: EffectTable = {}; + for (const [effect, stacks] of buffs) { + table[effect.id] = { data: effect, stacks }; + } + return table; +} + +function createPlayerEntity( + runState: RunState, + deck: ReturnType, +): PlayerEntity { + return { + id: "player", + hp: runState.currentHp, + maxHp: runState.maxHp, + isAlive: runState.currentHp > 0, + energy: 3, + maxEnergy: 3, + deck, + itemEffects: {}, + effects: {}, + }; +} diff --git a/src/samples/slay-the-spire-like/system/encounter/index.ts b/src/samples/slay-the-spire-like/system/encounter/index.ts new file mode 100644 index 0000000..23bf6aa --- /dev/null +++ b/src/samples/slay-the-spire-like/system/encounter/index.ts @@ -0,0 +1,3 @@ +export { RunState, EncounterState } from "./types"; +export { buildCombatState } from "./combat"; +export { generateInstanceId } from "./shop"; diff --git a/src/samples/slay-the-spire-like/system/encounter/run.ts b/src/samples/slay-the-spire-like/system/encounter/run.ts new file mode 100644 index 0000000..809f405 --- /dev/null +++ b/src/samples/slay-the-spire-like/system/encounter/run.ts @@ -0,0 +1,52 @@ +import { RunState } from "./types"; + +const DEFAULT_MAX_HP = 50; +const DEFAULT_GOLD = 50; + +export function createRunState(startNode: string): RunState { + return { + maxHp: DEFAULT_MAX_HP, + currentHp: DEFAULT_MAX_HP, + gold: DEFAULT_GOLD, + _idCounter: { value: 0 }, + }; +} + +export function damagePlayer(runState: RunState, amount: number): void { + runState.currentHp = Math.max(0, runState.currentHp - amount); +} + +export function healPlayer(runState: RunState, amount: number): void { + runState.currentHp = Math.min(runState.maxHp, runState.currentHp + amount); +} + +export function setMaxHp(runState: RunState, newMax: number): void { + const diff = Math.max(0, newMax - runState.maxHp); + runState.maxHp = newMax; + runState.currentHp = Math.max(0, Math.min(newMax, runState.currentHp + diff)); +} + +/** + * Adds gold to the player. + */ +export function addGold(runState: RunState, amount: number): void { + runState.gold += amount; +} + +/** + * Spends gold. Returns false if the player doesn't have enough gold. + */ +export function spendGold( + runState: RunState, + amount: number, +): { success: true } | { success: false; reason: string } { + if (amount <= 0) { + return { success: false, reason: "金额必须大于零" }; + } + if (runState.gold < amount) { + return { success: false, reason: "金币不足" }; + } + + runState.gold -= amount; + return { success: true }; +} diff --git a/src/samples/slay-the-spire-like/system/encounter/shop.ts b/src/samples/slay-the-spire-like/system/encounter/shop.ts new file mode 100644 index 0000000..20fd882 --- /dev/null +++ b/src/samples/slay-the-spire-like/system/encounter/shop.ts @@ -0,0 +1,4 @@ +export function generateInstanceId(counter: { value: number }): string { + counter.value++; + return `item-${counter.value}`; +} diff --git a/src/samples/slay-the-spire-like/system/encounter/types.ts b/src/samples/slay-the-spire-like/system/encounter/types.ts new file mode 100644 index 0000000..002c323 --- /dev/null +++ b/src/samples/slay-the-spire-like/system/encounter/types.ts @@ -0,0 +1,42 @@ +import { GridInventory } from "../grid-inventory"; +import { GameItemMeta } from "../grid-inventory/types"; +import { EncounterData } from "../types"; + +export interface RunState { + _idCounter: { value: number }; + + currentHp: number; + maxHp: number; + gold: number; +} + +export type EncounterState = + | CombatEncounterState + | ShopEncounterState + | CurioEncounterState + | CampEncounterState + | DialogueEncounterState; + +export type CombatEncounterState = { + data: EncounterData<"minion" | "elite">; + blocked: boolean; +}; + +export type ShopEncounterState = { + data: EncounterData<"shop">; + items: GridInventory; +}; + +export type CurioEncounterState = { + data: EncounterData<"curio">; + items: GridInventory; +}; + +export type CampEncounterState = { + data: EncounterData<"camp">; +}; + +export type DialogueEncounterState = { + data: EncounterData<"event">; + blocked: boolean; +}; diff --git a/src/samples/slay-the-spire-like/system/grid-inventory/index.ts b/src/samples/slay-the-spire-like/system/grid-inventory/index.ts index d926b78..465ac33 100644 --- a/src/samples/slay-the-spire-like/system/grid-inventory/index.ts +++ b/src/samples/slay-the-spire-like/system/grid-inventory/index.ts @@ -1,13 +1,22 @@ -export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types'; +export type { + CellCoordinate, + CellKey, + GridInventory, + InventoryItem, + MutationResult, + PlacementResult, +} from "./types"; export { - createGridInventory, - flipItem, - getAdjacentItems, - getItemAtCell, - getOccupiedCellSet, - moveItem, - placeItem, - removeItem, - rotateItem, - validatePlacement, -} from './transform'; + createGridInventory, + flipItem, + getAdjacentItems, + getItemAtCell, + getOccupiedCellSet, + moveItem, + placeItem, + removeItem, + rotateItem, + validatePlacement, +} from "./transform"; + +export type { GameItemMeta, GameItem } from "./types"; diff --git a/src/samples/slay-the-spire-like/system/grid-inventory/types.ts b/src/samples/slay-the-spire-like/system/grid-inventory/types.ts index 645388e..9bed284 100644 --- a/src/samples/slay-the-spire-like/system/grid-inventory/types.ts +++ b/src/samples/slay-the-spire-like/system/grid-inventory/types.ts @@ -1,5 +1,6 @@ -import type { ParsedShape } from '../utils/parse-shape'; -import type { Transform2D } from '../utils/shape-collision'; +import { ItemData } from "../types"; +import type { ParsedShape } from "../utils/parse-shape"; +import type { Transform2D } from "../utils/shape-collision"; /** * String key representing a grid cell in "x,y" format. @@ -10,8 +11,8 @@ export type CellKey = `${number},${number}`; * Simple 2D coordinate for grid cells. */ export interface CellCoordinate { - x: number; - y: number; + x: number; + y: number; } /** @@ -19,25 +20,29 @@ export interface CellCoordinate { * @template TMeta - Optional metadata type for game-specific data */ export interface InventoryItem { - /** Unique item identifier */ - id: string; - /** Reference to the item's shape definition */ - shape: ParsedShape; - /** Current transformation (position, rotation, flips) */ - transform: Transform2D; - /** Optional metadata for game-specific data */ - meta?: TMeta; + /** Unique item identifier */ + id: string; + /** Reference to the item's shape definition */ + shape: ParsedShape; + /** Current transformation (position, rotation, flips) */ + transform: Transform2D; + /** Optional metadata for game-specific data */ + meta?: TMeta; } /** * Result of a placement validation check. */ -export type PlacementResult = { valid: true } | { valid: false; reason: string }; +export type PlacementResult = + | { valid: true } + | { valid: false; reason: string }; /** * Result of a mutation operation (move, rotate, flip). */ -export type MutationResult = { success: true } | { success: false; reason: string }; +export type MutationResult = + | { success: true } + | { success: false; reason: string }; /** * Grid inventory state. @@ -45,12 +50,21 @@ export type MutationResult = { success: true } | { success: false; reason: strin * @template TMeta - Optional metadata type for items */ export interface GridInventory { - /** Board width in cells */ - width: number; - /** Board height in cells */ - height: number; - /** Map of itemId -> InventoryItem for all placed items */ - items: Map>; - /** Set of occupied cells in "x,y" format for O(1) collision lookups */ - occupiedCells: Set; + /** Board width in cells */ + width: number; + /** Board height in cells */ + height: number; + /** Map of itemId -> InventoryItem for all placed items */ + items: Map>; + /** Set of occupied cells in "x,y" format for O(1) collision lookups */ + occupiedCells: Set; } + +export interface GameItemMeta { + itemData: ItemData; + shape: ParsedShape; + consumedUses?: number; + startEffects?: Record; + tradePrice?: number; +} +export type GameItem = InventoryItem; diff --git a/src/samples/slay-the-spire-like/system/map/index.ts b/src/samples/slay-the-spire-like/system/map/index.ts index d821522..55e5470 100644 --- a/src/samples/slay-the-spire-like/system/map/index.ts +++ b/src/samples/slay-the-spire-like/system/map/index.ts @@ -15,4 +15,10 @@ export { findAllPaths, } from "./generator"; -export { canMoveTo, moveToNode } from "./navigation"; +export { + canMoveTo, + moveToNode, + getReachableChildren, + isAtEndNode, + isAtStartNode, +} from "./navigation"; diff --git a/src/samples/slay-the-spire-like/system/map/navigation.ts b/src/samples/slay-the-spire-like/system/map/navigation.ts index e6a9569..f92aa16 100644 --- a/src/samples/slay-the-spire-like/system/map/navigation.ts +++ b/src/samples/slay-the-spire-like/system/map/navigation.ts @@ -31,3 +31,35 @@ export function moveToNode( navigator.visitedNodes.add(targetNodeId); return true; } + +export function* getReachableChildren( + map: PointCrawlMap, + navigator: PointCrawlMapNavigator, +) { + const currentNode = getNode(map, navigator.currentNodeId); + if (!currentNode) return; + + for (const id of currentNode.childIds) { + const node = getNode(map, id); + if (!node) continue; + yield node; + } +} + +export function isAtStartNode( + map: PointCrawlMap, + navigator: PointCrawlMapNavigator, +): boolean { + return navigator.currentNodeId === map.layers[0]?.nodes[0]?.id; +} + +/** + * Checks if the current encounter is the end node. + */ +export function isAtEndNode( + map: PointCrawlMap, + navigator: PointCrawlMapNavigator, +): boolean { + const endLayer = map.layers[map.layers.length - 1]; + return endLayer?.nodes[0]?.id === navigator.currentNodeId; +} diff --git a/src/samples/slay-the-spire-like/system/progress/encounter.ts b/src/samples/slay-the-spire-like/system/progress/encounter.ts deleted file mode 100644 index 81f48cb..0000000 --- a/src/samples/slay-the-spire-like/system/progress/encounter.ts +++ /dev/null @@ -1,256 +0,0 @@ -import type { PointCrawlMap } from "../map/types"; -import type { - CombatState, - EnemyEntity, - PlayerEntity, - EffectTable, -} from "../combat/types"; -import type { - EncounterData, - EnemyData, - EffectData, - IntentData, -} from "../types"; -import type { RunState } from "./types"; -import { generateDeckFromInventory } from "../deck/factory"; -import { ReadonlyRNG } from "@/utils/rng"; - -// -- Encounter assignment to nodes -- - -/** - * Assigns an encounter from a pool to a specific node. - * Replaces any existing encounter on that node. - */ -export function assignEncounterToNode( - map: PointCrawlMap, - nodeId: string, - encounter: EncounterData, -): void { - const node = map.nodes.get(nodeId); - if (!node) { - throw new Error(`Node "${nodeId}" not found`); - } - node.encounter = encounter; -} - -/** - * Assigns encounters from a typed pool to all unassigned nodes of matching type. - * Uses RNG for random selection; each encounter can be assigned multiple times. - */ -export function assignEncountersFromPool( - map: PointCrawlMap, - encounterPool: EncounterData[], - rng: ReadonlyRNG, -): void { - if (encounterPool.length === 0) return; - - for (const node of map.nodes.values()) { - if (node.type === "start" || node.type === "end") continue; - if (node.encounter) continue; - - const assigned = encounterPool[rng.nextInt(encounterPool.length)]; - node.encounter = assigned; - } -} - -/** - * Batch-assigns encounters for all node types from a typed index. - * Keys in the index should match encounter type strings (e.g. 'minion', 'elite'). - */ -export function assignAllEncounters( - map: PointCrawlMap, - encounterIndex: Map, - rng: ReadonlyRNG, -): void { - for (const node of map.nodes.values()) { - if (node.type === "start" || node.type === "end") continue; - if (node.encounter) continue; - - const encounterType = node.type; - const pool = encounterIndex.get(encounterType); - if (!pool || pool.length === 0) continue; - - node.encounter = pool[rng.nextInt(pool.length)]; - } -} - -// -- CombatState construction -- - -/** - * Builds a full CombatState from an encounter and the current run state. - * - Creates EnemyEntity instances with HP, initial buffs, and intents - * - Creates PlayerEntity with energy (3), deck from inventory, and HP from run state - * - Sets initial phase to 'playerTurn', turn 1 - */ -export function buildCombatState(runState: RunState): CombatState { - const encounter = getCurrentEncounterData(runState); - if (!encounter) - throw new Error(`No encounter found for node ${runState.currentNodeId}`); - - const enemies = createEnemyEntities(encounter); - const deck = generateDeckFromInventory(runState.inventory); - const player = createPlayerEntity(runState, deck); - - return { - enemies, - player, - phase: "playerTurn", - turnNumber: 1, - result: null, - loot: [], - }; -} - -/** - * Creates EnemyEntity instances from encounter enemy definitions. - * Each enemy gets: HP from encounter tuple, initial buffs from encounter, intents from enemy definition. - */ -export function createEnemyEntities(encounter: EncounterData): EnemyEntity[] { - const enemies: EnemyEntity[] = []; - let instanceCounter = 0; - - for (const [enemyData, hp, encounterBuffs] of encounter.enemies) { - const instanceId = `${enemyData.id}-${instanceCounter++}`; - const intents = buildIntentMap(enemyData); - const initialIntent = findInitialIntent(enemyData); - const effects = buildEffectTable(encounterBuffs); - - const entity: EnemyEntity = { - id: instanceId, - enemy: enemyData, - hp, - maxHp: hp, - isAlive: true, - effects, - intents, - currentIntent: initialIntent, - }; - enemies.push(entity); - } - - return enemies; -} - -/** - * Builds a map of intent ID -> IntentData for an enemy. - */ -function buildIntentMap(enemy: EnemyData): Record { - const intents: Record = {}; - for (const intent of enemy.intents) { - intents[intent.id] = intent; - } - return intents; -} - -/** - * Finds the initial intent ID for an enemy. - */ -function findInitialIntent(enemy: EnemyData): IntentData { - for (const intent of enemy.intents) { - if (intent.initialIntent) { - return intent; - } - } - if (enemy.intents.length === 0) { - throw new Error(`Enemy "${enemy.id}" has no intents`); - } - return enemy.intents[0]; -} - -/** - * Builds an EffectTable from encounter buff definitions. - */ -function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable { - const table: EffectTable = {}; - for (const [effect, stacks] of buffs) { - table[effect.id] = { data: effect, stacks }; - } - return table; -} - -/** - * Creates a PlayerEntity from the run state and deck. - */ -function createPlayerEntity( - runState: RunState, - deck: ReturnType, -): PlayerEntity { - return { - id: "player", - hp: runState.player.currentHp, - maxHp: runState.player.maxHp, - isAlive: runState.player.currentHp > 0, - energy: 3, - maxEnergy: 3, - deck, - itemEffects: {}, - effects: {}, - }; -} - -// -- Encounter lifecycle -- - -/** - * Gets the encounter data for the current node. - */ -export function getCurrentEncounterData( - runState: RunState, -): EncounterData | undefined { - const node = runState.map.nodes.get(runState.currentNodeId); - return node?.encounter; -} - -/** - * Checks if the current node has a combat encounter. - */ -export function isCombatEncounter(runState: RunState): boolean { - const encounter = getCurrentEncounterData(runState); - return encounter !== undefined && encounter.enemies.length > 0; -} - -/** - * Starts the encounter at the current node. - * Returns the constructed CombatState, or null if no combat encounter. - */ -export function startEncounter(runState: RunState): CombatState | null { - const encounter = getCurrentEncounterData(runState); - if (!encounter || encounter.enemies.length === 0) { - return null; - } - - return buildCombatState(runState); -} - -/** - * Resolves a completed combat and applies rewards to the run state. - * Handles: gold loot, item rewards, HP changes. - * Marks the encounter as resolved. - */ -export function resolveCombatEncounter( - runState: RunState, - combatState: CombatState, -): { success: true } | { success: false; reason: string } { - if (runState.currentEncounter.resolved) { - return { success: false, reason: "该遭遇已解决" }; - } - - // Apply HP from combat state back to run state - runState.player.currentHp = Math.max(0, combatState.player.hp); - - // Apply loot - for (const loot of combatState.loot) { - if (loot.type === "gold") { - runState.player.gold += loot.amount; - } - // Item rewards are handled by the caller via addItem() - } - - // Mark as resolved - runState.currentEncounter.resolved = true; - runState.currentEncounter.result = { - hpLost: runState.player.maxHp - runState.player.currentHp, - }; - runState.resolvedNodeIds.add(runState.currentNodeId); - - return { success: true }; -} diff --git a/src/samples/slay-the-spire-like/system/progress/index.ts b/src/samples/slay-the-spire-like/system/progress/index.ts deleted file mode 100644 index 4d72106..0000000 --- a/src/samples/slay-the-spire-like/system/progress/index.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { getNode } from '../map/generator'; -import type {MapNode, PointCrawlMap} from '../map/types'; -import { placeItem, validatePlacement, createGridInventory, removeItem as gridRemoveItem } from '../grid-inventory/transform'; -import type { GameItem, GameItemMeta, RunMutationResult, RunState } from './types'; -import type { GridInventory } from '../grid-inventory/types'; -import { IDENTITY_TRANSFORM, type Transform2D } from '../utils/shape-collision'; -import { parseShapeString, type ParsedShape } from '../utils/parse-shape'; -import {ItemData} from "@/samples/slay-the-spire-like/system/types"; - -// Re-export types -export type { - EncounterResult, - EncounterState, - GameItem, - GameItemMeta, - PlayerState, - RunMutationResult, - RunState, -} from './types'; - -// Re-export encounter construction functions -export { - assignEncounterToNode, - assignEncountersFromPool, - assignAllEncounters, - buildCombatState, - createEnemyEntities, - getCurrentEncounterData, - isCombatEncounter, - startEncounter, - resolveCombatEncounter, -} from './encounter'; - -// -- Constants -- - -const INVENTORY_WIDTH = 6; -const INVENTORY_HEIGHT = 4; - -const DEFAULT_MAX_HP = 50; -const DEFAULT_GOLD = 50; - -// -- Run creation -- - -/** - * Creates a new run state with a generated map, player stats, and starter inventory. - * - */ -export function createRunState(map: PointCrawlMap, starterItems: ItemData[]): RunState { - // Find the start node - const startNode = map.layers[0].nodes[0]; - - // Create empty inventory - const inventory = createGridInventory(INVENTORY_WIDTH, INVENTORY_HEIGHT); - const idCounter = { value: 0 }; - - // Place starter items - for (const itemData of starterItems) { - const shape = parseShapeString(itemData.shape); - const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter); - if (!itemInstance) { - // Inventory too small; item skipped - } - } - - return { - map, - player: { - maxHp: DEFAULT_MAX_HP, - currentHp: DEFAULT_MAX_HP, - gold: DEFAULT_GOLD, - }, - inventory, - currentNodeId: startNode.id, - currentEncounter: { - nodeId: startNode.id, - resolved: false, - }, - resolvedNodeIds: new Set(), - _idCounter: idCounter, - }; -} - -// -- Movement -- - -/** - * Checks whether the player can move to the target node. - * The target must be a child of the current node. - */ -export function canMoveTo(runState: RunState, targetNodeId: string): boolean { - const currentNode = getNode(runState.map, runState.currentNodeId); - if (!currentNode) return false; - - return currentNode.childIds.includes(targetNodeId); -} - -/** - * Moves the player to the target node. - * The target must be a direct child of the current node. - * - * @returns `{ success: true }` on success, or `{ success: false, reason: string }` on failure. - */ -export function moveToNode(runState: RunState, targetNodeId: string): RunMutationResult { - if (!canMoveTo(runState, targetNodeId)) { - return { success: false, reason: '无法移动到该节点' }; - } - - const targetNode = getNode(runState.map, targetNodeId); - if (!targetNode) { - return { success: false, reason: '目标节点不存在' }; - } - - // Update current position - runState.currentNodeId = targetNodeId; - - // Create new encounter state for the target node - const isResolved = runState.resolvedNodeIds.has(targetNodeId); - runState.currentEncounter = { - nodeId: targetNodeId, - resolved: isResolved, - }; - - return { success: true }; -} - -// -- Encounter management -- - -/** - * Marks the current encounter as resolved with optional result data. - */ -export function resolveEncounter(runState: RunState, result?: { goldEarned?: number; hpLost?: number; hpGained?: number; itemRewards?: string[] }): RunMutationResult { - if (runState.currentEncounter.resolved) { - return { success: false, reason: '该遭遇已解决' }; - } - - runState.currentEncounter.resolved = true; - runState.currentEncounter.result = result; - runState.resolvedNodeIds.add(runState.currentNodeId); - - // Apply result effects - if (result) { - if (result.goldEarned) { - runState.player.gold += result.goldEarned; - } - if (result.hpLost) { - runState.player.currentHp = Math.max(0, runState.player.currentHp - result.hpLost); - } - if (result.hpGained) { - runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + result.hpGained); - } - } - - return { success: true }; -} - -/** - * Checks whether the encounter at the given node has been resolved. - */ -export function isEncounterResolved(runState: RunState, nodeId: string): boolean { - return runState.resolvedNodeIds.has(nodeId); -} - -// -- Player stats -- - -/** - * Damages the player. Clamps HP to 0 minimum. - */ -export function damagePlayer(runState: RunState, amount: number): void { - runState.player.currentHp = Math.max(0, runState.player.currentHp - amount); -} - -/** - * Heals the player. Clamps HP to maxHp. - */ -export function healPlayer(runState: RunState, amount: number): void { - runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + amount); -} - -/** - * Sets the player's maximum HP and adjusts current HP proportionally. - */ -export function setMaxHp(runState: RunState, newMax: number): void { - const diff = newMax - runState.player.maxHp; - runState.player.maxHp = newMax; - runState.player.currentHp = Math.max(0, Math.min(newMax, runState.player.currentHp + diff)); -} - -/** - * Adds gold to the player. - */ -export function addGold(runState: RunState, amount: number): void { - runState.player.gold += amount; -} - -/** - * Spends gold. Returns false if the player doesn't have enough gold. - */ -export function spendGold(runState: RunState, amount: number): RunMutationResult { - if (amount <= 0) { - return { success: false, reason: '金额必须大于零' }; - } - if (runState.player.gold < amount) { - return { success: false, reason: '金币不足' }; - } - - runState.player.gold -= amount; - return { success: true }; -} - -// -- Inventory management -- - -/** - * Adds an item from CSV data to the inventory. - * Finds the first available valid position. - * - * @returns The placed item instance, or undefined if no valid position exists. - */ -export function addItem( - runState: RunState, - itemData: ItemData -): GameItem | undefined { - const shape = parseShapeString(itemData.shape); - return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter); -} - -/** - * Removes an item from the inventory by its instance ID. - */ -export function removeItem(runState: RunState, instanceId: string): void { - gridRemoveItem(runState.inventory, instanceId); -} - -// -- Query helpers -- - -/** - * Returns the node the player is currently at. - */ -export function getCurrentNode(runState: RunState): MapNode | undefined { - return getNode(runState.map, runState.currentNodeId); -} - -/** - * Returns all reachable child nodes from the current position. - */ -export function getReachableChildren(runState: RunState): MapNode[] { - const currentNode = getNode(runState.map, runState.currentNodeId); - if (!currentNode) return []; - - return currentNode.childIds - .map(id => getNode(runState.map, id)) - .filter((n): n is MapNode => n !== undefined); -} - -/** - * Returns available children that haven't been resolved yet. - */ -export function getUnresolvedChildren(runState: RunState): MapNode[] { - return getReachableChildren(runState).filter( - node => !runState.resolvedNodeIds.has(node.id) - ); -} - -/** - * Checks if the current encounter is the start node (no encounter). - */ -export function isAtStartNode(runState: RunState): boolean { - return runState.currentNodeId === runState.map.layers[0]?.nodes[0]?.id; -} - -/** - * Checks if the current encounter is the end node. - */ -export function isAtEndNode(runState: RunState): boolean { - const endLayer = runState.map.layers[runState.map.layers.length - 1]; - return endLayer?.nodes[0]?.id === runState.currentNodeId; -} - -// -- Internal helpers -- - -/** - * Generates a unique item instance ID. - */ -function generateInstanceId(counter: { value: number }): string { - counter.value++; - return `item-${counter.value}`; -} - -/** - * Tries to place an item in the inventory by scanning all possible positions. - * Returns the placed item instance, or undefined if no valid position exists. - */ -function tryPlaceItemInInventory( - inventory: GridInventory, - itemData: ItemData, - shape: ParsedShape, - idCounter: { value: number } -): GameItem | undefined { - const instanceId = generateInstanceId(idCounter); - - // Try to find a valid position - for (let y = 0; y <= inventory.height - shape.height; y++) { - for (let x = 0; x <= inventory.width - shape.width; x++) { - const transform = { - ...IDENTITY_TRANSFORM, - offset: { x, y }, - }; - - const validation = validatePlacement(inventory, shape, transform); - if (validation.valid) { - const item: GameItem = { - id: instanceId, - shape, - transform, - meta: { - itemData, - shape, - }, - }; - - placeItem(inventory, item); - return item; - } - } - } - - return undefined; -} diff --git a/src/samples/slay-the-spire-like/system/progress/types.ts b/src/samples/slay-the-spire-like/system/progress/types.ts deleted file mode 100644 index 66ac845..0000000 --- a/src/samples/slay-the-spire-like/system/progress/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { PointCrawlMap } from "../map/types"; -import type { GridInventory, InventoryItem } from "../grid-inventory/types"; -import type { ParsedShape } from "../utils/parse-shape"; -import { ItemData } from "@/samples/slay-the-spire-like/system/types"; - -/** - * Result of an encounter (combat, event, etc.). - */ -export interface EncounterResult { - /** Gold earned from the encounter */ - goldEarned?: number; - /** HP lost during the encounter */ - hpLost?: number; - /** HP gained (e.g., from camp heal) */ - hpGained?: number; - /** Item IDs rewarded */ - itemRewards?: string[]; -} - -/** - * Runtime state of an encounter at a specific node. - */ -export interface EncounterState { - /** The node ID where this encounter is located */ - nodeId: string; - /** Whether the encounter has been resolved */ - resolved: boolean; - /** Optional result data after resolution */ - result?: EncounterResult; -} - -/** - * Metadata attached to each inventory item instance. - * Bridges CSV item data with the grid inventory system. - */ -export interface GameItemMeta { - /** Original CSV item data */ - itemData: ItemData; - /** Parsed shape for grid placement */ - shape: ParsedShape; - /** Consumed uses, if card cost type is uses**/ - consumedUses?: number; - /** Effects applied to the item */ - effects?: Record; -} - -/** - * An item instance in the game inventory. - * Extends InventoryItem with game-specific metadata. - */ -export type GameItem = InventoryItem; - -/** - * Player runtime state. - */ -export interface PlayerState { - /** Maximum HP */ - maxHp: number; - /** Current HP */ - currentHp: number; - /** Current gold */ - gold: number; -} - -/** - * Full run state for a game session. - * Designed to be used inside `MutableSignal.produce()` callbacks. - */ -export interface RunState { - /** Generated point crawl map */ - map: PointCrawlMap; - /** Player HP and gold */ - player: PlayerState; - /** Grid inventory with placed items */ - inventory: GridInventory; - /** Current node ID where the player is located */ - currentNodeId: string; - /** State of the encounter at the current node */ - currentEncounter: EncounterState; - /** Set of node IDs whose encounters have been resolved */ - resolvedNodeIds: Set; - /** Internal counter for generating unique item instance IDs */ - _idCounter: { value: number }; -} - -/** - * Result of a mutation operation on the run state. - */ -export type RunMutationResult = - | { success: true } - | { success: false; reason: string }; diff --git a/src/samples/slay-the-spire-like/system/types.ts b/src/samples/slay-the-spire-like/system/types.ts index 80c1b57..6dd49cc 100644 --- a/src/samples/slay-the-spire-like/system/types.ts +++ b/src/samples/slay-the-spire-like/system/types.ts @@ -56,9 +56,9 @@ export type EncounterType = | "shop" | "camp" | "curio"; -export type EncounterData = { +export type EncounterData = { readonly id: string; - readonly type: EncounterType; + readonly type: T; readonly name: string; readonly description: string; readonly enemies: readonly [