335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
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<GameItemMeta>(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<string>(),
|
|
_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<GameItemMeta>,
|
|
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;
|
|
}
|