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; }