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 index 35ee97a..b254585 100644 --- a/src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts +++ b/src/samples/slay-the-spire-like/data/heroItemFighter1.csv.d.ts @@ -5,6 +5,7 @@ type HeroItemFighter1Table = readonly { readonly costType: "energy" | "uses"; readonly costCount: number; readonly targetType: "single" | "none"; + readonly price: number; readonly desc: string; }[]; diff --git a/src/samples/slay-the-spire-like/index.ts b/src/samples/slay-the-spire-like/index.ts index a009c8e..b4079ee 100644 --- a/src/samples/slay-the-spire-like/index.ts +++ b/src/samples/slay-the-spire-like/index.ts @@ -4,7 +4,7 @@ export { default as encounterDesertCsv } from './data/encounterDesert.csv'; export type { EncounterDesert } from './data/encounterDesert.csv'; // Grid Inventory -export type { CellCoordinate, GridInventory, InventoryItem, PlacementResult } from './grid-inventory'; +export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './grid-inventory'; export { createGridInventory, flipItem, @@ -23,6 +23,36 @@ export { MapNodeType, MapLayerType } from './map'; export type { MapNode, MapLayer, PointCrawlMap } from './map'; export { generatePointCrawlMap, getNode, getChildren, getParents, hasPath, findAllPaths } from './map'; +// Progress Manager +export type { + EncounterResult, + EncounterState, + GameItem, + GameItemMeta, + PlayerState, + RunMutationResult, + RunState, +} from './progress'; +export { + addGold, + addItemFromCsv, + canMoveTo, + createRunState, + damagePlayer, + getReachableChildren, + getCurrentNode, + getUnresolvedChildren, + healPlayer, + isAtEndNode, + isAtStartNode, + isEncounterResolved, + moveToNode, + removeItem as removeItemFromRun, + resolveEncounter, + setMaxHp, + spendGold, +} from './progress'; + // Utils - Parse Shape export type { ParsedShape } from './utils/parse-shape'; export { parseShapeString } from './utils/parse-shape'; diff --git a/src/samples/slay-the-spire-like/progress/index.ts b/src/samples/slay-the-spire-like/progress/index.ts new file mode 100644 index 0000000..72f6a55 --- /dev/null +++ b/src/samples/slay-the-spire-like/progress/index.ts @@ -0,0 +1,334 @@ +import { Mulberry32RNG, type RNG } from '@/utils/rng'; +import { generatePointCrawlMap, getNode } from '../map/generator'; +import type { MapNode } 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 type { HeroItemFighter1 } from '../data/heroItemFighter1.csv'; +import { heroItemFighter1Data } from '../data'; + +// Re-export types +export type { + EncounterResult, + EncounterState, + GameItem, + GameItemMeta, + PlayerState, + RunMutationResult, + RunState, +} from './types'; + +// -- Constants -- + +const INVENTORY_WIDTH = 6; +const INVENTORY_HEIGHT = 4; + +const DEFAULT_MAX_HP = 50; +const DEFAULT_GOLD = 50; + +/** Starter items to give the player at the beginning of a run. */ +const STARTER_ITEM_NAMES = ['治疗药剂', '绷带', '水袋', '短刀', '剑']; + +// -- Run creation -- + +/** + * Creates a new run state with a generated map, player stats, and starter inventory. + * + * @param seed RNG seed for reproducibility. If omitted, uses current timestamp. + * @param rng Optional RNG instance for controlled randomness (overrides seed). + */ +export function createRunState(seed?: number, rng?: RNG): RunState { + const actualSeed = seed ?? new Mulberry32RNG().nextInt(2 ** 31); + const map = generatePointCrawlMap(actualSeed); + + // 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 itemName of STARTER_ITEM_NAMES) { + const itemData = findItemByName(itemName); + if (!itemData) continue; + + const shape = parseShapeString(itemData.shape); + const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter); + if (!itemInstance) { + // Inventory too small; item skipped + } + } + + return { + seed: actualSeed, + 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 addItemFromCsv( + runState: RunState, + itemData: HeroItemFighter1 +): 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 -- + +/** + * Finds a hero item by name from the CSV data. + */ +function findItemByName(name: string): HeroItemFighter1 | undefined { + return heroItemFighter1Data.find(item => item.name === name); +} + +/** + * 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: HeroItemFighter1, + 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/progress/types.ts b/src/samples/slay-the-spire-like/progress/types.ts new file mode 100644 index 0000000..2ece9dc --- /dev/null +++ b/src/samples/slay-the-spire-like/progress/types.ts @@ -0,0 +1,87 @@ +import type { PointCrawlMap } from '../map/types'; +import type { GridInventory, InventoryItem } from '../grid-inventory/types'; +import type { ParsedShape } from '../utils/parse-shape'; +import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv'; + +/** + * 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: HeroItemFighter1; + /** Parsed shape for grid placement */ + shape: ParsedShape; +} + +/** + * 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 { + /** RNG seed used for map generation */ + seed: number; + /** 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 };