refactor: add progress
This commit is contained in:
parent
6b724df7e7
commit
204198b10f
|
|
@ -5,6 +5,7 @@ type HeroItemFighter1Table = readonly {
|
||||||
readonly costType: "energy" | "uses";
|
readonly costType: "energy" | "uses";
|
||||||
readonly costCount: number;
|
readonly costCount: number;
|
||||||
readonly targetType: "single" | "none";
|
readonly targetType: "single" | "none";
|
||||||
|
readonly price: number;
|
||||||
readonly desc: string;
|
readonly desc: string;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ export { default as encounterDesertCsv } from './data/encounterDesert.csv';
|
||||||
export type { EncounterDesert } from './data/encounterDesert.csv';
|
export type { EncounterDesert } from './data/encounterDesert.csv';
|
||||||
|
|
||||||
// Grid Inventory
|
// Grid Inventory
|
||||||
export type { CellCoordinate, GridInventory, InventoryItem, PlacementResult } from './grid-inventory';
|
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './grid-inventory';
|
||||||
export {
|
export {
|
||||||
createGridInventory,
|
createGridInventory,
|
||||||
flipItem,
|
flipItem,
|
||||||
|
|
@ -23,6 +23,36 @@ export { MapNodeType, MapLayerType } from './map';
|
||||||
export type { MapNode, MapLayer, PointCrawlMap } from './map';
|
export type { MapNode, MapLayer, PointCrawlMap } from './map';
|
||||||
export { generatePointCrawlMap, getNode, getChildren, getParents, hasPath, findAllPaths } 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
|
// Utils - Parse Shape
|
||||||
export type { ParsedShape } from './utils/parse-shape';
|
export type { ParsedShape } from './utils/parse-shape';
|
||||||
export { parseShapeString } from './utils/parse-shape';
|
export { parseShapeString } from './utils/parse-shape';
|
||||||
|
|
|
||||||
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
|
@ -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<GameItemMeta>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<GameItemMeta>;
|
||||||
|
/** 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<string>;
|
||||||
|
/** 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 };
|
||||||
Loading…
Reference in New Issue