refactor: add progress
This commit is contained in:
parent
6b724df7e7
commit
204198b10f
|
|
@ -5,6 +5,7 @@ type HeroItemFighter1Table = readonly {
|
|||
readonly costType: "energy" | "uses";
|
||||
readonly costCount: number;
|
||||
readonly targetType: "single" | "none";
|
||||
readonly price: number;
|
||||
readonly desc: string;
|
||||
}[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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