From 4a16e307941ef32202b5c348ae636a7d71f1bb47 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Fri, 26 Dec 2025 20:02:21 +0800 Subject: [PATCH] =?UTF-8?q?docs(modules):=20=E6=B7=BB=E5=8A=A0=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E6=A8=A1=E5=9D=97=E6=96=87=E6=A1=A3=20(#350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(modules): 添加框架模块文档 添加以下模块的完整文档: - FSM (状态机): 状态定义、转换条件、优先级、事件监听 - Timer (定时器): 定时器调度、冷却系统、服务令牌 - Spatial (空间索引): GridSpatialIndex、AOI 兴趣区域管理 - Pathfinding (寻路): A* 算法、网格地图、导航网格、路径平滑 - Procgen (程序化生成): 噪声函数、种子随机数、加权随机 所有文档均基于实际源码 API 编写,包含: - 快速开始示例 - 完整 API 参考 - 实际使用案例 - 蓝图节点说明 - 最佳实践建议 * docs(modules): 添加 Blueprint 模块文档和所有模块英文版 新增中文文档: - Blueprint (蓝图可视化脚本): VM、自定义节点、组合系统、触发器 新增英文文档 (docs/en/modules/): - FSM: State machine API, transitions, ECS integration - Timer: Timers, cooldowns, service tokens - Spatial: Grid spatial index, AOI management - Pathfinding: A*, grid map, NavMesh, path smoothing - Procgen: Noise functions, seeded random, weighted random - Blueprint: Visual scripting, custom nodes, composition 所有文档均基于实际源码 API 编写。 --- docs/en/modules/blueprint/index.md | 404 ++++++++++++++++++ docs/en/modules/fsm/index.md | 316 ++++++++++++++ docs/en/modules/pathfinding/index.md | 299 +++++++++++++ docs/en/modules/procgen/index.md | 396 ++++++++++++++++++ docs/en/modules/spatial/index.md | 322 ++++++++++++++ docs/en/modules/timer/index.md | 352 ++++++++++++++++ docs/modules/blueprint/index.md | 507 ++++++++++++++++++++++ docs/modules/fsm/index.md | 337 +++++++++++++++ docs/modules/pathfinding/index.md | 502 ++++++++++++++++++++++ docs/modules/procgen/index.md | 557 +++++++++++++++++++++++++ docs/modules/spatial/index.md | 600 +++++++++++++++++++++++++++ docs/modules/timer/index.md | 479 +++++++++++++++++++++ 12 files changed, 5071 insertions(+) create mode 100644 docs/en/modules/blueprint/index.md create mode 100644 docs/en/modules/fsm/index.md create mode 100644 docs/en/modules/pathfinding/index.md create mode 100644 docs/en/modules/procgen/index.md create mode 100644 docs/en/modules/spatial/index.md create mode 100644 docs/en/modules/timer/index.md create mode 100644 docs/modules/blueprint/index.md create mode 100644 docs/modules/fsm/index.md create mode 100644 docs/modules/pathfinding/index.md create mode 100644 docs/modules/procgen/index.md create mode 100644 docs/modules/spatial/index.md create mode 100644 docs/modules/timer/index.md diff --git a/docs/en/modules/blueprint/index.md b/docs/en/modules/blueprint/index.md new file mode 100644 index 00000000..f32740b7 --- /dev/null +++ b/docs/en/modules/blueprint/index.md @@ -0,0 +1,404 @@ +# Blueprint Visual Scripting + +`@esengine/blueprint` provides a full-featured visual scripting system supporting node-based programming, event-driven execution, and blueprint composition. + +## Installation + +```bash +npm install @esengine/blueprint +``` + +## Quick Start + +```typescript +import { + createBlueprintSystem, + createBlueprintComponentData, + NodeRegistry, + RegisterNode +} from '@esengine/blueprint'; + +// Create blueprint system +const blueprintSystem = createBlueprintSystem(scene); + +// Load blueprint asset +const blueprint = await loadBlueprintAsset('player.bp'); + +// Create blueprint component data +const componentData = createBlueprintComponentData(); +componentData.blueprintAsset = blueprint; + +// Update in game loop +function gameLoop(dt: number) { + blueprintSystem.process(entities, dt); +} +``` + +## Core Concepts + +### Blueprint Asset Structure + +Blueprints are saved as `.bp` files: + +```typescript +interface BlueprintAsset { + version: number; // Format version + type: 'blueprint'; // Asset type + metadata: BlueprintMetadata; // Metadata + variables: BlueprintVariable[]; // Variable definitions + nodes: BlueprintNode[]; // Node instances + connections: BlueprintConnection[]; // Connections +} +``` + +### Node Categories + +| Category | Description | Color | +|----------|-------------|-------| +| `event` | Event nodes (entry points) | Red | +| `flow` | Flow control | Gray | +| `entity` | Entity operations | Blue | +| `component` | Component access | Cyan | +| `math` | Math operations | Green | +| `logic` | Logic operations | Red | +| `variable` | Variable access | Purple | +| `time` | Time utilities | Cyan | +| `debug` | Debug utilities | Gray | + +### Pin Types + +Nodes connect through pins: + +```typescript +interface BlueprintPinDefinition { + name: string; // Pin name + type: PinDataType; // Data type + direction: 'input' | 'output'; + isExec?: boolean; // Execution pin + defaultValue?: unknown; +} + +type PinDataType = + | 'exec' // Execution flow + | 'boolean' // Boolean + | 'number' // Number + | 'string' // String + | 'vector2' // 2D vector + | 'vector3' // 3D vector + | 'entity' // Entity reference + | 'component' // Component reference + | 'any'; // Any type +``` + +### Variable Scopes + +```typescript +type VariableScope = + | 'local' // Per execution + | 'instance' // Per entity + | 'global'; // Shared globally +``` + +## Virtual Machine API + +### BlueprintVM + +The virtual machine executes blueprint graphs: + +```typescript +import { BlueprintVM } from '@esengine/blueprint'; + +const vm = new BlueprintVM(blueprintAsset, entity, scene); + +vm.start(); // Start (triggers BeginPlay) +vm.tick(deltaTime); // Update (triggers Tick) +vm.stop(); // Stop (triggers EndPlay) + +vm.pause(); +vm.resume(); + +// Trigger events +vm.triggerEvent('EventCollision', { other: otherEntity }); +vm.triggerCustomEvent('OnDamage', { amount: 50 }); + +// Debug mode +vm.debug = true; +``` + +### Execution Context + +```typescript +interface ExecutionContext { + blueprint: BlueprintAsset; + entity: Entity; + scene: IScene; + deltaTime: number; + time: number; + + getInput(nodeId: string, pinName: string): T; + setOutput(nodeId: string, pinName: string, value: unknown): void; + getVariable(name: string): T; + setVariable(name: string, value: unknown): void; +} +``` + +### Execution Result + +```typescript +interface ExecutionResult { + outputs?: Record; // Output values + nextExec?: string | null; // Next exec pin + delay?: number; // Delay execution (ms) + yield?: boolean; // Pause until next frame + error?: string; // Error message +} +``` + +## Custom Nodes + +### Define Node Template + +```typescript +import { BlueprintNodeTemplate } from '@esengine/blueprint'; + +const MyNodeTemplate: BlueprintNodeTemplate = { + type: 'MyCustomNode', + title: 'My Custom Node', + category: 'custom', + description: 'A custom node example', + keywords: ['custom', 'example'], + inputs: [ + { name: 'exec', type: 'exec', direction: 'input', isExec: true }, + { name: 'value', type: 'number', direction: 'input', defaultValue: 0 } + ], + outputs: [ + { name: 'exec', type: 'exec', direction: 'output', isExec: true }, + { name: 'result', type: 'number', direction: 'output' } + ] +}; +``` + +### Implement Node Executor + +```typescript +import { INodeExecutor, RegisterNode } from '@esengine/blueprint'; + +@RegisterNode(MyNodeTemplate) +class MyNodeExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = context.getInput(node.id, 'value'); + const result = value * 2; + + return { + outputs: { result }, + nextExec: 'exec' + }; + } +} +``` + +### Registration Methods + +```typescript +// Method 1: Decorator +@RegisterNode(MyNodeTemplate) +class MyNodeExecutor implements INodeExecutor { ... } + +// Method 2: Manual registration +NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor()); +``` + +## Node Registry + +```typescript +import { NodeRegistry } from '@esengine/blueprint'; + +const registry = NodeRegistry.instance; + +const allTemplates = registry.getAllTemplates(); +const mathNodes = registry.getTemplatesByCategory('math'); +const results = registry.searchTemplates('add'); + +if (registry.has('MyCustomNode')) { ... } +``` + +## Built-in Nodes + +### Event Nodes +| Node | Description | +|------|-------------| +| `EventBeginPlay` | Triggered on blueprint start | +| `EventTick` | Triggered every frame | +| `EventEndPlay` | Triggered on blueprint stop | +| `EventCollision` | Triggered on collision | +| `EventInput` | Triggered on input | +| `EventTimer` | Triggered by timer | + +### Time Nodes +| Node | Description | +|------|-------------| +| `Delay` | Delay execution | +| `GetDeltaTime` | Get frame delta | +| `GetTime` | Get total runtime | + +### Math Nodes +| Node | Description | +|------|-------------| +| `Add`, `Subtract`, `Multiply`, `Divide` | Basic operations | +| `Abs`, `Clamp`, `Lerp`, `Min`, `Max` | Utility functions | + +### Debug Nodes +| Node | Description | +|------|-------------| +| `Print` | Print to console | + +## Blueprint Composition + +### Blueprint Fragments + +Encapsulate reusable logic as fragments: + +```typescript +import { createFragment } from '@esengine/blueprint'; + +const healthFragment = createFragment('HealthSystem', { + inputs: [ + { name: 'damage', type: 'number', internalNodeId: 'input1', internalPinName: 'value' } + ], + outputs: [ + { name: 'isDead', type: 'boolean', internalNodeId: 'output1', internalPinName: 'value' } + ], + graph: { nodes: [...], connections: [...], variables: [...] } +}); +``` + +### Compose Blueprints + +```typescript +import { createComposer, FragmentRegistry } from '@esengine/blueprint'; + +// Register fragments +FragmentRegistry.instance.register('health', healthFragment); +FragmentRegistry.instance.register('movement', movementFragment); + +// Create composer +const composer = createComposer('PlayerBlueprint'); + +// Add fragments to slots +composer.addFragment(healthFragment, 'slot1', { position: { x: 0, y: 0 } }); +composer.addFragment(movementFragment, 'slot2', { position: { x: 400, y: 0 } }); + +// Connect slots +composer.connect('slot1', 'onDeath', 'slot2', 'disable'); + +// Validate +const validation = composer.validate(); +if (!validation.isValid) { + console.error(validation.errors); +} + +// Compile to blueprint +const blueprint = composer.compile(); +``` + +## Trigger System + +### Define Trigger Conditions + +```typescript +import { TriggerCondition, TriggerDispatcher } from '@esengine/blueprint'; + +const lowHealthCondition: TriggerCondition = { + type: 'comparison', + left: { type: 'variable', name: 'health' }, + operator: '<', + right: { type: 'constant', value: 20 } +}; +``` + +### Use Trigger Dispatcher + +```typescript +const dispatcher = new TriggerDispatcher(); + +dispatcher.register('lowHealth', lowHealthCondition, (context) => { + context.triggerEvent('OnLowHealth'); +}); + +dispatcher.evaluate(context); +``` + +## ECS Integration + +### Using Blueprint System + +```typescript +import { createBlueprintSystem } from '@esengine/blueprint'; + +class GameScene { + private blueprintSystem: BlueprintSystem; + + initialize() { + this.blueprintSystem = createBlueprintSystem(this.scene); + } + + update(dt: number) { + this.blueprintSystem.process(this.entities, dt); + } +} +``` + +### Triggering Blueprint Events + +```typescript +import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint'; + +triggerBlueprintEvent(entity, 'Collision', { other: otherEntity }); +triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity }); +``` + +## Serialization + +### Save Blueprint + +```typescript +import { validateBlueprintAsset } from '@esengine/blueprint'; + +function saveBlueprint(blueprint: BlueprintAsset, path: string): void { + if (!validateBlueprintAsset(blueprint)) { + throw new Error('Invalid blueprint structure'); + } + const json = JSON.stringify(blueprint, null, 2); + fs.writeFileSync(path, json); +} +``` + +### Load Blueprint + +```typescript +async function loadBlueprint(path: string): Promise { + const json = await fs.readFile(path, 'utf-8'); + const asset = JSON.parse(json); + + if (!validateBlueprintAsset(asset)) { + throw new Error('Invalid blueprint file'); + } + + return asset; +} +``` + +## Best Practices + +1. **Use fragments for reusable logic** +2. **Choose appropriate variable scopes** + - `local`: Temporary calculations + - `instance`: Entity state (e.g., health) + - `global`: Game-wide state +3. **Avoid infinite loops** - VM has max steps per frame (default 1000) +4. **Debug techniques** + - Enable `vm.debug = true` for execution logs + - Use Print nodes for intermediate values +5. **Performance optimization** + - Pure nodes (`isPure: true`) cache outputs + - Avoid heavy computation in Tick diff --git a/docs/en/modules/fsm/index.md b/docs/en/modules/fsm/index.md new file mode 100644 index 00000000..1fb09f8d --- /dev/null +++ b/docs/en/modules/fsm/index.md @@ -0,0 +1,316 @@ +# State Machine (FSM) + +`@esengine/fsm` provides a type-safe finite state machine implementation for characters, AI, or any scenario requiring state management. + +## Installation + +```bash +npm install @esengine/fsm +``` + +## Quick Start + +```typescript +import { createStateMachine } from '@esengine/fsm'; + +// Define state types +type PlayerState = 'idle' | 'walk' | 'run' | 'jump'; + +// Create state machine +const fsm = createStateMachine('idle'); + +// Define states with callbacks +fsm.defineState('idle', { + onEnter: (ctx, from) => console.log(`Entered idle from ${from}`), + onExit: (ctx, to) => console.log(`Exiting idle to ${to}`), + onUpdate: (ctx, dt) => { /* Update every frame */ } +}); + +fsm.defineState('walk', { + onEnter: () => console.log('Started walking') +}); + +// Manual transition +fsm.transition('walk'); + +console.log(fsm.current); // 'walk' +``` + +## Core Concepts + +### State Configuration + +Each state can be configured with the following callbacks: + +```typescript +interface StateConfig { + name: TState; // State name + onEnter?: (context: TContext, from: TState | null) => void; // Enter callback + onExit?: (context: TContext, to: TState) => void; // Exit callback + onUpdate?: (context: TContext, deltaTime: number) => void; // Update callback + tags?: string[]; // State tags + metadata?: Record; // Metadata +} +``` + +### Transition Conditions + +Define conditional state transitions: + +```typescript +interface Context { + isMoving: boolean; + isRunning: boolean; + isGrounded: boolean; +} + +const fsm = createStateMachine('idle', { + context: { isMoving: false, isRunning: false, isGrounded: true } +}); + +// Define transition conditions +fsm.defineTransition('idle', 'walk', (ctx) => ctx.isMoving); +fsm.defineTransition('walk', 'run', (ctx) => ctx.isRunning); +fsm.defineTransition('walk', 'idle', (ctx) => !ctx.isMoving); + +// Automatically evaluate and execute matching transitions +fsm.evaluateTransitions(); +``` + +### Transition Priority + +When multiple transitions are valid, higher priority executes first: + +```typescript +// Higher priority number = higher priority +fsm.defineTransition('idle', 'attack', (ctx) => ctx.isAttacking, 10); +fsm.defineTransition('idle', 'walk', (ctx) => ctx.isMoving, 1); + +// If both conditions are met, 'attack' (priority 10) is tried first +``` + +## API Reference + +### createStateMachine + +```typescript +function createStateMachine( + initialState: TState, + options?: StateMachineOptions +): IStateMachine +``` + +**Parameters:** +- `initialState` - Initial state +- `options.context` - Context object, accessible in callbacks +- `options.maxHistorySize` - Maximum history entries (default 100) +- `options.enableHistory` - Enable history tracking (default true) + +### State Machine Properties + +| Property | Type | Description | +|----------|------|-------------| +| `current` | `TState` | Current state | +| `previous` | `TState \| null` | Previous state | +| `context` | `TContext` | Context object | +| `isTransitioning` | `boolean` | Whether currently transitioning | +| `currentStateDuration` | `number` | Current state duration (ms) | + +### State Machine Methods + +#### State Definition + +```typescript +// Define state +fsm.defineState('idle', { + onEnter: (ctx, from) => {}, + onExit: (ctx, to) => {}, + onUpdate: (ctx, dt) => {} +}); + +// Check if state exists +fsm.hasState('idle'); // true + +// Get state configuration +fsm.getStateConfig('idle'); + +// Get all states +fsm.getStates(); // ['idle', 'walk', ...] +``` + +#### Transition Operations + +```typescript +// Define transition +fsm.defineTransition('idle', 'walk', condition, priority); + +// Remove transition +fsm.removeTransition('idle', 'walk'); + +// Get transitions from state +fsm.getTransitionsFrom('idle'); + +// Check if transition is possible +fsm.canTransition('walk'); // true/false + +// Manual transition +fsm.transition('walk'); + +// Force transition (ignore conditions) +fsm.transition('walk', true); + +// Auto-evaluate transition conditions +fsm.evaluateTransitions(); +``` + +#### Lifecycle + +```typescript +// Update state machine (calls current state's onUpdate) +fsm.update(deltaTime); + +// Reset state machine +fsm.reset(); // Reset to current state +fsm.reset('idle'); // Reset to specified state +``` + +#### Event Listeners + +```typescript +// Listen to entering specific state +const unsubscribe = fsm.onEnter('walk', (from) => { + console.log(`Entered walk from ${from}`); +}); + +// Listen to exiting specific state +fsm.onExit('walk', (to) => { + console.log(`Exiting walk to ${to}`); +}); + +// Listen to any state change +fsm.onChange((event) => { + console.log(`${event.from} -> ${event.to} at ${event.timestamp}`); +}); + +// Unsubscribe +unsubscribe(); +``` + +#### Debugging + +```typescript +// Get state history +const history = fsm.getHistory(); +// [{ from: 'idle', to: 'walk', timestamp: 1234567890 }, ...] + +// Clear history +fsm.clearHistory(); + +// Get debug info +const info = fsm.getDebugInfo(); +// { current, previous, duration, stateCount, transitionCount, historySize } +``` + +## Practical Examples + +### Character State Machine + +```typescript +import { createStateMachine } from '@esengine/fsm'; + +type CharacterState = 'idle' | 'walk' | 'run' | 'jump' | 'fall' | 'attack'; + +interface CharacterContext { + velocity: { x: number; y: number }; + isGrounded: boolean; + isAttacking: boolean; + speed: number; +} + +const characterFSM = createStateMachine('idle', { + context: { + velocity: { x: 0, y: 0 }, + isGrounded: true, + isAttacking: false, + speed: 0 + } +}); + +// Define states +characterFSM.defineState('idle', { + onEnter: (ctx) => { ctx.speed = 0; } +}); + +characterFSM.defineState('walk', { + onEnter: (ctx) => { ctx.speed = 100; } +}); + +characterFSM.defineState('run', { + onEnter: (ctx) => { ctx.speed = 200; } +}); + +// Define transitions +characterFSM.defineTransition('idle', 'walk', (ctx) => Math.abs(ctx.velocity.x) > 0); +characterFSM.defineTransition('walk', 'idle', (ctx) => ctx.velocity.x === 0); +characterFSM.defineTransition('walk', 'run', (ctx) => Math.abs(ctx.velocity.x) > 150); + +// Jump has highest priority +characterFSM.defineTransition('idle', 'jump', (ctx) => !ctx.isGrounded, 10); +characterFSM.defineTransition('walk', 'jump', (ctx) => !ctx.isGrounded, 10); + +// Game loop usage +function gameUpdate(dt: number) { + // Update context + characterFSM.context.velocity.x = getInputVelocity(); + characterFSM.context.isGrounded = checkGrounded(); + + // Evaluate transitions + characterFSM.evaluateTransitions(); + + // Update current state + characterFSM.update(dt); +} +``` + +### ECS Integration + +```typescript +import { Component, EntitySystem, Matcher } from '@esengine/ecs-framework'; +import { createStateMachine, type IStateMachine } from '@esengine/fsm'; + +// State machine component +class FSMComponent extends Component { + fsm: IStateMachine; + + constructor(initialState: string) { + super(); + this.fsm = createStateMachine(initialState); + } +} + +// State machine system +class FSMSystem extends EntitySystem { + constructor() { + super(Matcher.all(FSMComponent)); + } + + protected processEntity(entity: Entity, dt: number): void { + const fsmComp = entity.getComponent(FSMComponent); + fsmComp.fsm.evaluateTransitions(); + fsmComp.fsm.update(dt); + } +} +``` + +## Blueprint Nodes + +The FSM module provides blueprint nodes for visual scripting: + +- `GetCurrentState` - Get current state +- `TransitionTo` - Transition to specified state +- `CanTransition` - Check if transition is possible +- `IsInState` - Check if in specified state +- `WasInState` - Check if was ever in specified state +- `GetStateDuration` - Get state duration +- `EvaluateTransitions` - Evaluate transition conditions +- `ResetStateMachine` - Reset state machine diff --git a/docs/en/modules/pathfinding/index.md b/docs/en/modules/pathfinding/index.md new file mode 100644 index 00000000..21c319ad --- /dev/null +++ b/docs/en/modules/pathfinding/index.md @@ -0,0 +1,299 @@ +# Pathfinding System + +`@esengine/pathfinding` provides a complete 2D pathfinding solution including A* algorithm, grid maps, navigation meshes, and path smoothing. + +## Installation + +```bash +npm install @esengine/pathfinding +``` + +## Quick Start + +### Grid Map Pathfinding + +```typescript +import { createGridMap, createAStarPathfinder } from '@esengine/pathfinding'; + +// Create 20x20 grid +const grid = createGridMap(20, 20); + +// Set obstacles +grid.setWalkable(5, 5, false); +grid.setWalkable(5, 6, false); + +// Create pathfinder +const pathfinder = createAStarPathfinder(grid); + +// Find path +const result = pathfinder.findPath(0, 0, 15, 15); + +if (result.found) { + console.log('Path found!'); + console.log('Path:', result.path); + console.log('Cost:', result.cost); +} +``` + +### NavMesh Pathfinding + +```typescript +import { createNavMesh } from '@esengine/pathfinding'; + +const navmesh = createNavMesh(); + +// Add polygon areas +navmesh.addPolygon([ + { x: 0, y: 0 }, { x: 10, y: 0 }, + { x: 10, y: 10 }, { x: 0, y: 10 } +]); + +navmesh.addPolygon([ + { x: 10, y: 0 }, { x: 20, y: 0 }, + { x: 20, y: 10 }, { x: 10, y: 10 } +]); + +// Auto-build connections +navmesh.build(); + +// Find path +const result = navmesh.findPath(1, 1, 18, 8); +``` + +## Core Concepts + +### IPathResult + +```typescript +interface IPathResult { + readonly found: boolean; // Path found + readonly path: readonly IPoint[];// Path points + readonly cost: number; // Total cost + readonly nodesSearched: number; // Nodes searched +} +``` + +### IPathfindingOptions + +```typescript +interface IPathfindingOptions { + maxNodes?: number; // Max search nodes (default 10000) + heuristicWeight?: number; // Heuristic weight (>1 faster but may be suboptimal) + allowDiagonal?: boolean; // Allow diagonal movement (default true) + avoidCorners?: boolean; // Avoid corner cutting (default true) +} +``` + +## Heuristic Functions + +| Function | Use Case | Description | +|----------|----------|-------------| +| `manhattanDistance` | 4-directional | Manhattan distance | +| `euclideanDistance` | Any direction | Euclidean distance | +| `chebyshevDistance` | 8-directional | Diagonal cost = 1 | +| `octileDistance` | 8-directional | Diagonal cost = √2 (default) | + +## Grid Map API + +### createGridMap + +```typescript +function createGridMap( + width: number, + height: number, + options?: IGridMapOptions +): GridMap +``` + +**Options:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `allowDiagonal` | `boolean` | `true` | Allow diagonal movement | +| `diagonalCost` | `number` | `√2` | Diagonal movement cost | +| `avoidCorners` | `boolean` | `true` | Avoid corner cutting | +| `heuristic` | `HeuristicFunction` | `octileDistance` | Heuristic function | + +### Map Operations + +```typescript +// Check/set walkability +grid.isWalkable(x, y); +grid.setWalkable(x, y, false); + +// Set movement cost (e.g., swamp, sand) +grid.setCost(x, y, 2); + +// Set rectangle region +grid.setRectWalkable(0, 0, 5, 5, false); + +// Load from array (0=walkable, non-0=blocked) +grid.loadFromArray([ + [0, 0, 0, 1, 0], + [0, 1, 0, 1, 0] +]); + +// Load from string (.=walkable, #=blocked) +grid.loadFromString(` +..... +.#.#. +`); + +// Export and reset +console.log(grid.toString()); +grid.reset(); +``` + +## A* Pathfinder API + +```typescript +const pathfinder = createAStarPathfinder(grid); + +const result = pathfinder.findPath( + startX, startY, + endX, endY, + { maxNodes: 5000, heuristicWeight: 1.5 } +); + +// Pathfinder is reusable +pathfinder.findPath(0, 0, 10, 10); +pathfinder.findPath(5, 5, 15, 15); +``` + +## NavMesh API + +```typescript +const navmesh = createNavMesh(); + +// Add convex polygons +const id1 = navmesh.addPolygon(vertices1); +const id2 = navmesh.addPolygon(vertices2); + +// Auto-detect shared edges +navmesh.build(); + +// Or manually set connections +navmesh.setConnection(id1, id2, { + left: { x: 10, y: 0 }, + right: { x: 10, y: 10 } +}); + +// Query and pathfind +const polygon = navmesh.findPolygonAt(5, 5); +navmesh.isWalkable(5, 5); +const result = navmesh.findPath(1, 1, 18, 8); +``` + +## Path Smoothing + +### Line of Sight Smoothing + +Remove unnecessary waypoints: + +```typescript +import { createLineOfSightSmoother } from '@esengine/pathfinding'; + +const smoother = createLineOfSightSmoother(); +const smoothedPath = smoother.smooth(result.path, grid); +``` + +### Curve Smoothing + +Catmull-Rom spline: + +```typescript +import { createCatmullRomSmoother } from '@esengine/pathfinding'; + +const smoother = createCatmullRomSmoother(5, 0.5); +const curvedPath = smoother.smooth(result.path, grid); +``` + +### Combined Smoothing + +```typescript +import { createCombinedSmoother } from '@esengine/pathfinding'; + +const smoother = createCombinedSmoother(5, 0.5); +const finalPath = smoother.smooth(result.path, grid); +``` + +### Line of Sight Functions + +```typescript +import { bresenhamLineOfSight, raycastLineOfSight } from '@esengine/pathfinding'; + +const hasLOS = bresenhamLineOfSight(x1, y1, x2, y2, grid); +const hasLOS2 = raycastLineOfSight(x1, y1, x2, y2, grid, 0.5); +``` + +## Practical Examples + +### Dynamic Obstacles + +```typescript +class DynamicPathfinding { + private grid: GridMap; + private pathfinder: AStarPathfinder; + private dynamicObstacles: Set = new Set(); + + addDynamicObstacle(x: number, y: number): void { + this.dynamicObstacles.add(`${x},${y}`); + this.grid.setWalkable(x, y, false); + } + + removeDynamicObstacle(x: number, y: number): void { + this.dynamicObstacles.delete(`${x},${y}`); + this.grid.setWalkable(x, y, true); + } +} +``` + +### Terrain Costs + +```typescript +const grid = createGridMap(50, 50); + +// Normal ground - cost 1 (default) +// Sand - cost 2 +for (let y = 10; y < 20; y++) { + for (let x = 0; x < 50; x++) { + grid.setCost(x, y, 2); + } +} + +// Swamp - cost 4 +for (let y = 30; y < 35; y++) { + for (let x = 20; x < 30; x++) { + grid.setCost(x, y, 4); + } +} +``` + +## Blueprint Nodes + +- `FindPath` - Find path +- `FindPathSmooth` - Find and smooth path +- `IsWalkable` - Check walkability +- `GetPathLength` - Get path point count +- `GetPathDistance` - Get total path distance +- `GetPathPoint` - Get specific path point +- `MoveAlongPath` - Move along path +- `HasLineOfSight` - Check line of sight + +## Performance Tips + +1. **Limit search range**: `{ maxNodes: 1000 }` +2. **Use heuristic weight**: `{ heuristicWeight: 1.5 }` (faster but may not be optimal) +3. **Reuse pathfinder instances** +4. **Use NavMesh for complex terrain** +5. **Choose appropriate heuristic for movement type** + +## Grid vs NavMesh + +| Feature | GridMap | NavMesh | +|---------|---------|---------| +| Use Case | Regular tile maps | Complex polygon terrain | +| Memory | Higher (width × height) | Lower (polygon count) | +| Precision | Grid-aligned | Continuous coordinates | +| Dynamic Updates | Easy | Requires rebuild | +| Setup Complexity | Simple | More complex | diff --git a/docs/en/modules/procgen/index.md b/docs/en/modules/procgen/index.md new file mode 100644 index 00000000..6115f0f5 --- /dev/null +++ b/docs/en/modules/procgen/index.md @@ -0,0 +1,396 @@ +# Procedural Generation (Procgen) + +`@esengine/procgen` provides core tools for procedural content generation, including noise functions, seeded random numbers, and various random utilities. + +## Installation + +```bash +npm install @esengine/procgen +``` + +## Quick Start + +### Noise Generation + +```typescript +import { createPerlinNoise, createFBM } from '@esengine/procgen'; + +// Create Perlin noise +const perlin = createPerlinNoise(12345); // seed + +// Sample 2D noise +const value = perlin.noise2D(x * 0.1, y * 0.1); +console.log(value); // [-1, 1] + +// Use FBM for more natural results +const fbm = createFBM(perlin, { + octaves: 6, + persistence: 0.5 +}); + +const height = fbm.noise2D(x * 0.01, y * 0.01); +``` + +### Seeded Random + +```typescript +import { createSeededRandom } from '@esengine/procgen'; + +// Create deterministic random generator +const rng = createSeededRandom(42); + +// Same seed always produces same sequence +console.log(rng.next()); // 0.xxx +console.log(rng.nextInt(1, 100)); // 1-100 +console.log(rng.nextBool(0.3)); // 30% true +``` + +### Weighted Random + +```typescript +import { createWeightedRandom, createSeededRandom } from '@esengine/procgen'; + +const rng = createSeededRandom(42); + +const loot = createWeightedRandom([ + { value: 'common', weight: 60 }, + { value: 'uncommon', weight: 25 }, + { value: 'rare', weight: 10 }, + { value: 'legendary', weight: 5 } +]); + +const drop = loot.pick(rng); +console.log(drop); // Likely 'common' +``` + +## Noise Functions + +### Perlin Noise + +Classic gradient noise, output range [-1, 1]: + +```typescript +import { createPerlinNoise } from '@esengine/procgen'; + +const perlin = createPerlinNoise(seed); +const value2D = perlin.noise2D(x, y); +const value3D = perlin.noise3D(x, y, z); +``` + +### Simplex Noise + +Faster than Perlin, less directional bias: + +```typescript +import { createSimplexNoise } from '@esengine/procgen'; + +const simplex = createSimplexNoise(seed); +const value = simplex.noise2D(x, y); +``` + +### Worley Noise + +Cell-based noise for stone, cell textures: + +```typescript +import { createWorleyNoise } from '@esengine/procgen'; + +const worley = createWorleyNoise(seed); +const distance = worley.noise2D(x, y); +``` + +### FBM (Fractal Brownian Motion) + +Layer multiple noise octaves for richer detail: + +```typescript +import { createPerlinNoise, createFBM } from '@esengine/procgen'; + +const baseNoise = createPerlinNoise(seed); + +const fbm = createFBM(baseNoise, { + octaves: 6, // Layer count (more = richer detail) + lacunarity: 2.0, // Frequency multiplier + persistence: 0.5, // Amplitude decay + frequency: 1.0, // Initial frequency + amplitude: 1.0 // Initial amplitude +}); + +// Standard FBM +const value = fbm.noise2D(x, y); + +// Ridged FBM (for mountains) +const ridged = fbm.ridged2D(x, y); + +// Turbulence +const turb = fbm.turbulence2D(x, y); + +// Billowed (for clouds) +const cloud = fbm.billowed2D(x, y); +``` + +## Seeded Random API + +### SeededRandom + +Deterministic PRNG based on xorshift128+: + +```typescript +import { createSeededRandom } from '@esengine/procgen'; + +const rng = createSeededRandom(42); +``` + +### Basic Methods + +```typescript +rng.next(); // [0, 1) float +rng.nextInt(1, 10); // [min, max] integer +rng.nextFloat(0, 100); // [min, max) float +rng.nextBool(); // 50% +rng.nextBool(0.3); // 30% +rng.reset(); // Reset to initial state +``` + +### Distribution Methods + +```typescript +// Normal distribution (Gaussian) +rng.nextGaussian(); // mean 0, stdDev 1 +rng.nextGaussian(100, 15); // mean 100, stdDev 15 + +// Exponential distribution +rng.nextExponential(); // λ = 1 +rng.nextExponential(0.5); // λ = 0.5 +``` + +### Geometry Methods + +```typescript +// Uniform point in circle +const point = rng.nextPointInCircle(50); // { x, y } + +// Point on circle edge +const edge = rng.nextPointOnCircle(50); // { x, y } + +// Uniform point in sphere +const point3D = rng.nextPointInSphere(50); // { x, y, z } + +// Random direction vector +const dir = rng.nextDirection2D(); // { x, y }, length 1 +``` + +## Weighted Random API + +### WeightedRandom + +Precomputed cumulative weights for efficient selection: + +```typescript +import { createWeightedRandom } from '@esengine/procgen'; + +const selector = createWeightedRandom([ + { value: 'apple', weight: 5 }, + { value: 'banana', weight: 3 }, + { value: 'cherry', weight: 2 } +]); + +const result = selector.pick(rng); +const result2 = selector.pickRandom(); // Uses Math.random + +console.log(selector.getProbability(0)); // 0.5 (5/10) +console.log(selector.size); // 3 +console.log(selector.totalWeight); // 10 +``` + +### Convenience Functions + +```typescript +import { weightedPick, weightedPickFromMap } from '@esengine/procgen'; + +const item = weightedPick([ + { value: 'a', weight: 1 }, + { value: 'b', weight: 2 } +], rng); + +const item2 = weightedPickFromMap({ + 'common': 60, + 'rare': 30, + 'epic': 10 +}, rng); +``` + +## Shuffle and Sampling + +### shuffle / shuffleCopy + +Fisher-Yates shuffle: + +```typescript +import { shuffle, shuffleCopy } from '@esengine/procgen'; + +const arr = [1, 2, 3, 4, 5]; +shuffle(arr, rng); // In-place +const shuffled = shuffleCopy(arr, rng); // Copy +``` + +### pickOne + +```typescript +import { pickOne } from '@esengine/procgen'; + +const item = pickOne(['a', 'b', 'c', 'd'], rng); +``` + +### sample / sampleWithReplacement + +```typescript +import { sample, sampleWithReplacement } from '@esengine/procgen'; + +const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const unique = sample(arr, 3, rng); // 3 unique +const withRep = sampleWithReplacement(arr, 5, rng); // 5 with replacement +``` + +### randomIntegers + +```typescript +import { randomIntegers } from '@esengine/procgen'; + +// 5 unique random integers from 1-100 +const nums = randomIntegers(1, 100, 5, rng); +``` + +### weightedSample + +```typescript +import { weightedSample } from '@esengine/procgen'; + +const items = ['A', 'B', 'C', 'D', 'E']; +const weights = [10, 8, 6, 4, 2]; +const selected = weightedSample(items, weights, 3, rng); +``` + +## Practical Examples + +### Procedural Terrain + +```typescript +import { createPerlinNoise, createFBM } from '@esengine/procgen'; + +class TerrainGenerator { + private fbm: FBM; + private moistureFbm: FBM; + + constructor(seed: number) { + const heightNoise = createPerlinNoise(seed); + const moistureNoise = createPerlinNoise(seed + 1000); + + this.fbm = createFBM(heightNoise, { + octaves: 8, + persistence: 0.5, + frequency: 0.01 + }); + + this.moistureFbm = createFBM(moistureNoise, { + octaves: 4, + persistence: 0.6, + frequency: 0.02 + }); + } + + getHeight(x: number, y: number): number { + let height = this.fbm.noise2D(x, y); + height += this.fbm.ridged2D(x * 0.5, y * 0.5) * 0.3; + return (height + 1) * 0.5; // Normalize to [0, 1] + } + + getBiome(x: number, y: number): string { + const height = this.getHeight(x, y); + const moisture = (this.moistureFbm.noise2D(x, y) + 1) * 0.5; + + if (height < 0.3) return 'water'; + if (height < 0.4) return 'beach'; + if (height > 0.8) return 'mountain'; + + if (moisture < 0.3) return 'desert'; + if (moisture > 0.7) return 'forest'; + return 'grassland'; + } +} +``` + +### Loot System + +```typescript +import { createSeededRandom, createWeightedRandom } from '@esengine/procgen'; + +class LootSystem { + private rng: SeededRandom; + private raritySelector: WeightedRandom; + + constructor(seed: number) { + this.rng = createSeededRandom(seed); + this.raritySelector = createWeightedRandom([ + { value: 'common', weight: 60 }, + { value: 'uncommon', weight: 25 }, + { value: 'rare', weight: 10 }, + { value: 'legendary', weight: 5 } + ]); + } + + generateLoot(count: number): LootItem[] { + const loot: LootItem[] = []; + for (let i = 0; i < count; i++) { + const rarity = this.raritySelector.pick(this.rng); + // Get item from rarity table... + loot.push(item); + } + return loot; + } +} +``` + +## Blueprint Nodes + +### Noise Nodes +- `SampleNoise2D` - Sample 2D noise +- `SampleFBM` - Sample FBM noise + +### Random Nodes +- `SeededRandom` - Generate random float +- `SeededRandomInt` - Generate random integer +- `WeightedPick` - Weighted random selection +- `ShuffleArray` - Shuffle array +- `PickRandom` - Pick random element +- `SampleArray` - Sample from array +- `RandomPointInCircle` - Random point in circle + +## Best Practices + +1. **Use seeds for reproducibility** + ```typescript + const seed = Date.now(); + const rng = createSeededRandom(seed); + saveSeed(seed); + ``` + +2. **Precompute weighted selectors** + ```typescript + // Good: Create once, use many times + const selector = createWeightedRandom(items); + for (let i = 0; i < 1000; i++) { + selector.pick(rng); + } + ``` + +3. **Choose appropriate noise** + - Perlin: Smooth terrain, clouds + - Simplex: Performance-critical + - Worley: Cell textures, stone + - FBM: Natural multi-detail effects + +4. **Tune FBM parameters** + - `octaves`: More = richer detail, higher cost + - `persistence`: 0.5 is common, higher = more high-frequency detail + - `lacunarity`: Usually 2, controls frequency growth diff --git a/docs/en/modules/spatial/index.md b/docs/en/modules/spatial/index.md new file mode 100644 index 00000000..407b7021 --- /dev/null +++ b/docs/en/modules/spatial/index.md @@ -0,0 +1,322 @@ +# Spatial Index System + +`@esengine/spatial` provides efficient spatial querying and indexing, including range queries, nearest neighbor queries, raycasting, and AOI (Area of Interest) management. + +## Installation + +```bash +npm install @esengine/spatial +``` + +## Quick Start + +### Spatial Index + +```typescript +import { createGridSpatialIndex } from '@esengine/spatial'; + +// Create spatial index (cell size 100) +const spatialIndex = createGridSpatialIndex(100); + +// Insert objects +spatialIndex.insert(player, { x: 100, y: 200 }); +spatialIndex.insert(enemy1, { x: 150, y: 250 }); +spatialIndex.insert(enemy2, { x: 500, y: 600 }); + +// Find objects within radius +const nearby = spatialIndex.findInRadius({ x: 100, y: 200 }, 100); +console.log(nearby); // [player, enemy1] + +// Find nearest object +const nearest = spatialIndex.findNearest({ x: 100, y: 200 }); +console.log(nearest); // enemy1 + +// Update position +spatialIndex.update(player, { x: 120, y: 220 }); +``` + +### AOI (Area of Interest) + +```typescript +import { createGridAOI } from '@esengine/spatial'; + +// Create AOI manager +const aoi = createGridAOI(100); + +// Add observers +aoi.addObserver(player, { x: 100, y: 100 }, { viewRange: 200 }); +aoi.addObserver(npc, { x: 150, y: 150 }, { viewRange: 150 }); + +// Listen to enter/exit events +aoi.addListener((event) => { + if (event.type === 'enter') { + console.log(`${event.observer} saw ${event.target}`); + } else if (event.type === 'exit') { + console.log(`${event.target} left ${event.observer}'s view`); + } +}); + +// Update position (triggers enter/exit events) +aoi.updatePosition(player, { x: 200, y: 200 }); + +// Get visible entities +const visible = aoi.getEntitiesInView(player); +``` + +## Core Concepts + +### Spatial Index vs AOI + +| Feature | SpatialIndex | AOI | +|---------|--------------|-----| +| Purpose | General spatial queries | Entity visibility tracking | +| Events | No event notification | Enter/exit events | +| Direction | One-way query | Two-way tracking | +| Use Cases | Collision, range attacks | MMO sync, NPC AI perception | + +### IBounds + +```typescript +interface IBounds { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; +} +``` + +### IRaycastHit + +```typescript +interface IRaycastHit { + readonly target: T; // Hit object + readonly point: IVector2; // Hit point + readonly normal: IVector2;// Hit normal + readonly distance: number;// Distance from origin +} +``` + +## Spatial Index API + +### createGridSpatialIndex + +```typescript +function createGridSpatialIndex(cellSize?: number): GridSpatialIndex +``` + +**Choosing cellSize:** +- Too small: High memory, reduced query efficiency +- Too large: Many objects per cell, slow iteration +- Recommended: 1-2x average object spacing + +### Management Methods + +```typescript +spatialIndex.insert(entity, position); +spatialIndex.remove(entity); +spatialIndex.update(entity, newPosition); +spatialIndex.clear(); +``` + +### Query Methods + +#### findInRadius + +```typescript +const enemies = spatialIndex.findInRadius( + { x: 100, y: 200 }, + 50, + (entity) => entity.type === 'enemy' // Optional filter +); +``` + +#### findInRect + +```typescript +import { createBounds } from '@esengine/spatial'; + +const bounds = createBounds(0, 0, 200, 200); +const entities = spatialIndex.findInRect(bounds); +``` + +#### findNearest + +```typescript +const nearest = spatialIndex.findNearest( + playerPosition, + 500, // maxDistance + (entity) => entity.type === 'enemy' +); +``` + +#### findKNearest + +```typescript +const nearestEnemies = spatialIndex.findKNearest( + playerPosition, + 5, // k + 500, // maxDistance + (entity) => entity.type === 'enemy' +); +``` + +#### raycast / raycastFirst + +```typescript +const hits = spatialIndex.raycast(origin, direction, maxDistance); +const firstHit = spatialIndex.raycastFirst(origin, direction, maxDistance); +``` + +## AOI API + +### createGridAOI + +```typescript +function createGridAOI(cellSize?: number): GridAOI +``` + +### Observer Management + +```typescript +// Add observer +aoi.addObserver(player, position, { + viewRange: 200, + observable: true // Can be seen by others +}); + +// Remove observer +aoi.removeObserver(player); + +// Update position +aoi.updatePosition(player, newPosition); + +// Update view range +aoi.updateViewRange(player, 300); +``` + +### Query Methods + +```typescript +// Get entities in observer's view +const visible = aoi.getEntitiesInView(player); + +// Get observers who can see entity +const observers = aoi.getObserversOf(monster); + +// Check visibility +if (aoi.canSee(player, enemy)) { ... } +``` + +### Event System + +```typescript +// Global event listener +aoi.addListener((event) => { + switch (event.type) { + case 'enter': /* entered view */ break; + case 'exit': /* left view */ break; + } +}); + +// Entity-specific listener +aoi.addEntityListener(player, (event) => { + if (event.type === 'enter') { + sendToClient(player, 'entity_enter', event.target); + } +}); +``` + +## Utility Functions + +### Bounds Creation + +```typescript +import { + createBounds, + createBoundsFromCenter, + createBoundsFromCircle +} from '@esengine/spatial'; + +const bounds1 = createBounds(0, 0, 100, 100); +const bounds2 = createBoundsFromCenter({ x: 50, y: 50 }, 100, 100); +const bounds3 = createBoundsFromCircle({ x: 50, y: 50 }, 50); +``` + +### Geometry Checks + +```typescript +import { + isPointInBounds, + boundsIntersect, + boundsIntersectsCircle, + distance, + distanceSquared +} from '@esengine/spatial'; + +if (isPointInBounds(point, bounds)) { ... } +if (boundsIntersect(boundsA, boundsB)) { ... } +if (boundsIntersectsCircle(bounds, center, radius)) { ... } +const dist = distance(pointA, pointB); +const distSq = distanceSquared(pointA, pointB); // Faster +``` + +## Practical Examples + +### Range Attack Detection + +```typescript +class CombatSystem { + private spatialIndex: ISpatialIndex; + + dealAreaDamage(center: IVector2, radius: number, damage: number): void { + const targets = this.spatialIndex.findInRadius( + center, radius, + (entity) => entity.hasComponent(HealthComponent) + ); + + for (const target of targets) { + target.getComponent(HealthComponent).takeDamage(damage); + } + } +} +``` + +### MMO Sync System + +```typescript +class SyncSystem { + private aoi: IAOIManager; + + constructor() { + this.aoi = createGridAOI(100); + + this.aoi.addListener((event) => { + const packet = this.createSyncPacket(event); + this.sendToPlayer(event.observer, packet); + }); + } + + onPlayerMove(player: Player, newPosition: IVector2): void { + this.aoi.updatePosition(player, newPosition); + } +} +``` + +## Blueprint Nodes + +### Spatial Query Nodes +- `FindInRadius`, `FindInRect`, `FindNearest`, `FindKNearest` +- `Raycast`, `RaycastFirst` + +### AOI Nodes +- `GetEntitiesInView`, `GetObserversOf`, `CanSee` +- `OnEntityEnterView`, `OnEntityExitView` + +## Service Tokens + +```typescript +import { SpatialIndexToken, AOIManagerToken } from '@esengine/spatial'; + +services.register(SpatialIndexToken, createGridSpatialIndex(100)); +services.register(AOIManagerToken, createGridAOI(100)); +``` diff --git a/docs/en/modules/timer/index.md b/docs/en/modules/timer/index.md new file mode 100644 index 00000000..8a80ae53 --- /dev/null +++ b/docs/en/modules/timer/index.md @@ -0,0 +1,352 @@ +# Timer System + +`@esengine/timer` provides a flexible timer and cooldown system for delayed execution, repeating tasks, skill cooldowns, and more. + +## Installation + +```bash +npm install @esengine/timer +``` + +## Quick Start + +```typescript +import { createTimerService } from '@esengine/timer'; + +// Create timer service +const timerService = createTimerService(); + +// One-time timer (executes after 1 second) +const handle = timerService.schedule('myTimer', 1000, () => { + console.log('Timer fired!'); +}); + +// Repeating timer (every 100ms) +timerService.scheduleRepeating('heartbeat', 100, () => { + console.log('Tick'); +}); + +// Cooldown system (5 second cooldown) +timerService.startCooldown('skill_fireball', 5000); + +if (timerService.isCooldownReady('skill_fireball')) { + useFireball(); + timerService.startCooldown('skill_fireball', 5000); +} + +// Update in game loop +function gameLoop(deltaTime: number) { + timerService.update(deltaTime); +} +``` + +## Core Concepts + +### Timer vs Cooldown + +| Feature | Timer | Cooldown | +|---------|-------|----------| +| Purpose | Delayed code execution | Rate limiting | +| Callback | Has callback function | No callback | +| Repeat | Supports repeating | One-time | +| Query | Query remaining time | Query progress/ready status | + +### TimerHandle + +Handle object returned when scheduling a timer: + +```typescript +interface TimerHandle { + readonly id: string; // Timer ID + readonly isValid: boolean; // Whether valid (not cancelled) + cancel(): void; // Cancel timer +} +``` + +### TimerInfo + +Timer information object: + +```typescript +interface TimerInfo { + readonly id: string; // Timer ID + readonly remaining: number; // Remaining time (ms) + readonly repeating: boolean; // Whether repeating + readonly interval?: number; // Interval (repeating only) +} +``` + +### CooldownInfo + +Cooldown information object: + +```typescript +interface CooldownInfo { + readonly id: string; // Cooldown ID + readonly duration: number; // Total duration (ms) + readonly remaining: number; // Remaining time (ms) + readonly progress: number; // Progress (0-1, 0=started, 1=finished) + readonly isReady: boolean; // Whether ready +} +``` + +## API Reference + +### createTimerService + +```typescript +function createTimerService(config?: TimerServiceConfig): ITimerService +``` + +**Configuration:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `maxTimers` | `number` | `0` | Maximum timer count (0 = unlimited) | +| `maxCooldowns` | `number` | `0` | Maximum cooldown count (0 = unlimited) | + +### Timer API + +#### schedule + +Schedule a one-time timer: + +```typescript +const handle = timerService.schedule('explosion', 2000, () => { + createExplosion(); +}); + +// Cancel early +handle.cancel(); +``` + +#### scheduleRepeating + +Schedule a repeating timer: + +```typescript +// Execute every second +timerService.scheduleRepeating('regen', 1000, () => { + player.hp += 5; +}); + +// Execute immediately once, then repeat every second +timerService.scheduleRepeating('tick', 1000, () => { + console.log('Tick'); +}, true); // immediate = true +``` + +#### cancel / cancelById + +Cancel timers: + +```typescript +// Cancel by handle +handle.cancel(); +// or +timerService.cancel(handle); + +// Cancel by ID +timerService.cancelById('regen'); +``` + +#### hasTimer + +Check if timer exists: + +```typescript +if (timerService.hasTimer('explosion')) { + console.log('Explosion is pending'); +} +``` + +#### getTimerInfo + +Get timer information: + +```typescript +const info = timerService.getTimerInfo('explosion'); +if (info) { + console.log(`Remaining: ${info.remaining}ms`); + console.log(`Repeating: ${info.repeating}`); +} +``` + +### Cooldown API + +#### startCooldown + +Start a cooldown: + +```typescript +timerService.startCooldown('skill_fireball', 5000); +``` + +#### isCooldownReady / isOnCooldown + +Check cooldown status: + +```typescript +if (timerService.isCooldownReady('skill_fireball')) { + castFireball(); + timerService.startCooldown('skill_fireball', 5000); +} + +if (timerService.isOnCooldown('skill_fireball')) { + console.log('On cooldown...'); +} +``` + +#### getCooldownProgress / getCooldownRemaining + +Get cooldown progress: + +```typescript +// Progress 0-1 (0=started, 1=complete) +const progress = timerService.getCooldownProgress('skill_fireball'); +console.log(`Progress: ${(progress * 100).toFixed(0)}%`); + +// Remaining time (ms) +const remaining = timerService.getCooldownRemaining('skill_fireball'); +console.log(`Remaining: ${(remaining / 1000).toFixed(1)}s`); +``` + +#### getCooldownInfo + +Get complete cooldown info: + +```typescript +const info = timerService.getCooldownInfo('skill_fireball'); +if (info) { + console.log(`Duration: ${info.duration}ms`); + console.log(`Remaining: ${info.remaining}ms`); + console.log(`Progress: ${info.progress}`); + console.log(`Ready: ${info.isReady}`); +} +``` + +#### resetCooldown / clearAllCooldowns + +Reset cooldowns: + +```typescript +// Reset single cooldown +timerService.resetCooldown('skill_fireball'); + +// Clear all cooldowns (e.g., on respawn) +timerService.clearAllCooldowns(); +``` + +### Lifecycle + +#### update + +Update timer service (call every frame): + +```typescript +function gameLoop(deltaTime: number) { + timerService.update(deltaTime); // deltaTime in ms +} +``` + +#### clear + +Clear all timers and cooldowns: + +```typescript +timerService.clear(); +``` + +### Debug Properties + +```typescript +console.log(timerService.activeTimerCount); +console.log(timerService.activeCooldownCount); +const timerIds = timerService.getActiveTimerIds(); +const cooldownIds = timerService.getActiveCooldownIds(); +``` + +## Practical Examples + +### Skill Cooldown System + +```typescript +import { createTimerService, type ITimerService } from '@esengine/timer'; + +class SkillSystem { + private timerService: ITimerService; + private skills: Map = new Map(); + + constructor() { + this.timerService = createTimerService(); + } + + useSkill(skillId: string): boolean { + const skill = this.skills.get(skillId); + if (!skill) return false; + + if (!this.timerService.isCooldownReady(skillId)) { + const remaining = this.timerService.getCooldownRemaining(skillId); + console.log(`Skill ${skillId} on cooldown, ${remaining}ms remaining`); + return false; + } + + this.executeSkill(skill); + this.timerService.startCooldown(skillId, skill.cooldown); + return true; + } + + update(dt: number): void { + this.timerService.update(dt); + } +} +``` + +### DOT Effects + +```typescript +class EffectSystem { + private timerService: ITimerService; + + applyDOT(target: Entity, damage: number, duration: number): void { + const dotId = `dot_${target.id}_${Date.now()}`; + let elapsed = 0; + + this.timerService.scheduleRepeating(dotId, 1000, () => { + elapsed += 1000; + target.takeDamage(damage); + + if (elapsed >= duration) { + this.timerService.cancelById(dotId); + } + }); + } +} +``` + +## Blueprint Nodes + +### Cooldown Nodes + +- `StartCooldown` - Start cooldown +- `IsCooldownReady` - Check if cooldown is ready +- `GetCooldownProgress` - Get cooldown progress +- `GetCooldownInfo` - Get cooldown info +- `ResetCooldown` - Reset cooldown + +### Timer Nodes + +- `HasTimer` - Check if timer exists +- `CancelTimer` - Cancel timer +- `GetTimerRemaining` - Get timer remaining time + +## Service Token + +For dependency injection: + +```typescript +import { TimerServiceToken, createTimerService } from '@esengine/timer'; + +services.register(TimerServiceToken, createTimerService()); +const timerService = services.get(TimerServiceToken); +``` diff --git a/docs/modules/blueprint/index.md b/docs/modules/blueprint/index.md new file mode 100644 index 00000000..6c98c11a --- /dev/null +++ b/docs/modules/blueprint/index.md @@ -0,0 +1,507 @@ +# 蓝图可视化脚本 (Blueprint) + +`@esengine/blueprint` 提供了一个功能完整的可视化脚本系统,支持节点式编程、事件驱动和蓝图组合。 + +## 安装 + +```bash +npm install @esengine/blueprint +``` + +## 快速开始 + +```typescript +import { + createBlueprintSystem, + createBlueprintComponentData, + NodeRegistry, + RegisterNode +} from '@esengine/blueprint'; + +// 创建蓝图系统 +const blueprintSystem = createBlueprintSystem(scene); + +// 加载蓝图资产 +const blueprint = await loadBlueprintAsset('player.bp'); + +// 创建蓝图组件数据 +const componentData = createBlueprintComponentData(); +componentData.blueprintAsset = blueprint; + +// 在游戏循环中更新 +function gameLoop(dt: number) { + blueprintSystem.process(entities, dt); +} +``` + +## 核心概念 + +### 蓝图资产结构 + +蓝图保存为 `.bp` 文件,包含以下结构: + +```typescript +interface BlueprintAsset { + version: number; // 格式版本 + type: 'blueprint'; // 资产类型 + metadata: BlueprintMetadata; // 元数据 + variables: BlueprintVariable[]; // 变量定义 + nodes: BlueprintNode[]; // 节点实例 + connections: BlueprintConnection[]; // 连接 +} +``` + +### 节点类型 + +节点按功能分为以下类别: + +| 类别 | 说明 | 颜色 | +|------|------|------| +| `event` | 事件节点(入口点) | 红色 | +| `flow` | 流程控制 | 灰色 | +| `entity` | 实体操作 | 蓝色 | +| `component` | 组件访问 | 青色 | +| `math` | 数学运算 | 绿色 | +| `logic` | 逻辑运算 | 红色 | +| `variable` | 变量访问 | 紫色 | +| `time` | 时间工具 | 青色 | +| `debug` | 调试工具 | 灰色 | + +### 引脚类型 + +节点通过引脚连接: + +```typescript +interface BlueprintPinDefinition { + name: string; // 引脚名称 + type: PinDataType; // 数据类型 + direction: 'input' | 'output'; + isExec?: boolean; // 是否是执行引脚 + defaultValue?: unknown; +} + +// 支持的数据类型 +type PinDataType = + | 'exec' // 执行流 + | 'boolean' // 布尔值 + | 'number' // 数字 + | 'string' // 字符串 + | 'vector2' // 2D 向量 + | 'vector3' // 3D 向量 + | 'entity' // 实体引用 + | 'component' // 组件引用 + | 'any'; // 任意类型 +``` + +### 变量作用域 + +```typescript +type VariableScope = + | 'local' // 每次执行独立 + | 'instance' // 每个实体独立 + | 'global'; // 全局共享 +``` + +## 虚拟机 API + +### BlueprintVM + +蓝图虚拟机负责执行蓝图图: + +```typescript +import { BlueprintVM } from '@esengine/blueprint'; + +// 创建 VM +const vm = new BlueprintVM(blueprintAsset, entity, scene); + +// 启动(触发 BeginPlay) +vm.start(); + +// 每帧更新(触发 Tick) +vm.tick(deltaTime); + +// 停止(触发 EndPlay) +vm.stop(); + +// 暂停/恢复 +vm.pause(); +vm.resume(); + +// 触发事件 +vm.triggerEvent('EventCollision', { other: otherEntity }); +vm.triggerCustomEvent('OnDamage', { amount: 50 }); + +// 调试模式 +vm.debug = true; +``` + +### 执行上下文 + +```typescript +interface ExecutionContext { + blueprint: BlueprintAsset; // 蓝图资产 + entity: Entity; // 当前实体 + scene: IScene; // 当前场景 + deltaTime: number; // 帧间隔时间 + time: number; // 总运行时间 + + // 获取输入值 + getInput(nodeId: string, pinName: string): T; + + // 设置输出值 + setOutput(nodeId: string, pinName: string, value: unknown): void; + + // 变量访问 + getVariable(name: string): T; + setVariable(name: string, value: unknown): void; +} +``` + +### 执行结果 + +```typescript +interface ExecutionResult { + outputs?: Record; // 输出值 + nextExec?: string | null; // 下一个执行引脚 + delay?: number; // 延迟执行(毫秒) + yield?: boolean; // 暂停到下一帧 + error?: string; // 错误信息 +} +``` + +## 自定义节点 + +### 定义节点模板 + +```typescript +import { BlueprintNodeTemplate } from '@esengine/blueprint'; + +const MyNodeTemplate: BlueprintNodeTemplate = { + type: 'MyCustomNode', + title: 'My Custom Node', + category: 'custom', + description: 'A custom node example', + keywords: ['custom', 'example'], + inputs: [ + { name: 'exec', type: 'exec', direction: 'input', isExec: true }, + { name: 'value', type: 'number', direction: 'input', defaultValue: 0 } + ], + outputs: [ + { name: 'exec', type: 'exec', direction: 'output', isExec: true }, + { name: 'result', type: 'number', direction: 'output' } + ] +}; +``` + +### 实现节点执行器 + +```typescript +import { INodeExecutor, RegisterNode } from '@esengine/blueprint'; + +@RegisterNode(MyNodeTemplate) +class MyNodeExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + // 获取输入 + const value = context.getInput(node.id, 'value'); + + // 执行逻辑 + const result = value * 2; + + // 返回结果 + return { + outputs: { result }, + nextExec: 'exec' // 继续执行 + }; + } +} +``` + +### 使用装饰器注册 + +```typescript +// 方式 1: 使用装饰器 +@RegisterNode(MyNodeTemplate) +class MyNodeExecutor implements INodeExecutor { ... } + +// 方式 2: 手动注册 +NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor()); +``` + +## 节点注册表 + +```typescript +import { NodeRegistry } from '@esengine/blueprint'; + +// 获取单例 +const registry = NodeRegistry.instance; + +// 获取所有模板 +const allTemplates = registry.getAllTemplates(); + +// 按类别获取 +const mathNodes = registry.getTemplatesByCategory('math'); + +// 搜索节点 +const results = registry.searchTemplates('add'); + +// 检查是否存在 +if (registry.has('MyCustomNode')) { ... } +``` + +## 内置节点 + +### 事件节点 + +| 节点 | 说明 | +|------|------| +| `EventBeginPlay` | 蓝图启动时触发 | +| `EventTick` | 每帧触发 | +| `EventEndPlay` | 蓝图停止时触发 | +| `EventCollision` | 碰撞时触发 | +| `EventInput` | 输入事件触发 | +| `EventTimer` | 定时器触发 | +| `EventMessage` | 自定义消息触发 | + +### 时间节点 + +| 节点 | 说明 | +|------|------| +| `Delay` | 延迟执行 | +| `GetDeltaTime` | 获取帧间隔 | +| `GetTime` | 获取运行时间 | + +### 数学节点 + +| 节点 | 说明 | +|------|------| +| `Add` | 加法 | +| `Subtract` | 减法 | +| `Multiply` | 乘法 | +| `Divide` | 除法 | +| `Abs` | 绝对值 | +| `Clamp` | 限制范围 | +| `Lerp` | 线性插值 | +| `Min` / `Max` | 最小/最大值 | + +### 调试节点 + +| 节点 | 说明 | +|------|------| +| `Print` | 打印到控制台 | + +## 蓝图组合 + +### 蓝图片段 + +将可复用的逻辑封装为片段: + +```typescript +import { createFragment } from '@esengine/blueprint'; + +const healthFragment = createFragment('HealthSystem', { + inputs: [ + { name: 'damage', type: 'number', internalNodeId: 'input1', internalPinName: 'value' } + ], + outputs: [ + { name: 'isDead', type: 'boolean', internalNodeId: 'output1', internalPinName: 'value' } + ], + graph: { + nodes: [...], + connections: [...], + variables: [...] + } +}); +``` + +### 组合蓝图 + +```typescript +import { createComposer, FragmentRegistry } from '@esengine/blueprint'; + +// 注册片段 +FragmentRegistry.instance.register('health', healthFragment); +FragmentRegistry.instance.register('movement', movementFragment); + +// 创建组合器 +const composer = createComposer('PlayerBlueprint'); + +// 添加片段到槽位 +composer.addFragment(healthFragment, 'slot1', { position: { x: 0, y: 0 } }); +composer.addFragment(movementFragment, 'slot2', { position: { x: 400, y: 0 } }); + +// 连接槽位 +composer.connect('slot1', 'onDeath', 'slot2', 'disable'); + +// 验证 +const validation = composer.validate(); +if (!validation.isValid) { + console.error(validation.errors); +} + +// 编译成蓝图 +const blueprint = composer.compile(); +``` + +## 触发器系统 + +### 定义触发条件 + +```typescript +import { TriggerCondition, TriggerDispatcher } from '@esengine/blueprint'; + +const lowHealthCondition: TriggerCondition = { + type: 'comparison', + left: { type: 'variable', name: 'health' }, + operator: '<', + right: { type: 'constant', value: 20 } +}; +``` + +### 使用触发器分发器 + +```typescript +const dispatcher = new TriggerDispatcher(); + +// 注册触发器 +dispatcher.register('lowHealth', lowHealthCondition, (context) => { + context.triggerEvent('OnLowHealth'); +}); + +// 每帧评估 +dispatcher.evaluate(context); +``` + +## 与 ECS 集成 + +### 使用蓝图系统 + +```typescript +import { createBlueprintSystem } from '@esengine/blueprint'; + +class GameScene { + private blueprintSystem: BlueprintSystem; + + initialize() { + this.blueprintSystem = createBlueprintSystem(this.scene); + } + + update(dt: number) { + // 处理所有带蓝图组件的实体 + this.blueprintSystem.process(this.entities, dt); + } +} +``` + +### 触发蓝图事件 + +```typescript +import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint'; + +// 触发内置事件 +triggerBlueprintEvent(entity, 'Collision', { other: otherEntity }); + +// 触发自定义事件 +triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity }); +``` + +## 实际示例 + +### 玩家控制蓝图 + +```typescript +// 定义输入处理节点 +const InputMoveTemplate: BlueprintNodeTemplate = { + type: 'InputMove', + title: 'Get Movement Input', + category: 'input', + inputs: [], + outputs: [ + { name: 'direction', type: 'vector2', direction: 'output' } + ], + isPure: true +}; + +@RegisterNode(InputMoveTemplate) +class InputMoveExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const input = context.scene.services.get(InputServiceToken); + const direction = { + x: input.getAxis('horizontal'), + y: input.getAxis('vertical') + }; + return { outputs: { direction } }; + } +} +``` + +### 状态切换逻辑 + +```typescript +// 在蓝图中实现状态机逻辑 +const stateBlueprint = createEmptyBlueprint('PlayerState'); + +// 添加状态变量 +stateBlueprint.variables.push({ + name: 'currentState', + type: 'string', + defaultValue: 'idle', + scope: 'instance' +}); + +// 在 Tick 事件中检查状态转换 +// ... 通过节点连接实现 +``` + +## 序列化 + +### 保存蓝图 + +```typescript +import { validateBlueprintAsset } from '@esengine/blueprint'; + +function saveBlueprint(blueprint: BlueprintAsset, path: string): void { + if (!validateBlueprintAsset(blueprint)) { + throw new Error('Invalid blueprint structure'); + } + const json = JSON.stringify(blueprint, null, 2); + fs.writeFileSync(path, json); +} +``` + +### 加载蓝图 + +```typescript +async function loadBlueprint(path: string): Promise { + const json = await fs.readFile(path, 'utf-8'); + const asset = JSON.parse(json); + + if (!validateBlueprintAsset(asset)) { + throw new Error('Invalid blueprint file'); + } + + return asset; +} +``` + +## 最佳实践 + +1. **使用片段复用逻辑** + - 将通用逻辑封装为片段 + - 通过组合器构建复杂蓝图 + +2. **合理使用变量作用域** + - `local`: 临时计算结果 + - `instance`: 实体状态(如生命值) + - `global`: 游戏全局状态 + +3. **避免无限循环** + - VM 有每帧最大执行步数限制(默认 1000) + - 使用 Delay 节点打断长执行链 + +4. **调试技巧** + - 启用 `vm.debug = true` 查看执行日志 + - 使用 Print 节点输出中间值 + +5. **性能优化** + - 纯节点(`isPure: true`)的输出会被缓存 + - 避免在 Tick 中执行重计算 diff --git a/docs/modules/fsm/index.md b/docs/modules/fsm/index.md new file mode 100644 index 00000000..29ddd7f5 --- /dev/null +++ b/docs/modules/fsm/index.md @@ -0,0 +1,337 @@ +# 状态机 (FSM) + +`@esengine/fsm` 提供了一个类型安全的有限状态机实现,用于角色、AI 或任何需要状态管理的场景。 + +## 安装 + +```bash +npm install @esengine/fsm +``` + +## 快速开始 + +```typescript +import { createStateMachine } from '@esengine/fsm'; + +// 定义状态类型 +type PlayerState = 'idle' | 'walk' | 'run' | 'jump'; + +// 创建状态机 +const fsm = createStateMachine('idle'); + +// 定义状态和回调 +fsm.defineState('idle', { + onEnter: (ctx, from) => console.log(`从 ${from} 进入 idle`), + onExit: (ctx, to) => console.log(`从 idle 退出到 ${to}`), + onUpdate: (ctx, dt) => { /* 每帧更新 */ } +}); + +fsm.defineState('walk', { + onEnter: () => console.log('开始行走') +}); + +// 手动切换状态 +fsm.transition('walk'); + +console.log(fsm.current); // 'walk' +``` + +## 核心概念 + +### 状态配置 + +每个状态可以配置以下回调: + +```typescript +interface StateConfig { + name: TState; // 状态名称 + onEnter?: (context: TContext, from: TState | null) => void; // 进入回调 + onExit?: (context: TContext, to: TState) => void; // 退出回调 + onUpdate?: (context: TContext, deltaTime: number) => void; // 更新回调 + tags?: string[]; // 状态标签 + metadata?: Record; // 元数据 +} +``` + +### 转换条件 + +可以定义带条件的状态转换: + +```typescript +interface Context { + isMoving: boolean; + isRunning: boolean; + isGrounded: boolean; +} + +const fsm = createStateMachine('idle', { + context: { isMoving: false, isRunning: false, isGrounded: true } +}); + +// 定义转换条件 +fsm.defineTransition('idle', 'walk', (ctx) => ctx.isMoving); +fsm.defineTransition('walk', 'run', (ctx) => ctx.isRunning); +fsm.defineTransition('walk', 'idle', (ctx) => !ctx.isMoving); + +// 自动评估并执行满足条件的转换 +fsm.evaluateTransitions(); +``` + +### 转换优先级 + +当多个转换条件同时满足时,优先级高的先执行: + +```typescript +// 优先级数字越大越优先 +fsm.defineTransition('idle', 'attack', (ctx) => ctx.isAttacking, 10); +fsm.defineTransition('idle', 'walk', (ctx) => ctx.isMoving, 1); + +// 如果同时满足,会先尝试 attack(优先级 10) +``` + +## API 参考 + +### createStateMachine + +```typescript +function createStateMachine( + initialState: TState, + options?: StateMachineOptions +): IStateMachine +``` + +**参数:** +- `initialState` - 初始状态 +- `options.context` - 上下文对象,在回调中可访问 +- `options.maxHistorySize` - 最大历史记录数(默认 100) +- `options.enableHistory` - 是否启用历史记录(默认 true) + +### 状态机属性 + +| 属性 | 类型 | 描述 | +|------|------|------| +| `current` | `TState` | 当前状态 | +| `previous` | `TState \| null` | 上一个状态 | +| `context` | `TContext` | 上下文对象 | +| `isTransitioning` | `boolean` | 是否正在转换中 | +| `currentStateDuration` | `number` | 当前状态持续时间(毫秒) | + +### 状态机方法 + +#### 状态定义 + +```typescript +// 定义状态 +fsm.defineState('idle', { + onEnter: (ctx, from) => {}, + onExit: (ctx, to) => {}, + onUpdate: (ctx, dt) => {} +}); + +// 检查状态是否存在 +fsm.hasState('idle'); // true + +// 获取状态配置 +fsm.getStateConfig('idle'); + +// 获取所有状态 +fsm.getStates(); // ['idle', 'walk', ...] +``` + +#### 转换操作 + +```typescript +// 定义转换 +fsm.defineTransition('idle', 'walk', condition, priority); + +// 移除转换 +fsm.removeTransition('idle', 'walk'); + +// 获取从某状态出发的所有转换 +fsm.getTransitionsFrom('idle'); + +// 检查是否可以转换 +fsm.canTransition('walk'); // true/false + +// 手动转换 +fsm.transition('walk'); + +// 强制转换(忽略条件) +fsm.transition('walk', true); + +// 自动评估转换条件 +fsm.evaluateTransitions(); +``` + +#### 生命周期 + +```typescript +// 更新状态机(调用当前状态的 onUpdate) +fsm.update(deltaTime); + +// 重置状态机 +fsm.reset(); // 重置到当前状态 +fsm.reset('idle'); // 重置到指定状态 +``` + +#### 事件监听 + +```typescript +// 监听进入特定状态 +const unsubscribe = fsm.onEnter('walk', (from) => { + console.log(`从 ${from} 进入 walk`); +}); + +// 监听退出特定状态 +fsm.onExit('walk', (to) => { + console.log(`从 walk 退出到 ${to}`); +}); + +// 监听任意状态变化 +fsm.onChange((event) => { + console.log(`${event.from} -> ${event.to} at ${event.timestamp}`); +}); + +// 取消订阅 +unsubscribe(); +``` + +#### 调试 + +```typescript +// 获取状态历史 +const history = fsm.getHistory(); +// [{ from: 'idle', to: 'walk', timestamp: 1234567890 }, ...] + +// 清除历史 +fsm.clearHistory(); + +// 获取调试信息 +const info = fsm.getDebugInfo(); +// { current, previous, duration, stateCount, transitionCount, historySize } +``` + +## 实际示例 + +### 角色状态机 + +```typescript +import { createStateMachine } from '@esengine/fsm'; + +type CharacterState = 'idle' | 'walk' | 'run' | 'jump' | 'fall' | 'attack'; + +interface CharacterContext { + velocity: { x: number; y: number }; + isGrounded: boolean; + isAttacking: boolean; + speed: number; +} + +const characterFSM = createStateMachine('idle', { + context: { + velocity: { x: 0, y: 0 }, + isGrounded: true, + isAttacking: false, + speed: 0 + } +}); + +// 定义状态 +characterFSM.defineState('idle', { + onEnter: (ctx) => { + ctx.speed = 0; + }, + onUpdate: (ctx, dt) => { + // 播放待机动画 + } +}); + +characterFSM.defineState('walk', { + onEnter: (ctx) => { + ctx.speed = 100; + } +}); + +characterFSM.defineState('run', { + onEnter: (ctx) => { + ctx.speed = 200; + } +}); + +characterFSM.defineState('jump', { + onEnter: (ctx) => { + ctx.velocity.y = -300; + ctx.isGrounded = false; + } +}); + +// 定义转换 +characterFSM.defineTransition('idle', 'walk', (ctx) => Math.abs(ctx.velocity.x) > 0); +characterFSM.defineTransition('walk', 'idle', (ctx) => ctx.velocity.x === 0); +characterFSM.defineTransition('walk', 'run', (ctx) => Math.abs(ctx.velocity.x) > 150); +characterFSM.defineTransition('run', 'walk', (ctx) => Math.abs(ctx.velocity.x) <= 150); + +// 跳跃有最高优先级 +characterFSM.defineTransition('idle', 'jump', (ctx) => !ctx.isGrounded, 10); +characterFSM.defineTransition('walk', 'jump', (ctx) => !ctx.isGrounded, 10); +characterFSM.defineTransition('run', 'jump', (ctx) => !ctx.isGrounded, 10); + +characterFSM.defineTransition('jump', 'fall', (ctx) => ctx.velocity.y > 0); +characterFSM.defineTransition('fall', 'idle', (ctx) => ctx.isGrounded); + +// 游戏循环中使用 +function gameUpdate(dt: number) { + // 更新上下文 + characterFSM.context.velocity.x = getInputVelocity(); + characterFSM.context.isGrounded = checkGrounded(); + + // 评估状态转换 + characterFSM.evaluateTransitions(); + + // 更新当前状态 + characterFSM.update(dt); +} +``` + +### 与 ECS 集成 + +```typescript +import { Component, EntitySystem, Matcher } from '@esengine/ecs-framework'; +import { createStateMachine, type IStateMachine } from '@esengine/fsm'; + +// 状态机组件 +class FSMComponent extends Component { + fsm: IStateMachine; + + constructor(initialState: string) { + super(); + this.fsm = createStateMachine(initialState); + } +} + +// 状态机系统 +class FSMSystem extends EntitySystem { + constructor() { + super(Matcher.all(FSMComponent)); + } + + protected processEntity(entity: Entity, dt: number): void { + const fsmComp = entity.getComponent(FSMComponent); + fsmComp.fsm.evaluateTransitions(); + fsmComp.fsm.update(dt); + } +} +``` + +## 蓝图节点 + +FSM 模块提供了可视化脚本支持的蓝图节点: + +- `GetCurrentState` - 获取当前状态 +- `TransitionTo` - 转换到指定状态 +- `CanTransition` - 检查是否可以转换 +- `IsInState` - 检查是否在指定状态 +- `WasInState` - 检查是否曾在指定状态 +- `GetStateDuration` - 获取状态持续时间 +- `EvaluateTransitions` - 评估转换条件 +- `ResetStateMachine` - 重置状态机 diff --git a/docs/modules/pathfinding/index.md b/docs/modules/pathfinding/index.md new file mode 100644 index 00000000..088f4d8b --- /dev/null +++ b/docs/modules/pathfinding/index.md @@ -0,0 +1,502 @@ +# 寻路系统 (Pathfinding) + +`@esengine/pathfinding` 提供了完整的 2D 寻路解决方案,包括 A* 算法、网格地图、导航网格和路径平滑。 + +## 安装 + +```bash +npm install @esengine/pathfinding +``` + +## 快速开始 + +### 网格地图寻路 + +```typescript +import { createGridMap, createAStarPathfinder } from '@esengine/pathfinding'; + +// 创建 20x20 的网格地图 +const grid = createGridMap(20, 20); + +// 设置障碍物 +grid.setWalkable(5, 5, false); +grid.setWalkable(5, 6, false); +grid.setWalkable(5, 7, false); + +// 创建寻路器 +const pathfinder = createAStarPathfinder(grid); + +// 查找路径 +const result = pathfinder.findPath(0, 0, 15, 15); + +if (result.found) { + console.log('找到路径!'); + console.log('路径点:', result.path); + console.log('总代价:', result.cost); + console.log('搜索节点数:', result.nodesSearched); +} +``` + +### 导航网格寻路 + +```typescript +import { createNavMesh } from '@esengine/pathfinding'; + +// 创建导航网格 +const navmesh = createNavMesh(); + +// 添加多边形区域 +navmesh.addPolygon([ + { x: 0, y: 0 }, { x: 10, y: 0 }, + { x: 10, y: 10 }, { x: 0, y: 10 } +]); + +navmesh.addPolygon([ + { x: 10, y: 0 }, { x: 20, y: 0 }, + { x: 20, y: 10 }, { x: 10, y: 10 } +]); + +// 自动建立连接 +navmesh.build(); + +// 寻路 +const result = navmesh.findPath(1, 1, 18, 8); +``` + +## 核心概念 + +### IPoint - 坐标点 + +```typescript +interface IPoint { + readonly x: number; + readonly y: number; +} +``` + +### IPathResult - 寻路结果 + +```typescript +interface IPathResult { + readonly found: boolean; // 是否找到路径 + readonly path: readonly IPoint[]; // 路径点列表 + readonly cost: number; // 路径总代价 + readonly nodesSearched: number; // 搜索的节点数 +} +``` + +### IPathfindingOptions - 寻路配置 + +```typescript +interface IPathfindingOptions { + maxNodes?: number; // 最大搜索节点数(默认 10000) + heuristicWeight?: number; // 启发式权重(>1 更快但可能非最优) + allowDiagonal?: boolean; // 是否允许对角移动(默认 true) + avoidCorners?: boolean; // 是否避免穿角(默认 true) +} +``` + +## 启发式函数 + +模块提供了四种启发式函数: + +| 函数 | 适用场景 | 说明 | +|------|----------|------| +| `manhattanDistance` | 4方向移动 | 曼哈顿距离,只考虑水平/垂直 | +| `euclideanDistance` | 任意方向 | 欧几里得距离,直线距离 | +| `chebyshevDistance` | 8方向移动 | 切比雪夫距离,对角线代价为 1 | +| `octileDistance` | 8方向移动 | 八角距离,对角线代价为 √2(默认) | + +```typescript +import { manhattanDistance, octileDistance } from '@esengine/pathfinding'; + +// 自定义启发式 +const grid = createGridMap(20, 20, { + heuristic: manhattanDistance // 使用曼哈顿距离 +}); +``` + +## 网格地图 API + +### createGridMap + +```typescript +function createGridMap( + width: number, + height: number, + options?: IGridMapOptions +): GridMap +``` + +**配置选项:** + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| `allowDiagonal` | `boolean` | `true` | 允许对角移动 | +| `diagonalCost` | `number` | `√2` | 对角移动代价 | +| `avoidCorners` | `boolean` | `true` | 避免穿角 | +| `heuristic` | `HeuristicFunction` | `octileDistance` | 启发式函数 | + +### 地图操作 + +```typescript +// 检查/设置可通行性 +grid.isWalkable(x, y); +grid.setWalkable(x, y, false); + +// 设置移动代价(如沼泽、沙地) +grid.setCost(x, y, 2); // 代价为 2(默认 1) + +// 设置矩形区域 +grid.setRectWalkable(0, 0, 5, 5, false); + +// 从数组加载(0=可通行,非0=障碍) +grid.loadFromArray([ + [0, 0, 0, 1, 0], + [0, 1, 0, 1, 0], + [0, 1, 0, 0, 0] +]); + +// 从字符串加载(.=可通行,#=障碍) +grid.loadFromString(` +..... +.#.#. +.#... +`); + +// 导出为字符串 +console.log(grid.toString()); + +// 重置所有节点为可通行 +grid.reset(); +``` + +### 方向常量 + +```typescript +import { DIRECTIONS_4, DIRECTIONS_8 } from '@esengine/pathfinding'; + +// 4方向(上下左右) +DIRECTIONS_4 // [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, ...] + +// 8方向(含对角线) +DIRECTIONS_8 // [{ dx: 0, dy: -1 }, { dx: 1, dy: -1 }, ...] +``` + +## A* 寻路器 API + +### createAStarPathfinder + +```typescript +function createAStarPathfinder(map: IPathfindingMap): AStarPathfinder +``` + +### findPath + +```typescript +const result = pathfinder.findPath( + startX, startY, + endX, endY, + { + maxNodes: 5000, // 限制搜索节点数 + heuristicWeight: 1.5 // 加速但可能非最优 + } +); +``` + +### 重用寻路器 + +```typescript +// 寻路器可重用,内部会自动清理状态 +pathfinder.findPath(0, 0, 10, 10); +pathfinder.findPath(5, 5, 15, 15); + +// 手动清理(可选) +pathfinder.clear(); +``` + +## 导航网格 API + +### createNavMesh + +```typescript +function createNavMesh(): NavMesh +``` + +### 构建导航网格 + +```typescript +const navmesh = createNavMesh(); + +// 添加凸多边形 +const id1 = navmesh.addPolygon([ + { x: 0, y: 0 }, { x: 10, y: 0 }, + { x: 10, y: 10 }, { x: 0, y: 10 } +]); + +const id2 = navmesh.addPolygon([ + { x: 10, y: 0 }, { x: 20, y: 0 }, + { x: 20, y: 10 }, { x: 10, y: 10 } +]); + +// 方式1:自动检测共享边并建立连接 +navmesh.build(); + +// 方式2:手动设置连接 +navmesh.setConnection(id1, id2, { + left: { x: 10, y: 0 }, + right: { x: 10, y: 10 } +}); +``` + +### 查询和寻路 + +```typescript +// 查找包含点的多边形 +const polygon = navmesh.findPolygonAt(5, 5); + +// 检查位置是否可通行 +navmesh.isWalkable(5, 5); + +// 寻路(内部使用漏斗算法优化路径) +const result = navmesh.findPath(1, 1, 18, 8); +``` + +## 路径平滑 API + +### 视线简化 + +移除不必要的中间点: + +```typescript +import { createLineOfSightSmoother } from '@esengine/pathfinding'; + +const smoother = createLineOfSightSmoother(); +const smoothedPath = smoother.smooth(result.path, grid); + +// 原路径: [(0,0), (1,1), (2,2), (3,3), (4,4)] +// 简化后: [(0,0), (4,4)] +``` + +### 曲线平滑 + +使用 Catmull-Rom 样条曲线: + +```typescript +import { createCatmullRomSmoother } from '@esengine/pathfinding'; + +const smoother = createCatmullRomSmoother( + 5, // segments - 每段插值点数 + 0.5 // tension - 张力 (0-1) +); + +const curvedPath = smoother.smooth(result.path, grid); +``` + +### 组合平滑 + +先简化再曲线平滑: + +```typescript +import { createCombinedSmoother } from '@esengine/pathfinding'; + +const smoother = createCombinedSmoother(5, 0.5); +const finalPath = smoother.smooth(result.path, grid); +``` + +### 视线检测函数 + +```typescript +import { bresenhamLineOfSight, raycastLineOfSight } from '@esengine/pathfinding'; + +// Bresenham 算法(快速,网格对齐) +const hasLOS = bresenhamLineOfSight(x1, y1, x2, y2, grid); + +// 射线投射(精确,支持浮点坐标) +const hasLOS = raycastLineOfSight(x1, y1, x2, y2, grid, 0.5); +``` + +## 实际示例 + +### 游戏角色移动 + +```typescript +class MovementSystem { + private grid: GridMap; + private pathfinder: AStarPathfinder; + private smoother: CombinedSmoother; + + constructor(width: number, height: number) { + this.grid = createGridMap(width, height); + this.pathfinder = createAStarPathfinder(this.grid); + this.smoother = createCombinedSmoother(); + } + + findPath(from: IPoint, to: IPoint): IPoint[] | null { + const result = this.pathfinder.findPath( + from.x, from.y, + to.x, to.y + ); + + if (!result.found) { + return null; + } + + // 平滑路径 + return this.smoother.smooth(result.path, this.grid); + } + + setObstacle(x: number, y: number): void { + this.grid.setWalkable(x, y, false); + } + + setTerrain(x: number, y: number, cost: number): void { + this.grid.setCost(x, y, cost); + } +} +``` + +### 动态障碍物 + +```typescript +class DynamicPathfinding { + private grid: GridMap; + private pathfinder: AStarPathfinder; + private dynamicObstacles: Set = new Set(); + + addDynamicObstacle(x: number, y: number): void { + const key = `${x},${y}`; + if (!this.dynamicObstacles.has(key)) { + this.dynamicObstacles.add(key); + this.grid.setWalkable(x, y, false); + } + } + + removeDynamicObstacle(x: number, y: number): void { + const key = `${x},${y}`; + if (this.dynamicObstacles.has(key)) { + this.dynamicObstacles.delete(key); + this.grid.setWalkable(x, y, true); + } + } + + findPath(from: IPoint, to: IPoint): IPathResult { + return this.pathfinder.findPath(from.x, from.y, to.x, to.y); + } +} +``` + +### 不同地形代价 + +```typescript +// 设置不同地形的移动代价 +const grid = createGridMap(50, 50); + +// 普通地面 - 代价 1(默认) +// 沙地 - 代价 2 +for (let y = 10; y < 20; y++) { + for (let x = 0; x < 50; x++) { + grid.setCost(x, y, 2); + } +} + +// 沼泽 - 代价 4 +for (let y = 30; y < 35; y++) { + for (let x = 20; x < 30; x++) { + grid.setCost(x, y, 4); + } +} + +// 寻路时会自动考虑地形代价 +const result = pathfinder.findPath(0, 0, 49, 49); +``` + +### 分层寻路 + +对于大型地图,使用层级化寻路: + +```typescript +class HierarchicalPathfinding { + private coarseGrid: GridMap; // 粗粒度网格 + private fineGrid: GridMap; // 细粒度网格 + private coarsePathfinder: AStarPathfinder; + private finePathfinder: AStarPathfinder; + private cellSize = 10; + + findPath(from: IPoint, to: IPoint): IPoint[] { + // 1. 在粗粒度网格上寻路 + const coarseFrom = this.toCoarse(from); + const coarseTo = this.toCoarse(to); + const coarseResult = this.coarsePathfinder.findPath( + coarseFrom.x, coarseFrom.y, + coarseTo.x, coarseTo.y + ); + + if (!coarseResult.found) { + return []; + } + + // 2. 在每个粗粒度单元内进行细粒度寻路 + const finePath: IPoint[] = []; + // ... 详细实现略 + return finePath; + } + + private toCoarse(p: IPoint): IPoint { + return { + x: Math.floor(p.x / this.cellSize), + y: Math.floor(p.y / this.cellSize) + }; + } +} +``` + +## 蓝图节点 + +Pathfinding 模块提供了可视化脚本支持的蓝图节点: + +- `FindPath` - 查找路径 +- `FindPathSmooth` - 查找并平滑路径 +- `IsWalkable` - 检查位置是否可通行 +- `GetPathLength` - 获取路径点数 +- `GetPathDistance` - 获取路径总距离 +- `GetPathPoint` - 获取路径上的指定点 +- `MoveAlongPath` - 沿路径移动 +- `HasLineOfSight` - 检查视线 + +## 性能优化 + +1. **限制搜索范围** + ```typescript + pathfinder.findPath(x1, y1, x2, y2, { maxNodes: 1000 }); + ``` + +2. **使用启发式权重** + ```typescript + // 权重 > 1 会更快但可能不是最优路径 + pathfinder.findPath(x1, y1, x2, y2, { heuristicWeight: 1.5 }); + ``` + +3. **复用寻路器实例** + ```typescript + // 创建一次,多次使用 + const pathfinder = createAStarPathfinder(grid); + ``` + +4. **使用导航网格** + - 对于复杂地形,NavMesh 比网格寻路更高效 + - 多边形数量远少于网格单元格数量 + +5. **选择合适的启发式** + - 4方向移动用 `manhattanDistance` + - 8方向移动用 `octileDistance`(默认) + +## 网格 vs 导航网格 + +| 特性 | GridMap | NavMesh | +|------|---------|---------| +| 适用场景 | 规则瓦片地图 | 复杂多边形地形 | +| 内存占用 | 较高 (width × height) | 较低 (多边形数) | +| 精度 | 网格对齐 | 连续坐标 | +| 动态修改 | 容易 | 需要重建 | +| 设置复杂度 | 简单 | 较复杂 | diff --git a/docs/modules/procgen/index.md b/docs/modules/procgen/index.md new file mode 100644 index 00000000..a77a737f --- /dev/null +++ b/docs/modules/procgen/index.md @@ -0,0 +1,557 @@ +# 程序化生成 (Procgen) + +`@esengine/procgen` 提供了程序化内容生成的核心工具,包括噪声函数、种子随机数和各种随机工具。 + +## 安装 + +```bash +npm install @esengine/procgen +``` + +## 快速开始 + +### 噪声生成 + +```typescript +import { createPerlinNoise, createFBM } from '@esengine/procgen'; + +// 创建 Perlin 噪声 +const perlin = createPerlinNoise(12345); // 种子 + +// 采样 2D 噪声 +const value = perlin.noise2D(x * 0.1, y * 0.1); +console.log(value); // [-1, 1] + +// 使用 FBM 获得更自然的效果 +const fbm = createFBM(perlin, { + octaves: 6, + persistence: 0.5 +}); + +const height = fbm.noise2D(x * 0.01, y * 0.01); +``` + +### 种子随机数 + +```typescript +import { createSeededRandom } from '@esengine/procgen'; + +// 创建确定性随机数生成器 +const rng = createSeededRandom(42); + +// 相同种子总是产生相同序列 +console.log(rng.next()); // 0.xxx +console.log(rng.nextInt(1, 100)); // 1-100 +console.log(rng.nextBool(0.3)); // 30% true +``` + +### 加权随机 + +```typescript +import { createWeightedRandom, createSeededRandom } from '@esengine/procgen'; + +const rng = createSeededRandom(42); + +// 创建加权选择器 +const loot = createWeightedRandom([ + { value: 'common', weight: 60 }, + { value: 'uncommon', weight: 25 }, + { value: 'rare', weight: 10 }, + { value: 'legendary', weight: 5 } +]); + +// 随机选择 +const drop = loot.pick(rng); +console.log(drop); // 大概率是 'common' +``` + +## 噪声函数 + +### Perlin 噪声 + +经典的梯度噪声,输出范围 [-1, 1]: + +```typescript +import { createPerlinNoise } from '@esengine/procgen'; + +const perlin = createPerlinNoise(seed); + +// 2D 噪声 +const value2D = perlin.noise2D(x, y); + +// 3D 噪声 +const value3D = perlin.noise3D(x, y, z); +``` + +### Simplex 噪声 + +比 Perlin 更快、更少方向性偏差: + +```typescript +import { createSimplexNoise } from '@esengine/procgen'; + +const simplex = createSimplexNoise(seed); + +const value = simplex.noise2D(x, y); +``` + +### Worley 噪声 + +基于细胞的噪声,适合生成石头、细胞等纹理: + +```typescript +import { createWorleyNoise } from '@esengine/procgen'; + +const worley = createWorleyNoise(seed); + +// 返回到最近点的距离 +const distance = worley.noise2D(x, y); +``` + +### FBM (分形布朗运动) + +叠加多层噪声创建更丰富的细节: + +```typescript +import { createPerlinNoise, createFBM } from '@esengine/procgen'; + +const baseNoise = createPerlinNoise(seed); + +const fbm = createFBM(baseNoise, { + octaves: 6, // 层数(越多细节越丰富) + lacunarity: 2.0, // 频率倍增因子 + persistence: 0.5, // 振幅衰减因子 + frequency: 1.0, // 初始频率 + amplitude: 1.0 // 初始振幅 +}); + +// 标准 FBM +const value = fbm.noise2D(x, y); + +// Ridged FBM(脊状,适合山脉) +const ridged = fbm.ridged2D(x, y); + +// Turbulence(湍流) +const turb = fbm.turbulence2D(x, y); + +// Billowed(膨胀,适合云朵) +const cloud = fbm.billowed2D(x, y); +``` + +## 种子随机数 API + +### SeededRandom + +基于 xorshift128+ 算法的确定性伪随机数生成器: + +```typescript +import { createSeededRandom } from '@esengine/procgen'; + +const rng = createSeededRandom(42); +``` + +### 基础方法 + +```typescript +// [0, 1) 浮点数 +rng.next(); + +// [min, max] 整数 +rng.nextInt(1, 10); + +// [min, max) 浮点数 +rng.nextFloat(0, 100); + +// 布尔值(可指定概率) +rng.nextBool(); // 50% +rng.nextBool(0.3); // 30% + +// 重置到初始状态 +rng.reset(); +``` + +### 分布方法 + +```typescript +// 正态分布(高斯分布) +rng.nextGaussian(); // 均值 0, 标准差 1 +rng.nextGaussian(100, 15); // 均值 100, 标准差 15 + +// 指数分布 +rng.nextExponential(); // λ = 1 +rng.nextExponential(0.5); // λ = 0.5 +``` + +### 几何方法 + +```typescript +// 圆内均匀分布的点 +const point = rng.nextPointInCircle(50); // { x, y } + +// 圆周上的点 +const edge = rng.nextPointOnCircle(50); // { x, y } + +// 球内均匀分布的点 +const point3D = rng.nextPointInSphere(50); // { x, y, z } + +// 随机方向向量 +const dir = rng.nextDirection2D(); // { x, y },长度为 1 +``` + +## 加权随机 API + +### WeightedRandom + +预计算累积权重,高效随机选择: + +```typescript +import { createWeightedRandom } from '@esengine/procgen'; + +const selector = createWeightedRandom([ + { value: 'apple', weight: 5 }, + { value: 'banana', weight: 3 }, + { value: 'cherry', weight: 2 } +]); + +// 使用种子随机数 +const result = selector.pick(rng); + +// 使用 Math.random +const result2 = selector.pickRandom(); + +// 获取概率 +console.log(selector.getProbability(0)); // 0.5 (5/10) +console.log(selector.size); // 3 +console.log(selector.totalWeight); // 10 +``` + +### 便捷函数 + +```typescript +import { weightedPick, weightedPickFromMap } from '@esengine/procgen'; + +// 从数组选择 +const item = weightedPick([ + { value: 'a', weight: 1 }, + { value: 'b', weight: 2 } +], rng); + +// 从对象选择 +const item2 = weightedPickFromMap({ + 'common': 60, + 'rare': 30, + 'epic': 10 +}, rng); +``` + +## 洗牌和采样 API + +### shuffle / shuffleCopy + +Fisher-Yates 洗牌算法: + +```typescript +import { shuffle, shuffleCopy } from '@esengine/procgen'; + +const arr = [1, 2, 3, 4, 5]; + +// 原地洗牌 +shuffle(arr, rng); + +// 创建洗牌副本(不修改原数组) +const shuffled = shuffleCopy(arr, rng); +``` + +### pickOne + +随机选择一个元素: + +```typescript +import { pickOne } from '@esengine/procgen'; + +const items = ['a', 'b', 'c', 'd']; +const item = pickOne(items, rng); +``` + +### sample / sampleWithReplacement + +采样: + +```typescript +import { sample, sampleWithReplacement } from '@esengine/procgen'; + +const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +// 采样 3 个不重复元素 +const unique = sample(arr, 3, rng); + +// 采样 5 个(可重复) +const withRep = sampleWithReplacement(arr, 5, rng); +``` + +### randomIntegers + +生成范围内的随机整数数组: + +```typescript +import { randomIntegers } from '@esengine/procgen'; + +// 从 1-100 中随机选 5 个不重复的数 +const nums = randomIntegers(1, 100, 5, rng); +``` + +### weightedSample + +按权重采样(不重复): + +```typescript +import { weightedSample } from '@esengine/procgen'; + +const items = ['A', 'B', 'C', 'D', 'E']; +const weights = [10, 8, 6, 4, 2]; + +// 按权重选 3 个 +const selected = weightedSample(items, weights, 3, rng); +``` + +## 实际示例 + +### 程序化地形生成 + +```typescript +import { createPerlinNoise, createFBM } from '@esengine/procgen'; + +class TerrainGenerator { + private fbm: FBM; + private moistureFbm: FBM; + + constructor(seed: number) { + const heightNoise = createPerlinNoise(seed); + const moistureNoise = createPerlinNoise(seed + 1000); + + this.fbm = createFBM(heightNoise, { + octaves: 8, + persistence: 0.5, + frequency: 0.01 + }); + + this.moistureFbm = createFBM(moistureNoise, { + octaves: 4, + persistence: 0.6, + frequency: 0.02 + }); + } + + getHeight(x: number, y: number): number { + // 基础高度 + let height = this.fbm.noise2D(x, y); + + // 添加山脉 + height += this.fbm.ridged2D(x * 0.5, y * 0.5) * 0.3; + + return (height + 1) * 0.5; // 归一化到 [0, 1] + } + + getBiome(x: number, y: number): string { + const height = this.getHeight(x, y); + const moisture = (this.moistureFbm.noise2D(x, y) + 1) * 0.5; + + if (height < 0.3) return 'water'; + if (height < 0.4) return 'beach'; + if (height > 0.8) return 'mountain'; + + if (moisture < 0.3) return 'desert'; + if (moisture > 0.7) return 'forest'; + return 'grassland'; + } +} +``` + +### 战利品系统 + +```typescript +import { createSeededRandom, createWeightedRandom, sample } from '@esengine/procgen'; + +interface LootItem { + id: string; + rarity: string; +} + +class LootSystem { + private rng: SeededRandom; + private raritySelector: WeightedRandom; + private lootTables: Map = new Map(); + + constructor(seed: number) { + this.rng = createSeededRandom(seed); + + this.raritySelector = createWeightedRandom([ + { value: 'common', weight: 60 }, + { value: 'uncommon', weight: 25 }, + { value: 'rare', weight: 10 }, + { value: 'legendary', weight: 5 } + ]); + + // 初始化战利品表 + this.lootTables.set('common', [/* ... */]); + this.lootTables.set('rare', [/* ... */]); + // ... + } + + generateLoot(count: number): LootItem[] { + const loot: LootItem[] = []; + + for (let i = 0; i < count; i++) { + const rarity = this.raritySelector.pick(this.rng); + const table = this.lootTables.get(rarity)!; + const item = pickOne(table, this.rng); + loot.push(item); + } + + return loot; + } + + // 保证可重现 + setSeed(seed: number): void { + this.rng = createSeededRandom(seed); + } +} +``` + +### 程序化敌人放置 + +```typescript +import { createSeededRandom } from '@esengine/procgen'; + +class EnemySpawner { + private rng: SeededRandom; + + constructor(seed: number) { + this.rng = createSeededRandom(seed); + } + + spawnEnemiesInArea( + centerX: number, + centerY: number, + radius: number, + count: number + ): Array<{ x: number; y: number; type: string }> { + const enemies: Array<{ x: number; y: number; type: string }> = []; + + for (let i = 0; i < count; i++) { + // 在圆内生成位置 + const pos = this.rng.nextPointInCircle(radius); + + // 随机选择敌人类型 + const type = this.rng.nextBool(0.2) ? 'elite' : 'normal'; + + enemies.push({ + x: centerX + pos.x, + y: centerY + pos.y, + type + }); + } + + return enemies; + } +} +``` + +### 程序化关卡布局 + +```typescript +import { createSeededRandom, shuffle } from '@esengine/procgen'; + +interface Room { + x: number; + y: number; + width: number; + height: number; + type: 'start' | 'combat' | 'treasure' | 'boss'; +} + +class DungeonGenerator { + private rng: SeededRandom; + + constructor(seed: number) { + this.rng = createSeededRandom(seed); + } + + generate(roomCount: number): Room[] { + const rooms: Room[] = []; + + // 生成房间 + for (let i = 0; i < roomCount; i++) { + rooms.push({ + x: this.rng.nextInt(0, 100), + y: this.rng.nextInt(0, 100), + width: this.rng.nextInt(5, 15), + height: this.rng.nextInt(5, 15), + type: 'combat' + }); + } + + // 随机分配特殊房间 + shuffle(rooms, this.rng); + rooms[0].type = 'start'; + rooms[1].type = 'treasure'; + rooms[rooms.length - 1].type = 'boss'; + + return rooms; + } +} +``` + +## 蓝图节点 + +Procgen 模块提供了可视化脚本支持的蓝图节点: + +### 噪声节点 + +- `SampleNoise2D` - 采样 2D 噪声 +- `SampleFBM` - 采样 FBM 噪声 + +### 随机节点 + +- `SeededRandom` - 生成随机浮点数 +- `SeededRandomInt` - 生成随机整数 +- `WeightedPick` - 加权随机选择 +- `ShuffleArray` - 洗牌数组 +- `PickRandom` - 随机选择元素 +- `SampleArray` - 采样数组 +- `RandomPointInCircle` - 圆内随机点 + +## 最佳实践 + +1. **使用种子保证可重现性** + ```typescript + // 保存种子以便重现相同结果 + const seed = Date.now(); + const rng = createSeededRandom(seed); + saveSeed(seed); + ``` + +2. **预计算加权选择器** + ```typescript + // 好:创建一次,多次使用 + const selector = createWeightedRandom(items); + for (let i = 0; i < 1000; i++) { + selector.pick(rng); + } + + // 不好:每次都创建 + for (let i = 0; i < 1000; i++) { + weightedPick(items, rng); + } + ``` + +3. **选择合适的噪声函数** + - Perlin:平滑过渡的地形、云彩 + - Simplex:性能要求高的场景 + - Worley:细胞、石头纹理 + - FBM:需要多层细节的自然效果 + +4. **调整 FBM 参数** + - `octaves`:越多细节越丰富,但性能开销越大 + - `persistence`:0.5 是常用值,越大高频细节越明显 + - `lacunarity`:通常为 2,控制频率增长速度 diff --git a/docs/modules/spatial/index.md b/docs/modules/spatial/index.md new file mode 100644 index 00000000..e21b7a46 --- /dev/null +++ b/docs/modules/spatial/index.md @@ -0,0 +1,600 @@ +# 空间索引系统 (Spatial) + +`@esengine/spatial` 提供了高效的空间查询和索引功能,包括范围查询、最近邻查询、射线检测和 AOI(兴趣区域)管理。 + +## 安装 + +```bash +npm install @esengine/spatial +``` + +## 快速开始 + +### 空间索引 + +```typescript +import { createGridSpatialIndex } from '@esengine/spatial'; + +// 创建空间索引(网格单元格大小为 100) +const spatialIndex = createGridSpatialIndex(100); + +// 插入对象 +spatialIndex.insert(player, { x: 100, y: 200 }); +spatialIndex.insert(enemy1, { x: 150, y: 250 }); +spatialIndex.insert(enemy2, { x: 500, y: 600 }); + +// 查找半径内的对象 +const nearby = spatialIndex.findInRadius({ x: 100, y: 200 }, 100); +console.log(nearby); // [player, enemy1] + +// 查找最近的对象 +const nearest = spatialIndex.findNearest({ x: 100, y: 200 }); +console.log(nearest); // enemy1 + +// 更新位置 +spatialIndex.update(player, { x: 120, y: 220 }); +``` + +### AOI 兴趣区域 + +```typescript +import { createGridAOI } from '@esengine/spatial'; + +// 创建 AOI 管理器 +const aoi = createGridAOI(100); + +// 添加观察者(玩家) +aoi.addObserver(player, { x: 100, y: 100 }, { viewRange: 200 }); +aoi.addObserver(npc, { x: 150, y: 150 }, { viewRange: 150 }); + +// 监听进入/离开事件 +aoi.addListener((event) => { + if (event.type === 'enter') { + console.log(`${event.observer} 看到了 ${event.target}`); + } else if (event.type === 'exit') { + console.log(`${event.target} 离开了 ${event.observer} 的视野`); + } +}); + +// 更新位置(会自动触发进入/离开事件) +aoi.updatePosition(player, { x: 200, y: 200 }); + +// 获取视野内的实体 +const visible = aoi.getEntitiesInView(player); +``` + +## 核心概念 + +### 空间索引 vs AOI + +| 特性 | 空间索引 (SpatialIndex) | AOI (Area of Interest) | +|------|------------------------|------------------------| +| 用途 | 通用空间查询 | 实体可见性追踪 | +| 事件 | 无事件通知 | 进入/离开事件 | +| 方向 | 单向查询 | 双向追踪(谁看到谁) | +| 场景 | 碰撞检测、范围攻击 | MMO 同步、NPC AI 感知 | + +### IBounds 边界框 + +```typescript +interface IBounds { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; +} +``` + +### IRaycastHit 射线检测结果 + +```typescript +interface IRaycastHit { + readonly target: T; // 命中的对象 + readonly point: IVector2; // 命中点坐标 + readonly normal: IVector2; // 命中点法线 + readonly distance: number; // 距离射线起点的距离 +} +``` + +## 空间索引 API + +### createGridSpatialIndex + +```typescript +function createGridSpatialIndex(cellSize?: number): GridSpatialIndex +``` + +创建基于均匀网格的空间索引。 + +**参数:** +- `cellSize` - 网格单元格大小(默认 100) + +**选择合适的 cellSize:** +- 太小:内存占用高,查询效率降低 +- 太大:单元格内对象过多,遍历耗时 +- 建议:设置为对象平均分布间距的 1-2 倍 + +### 管理方法 + +#### insert + +插入对象到索引: + +```typescript +spatialIndex.insert(enemy, { x: 100, y: 200 }); +``` + +#### remove + +移除对象: + +```typescript +spatialIndex.remove(enemy); +``` + +#### update + +更新对象位置: + +```typescript +spatialIndex.update(enemy, { x: 150, y: 250 }); +``` + +#### clear + +清空索引: + +```typescript +spatialIndex.clear(); +``` + +### 查询方法 + +#### findInRadius + +查找圆形范围内的所有对象: + +```typescript +// 查找中心点 (100, 200) 半径 50 内的所有敌人 +const enemies = spatialIndex.findInRadius( + { x: 100, y: 200 }, + 50, + (entity) => entity.type === 'enemy' // 可选过滤器 +); +``` + +#### findInRect + +查找矩形区域内的所有对象: + +```typescript +import { createBounds } from '@esengine/spatial'; + +const bounds = createBounds(0, 0, 200, 200); +const entities = spatialIndex.findInRect(bounds); +``` + +#### findNearest + +查找最近的对象: + +```typescript +// 查找最近的敌人(最大搜索距离 500) +const nearest = spatialIndex.findNearest( + playerPosition, + 500, // maxDistance + (entity) => entity.type === 'enemy' +); + +if (nearest) { + attackTarget(nearest); +} +``` + +#### findKNearest + +查找最近的 K 个对象: + +```typescript +// 查找最近的 5 个敌人 +const nearestEnemies = spatialIndex.findKNearest( + playerPosition, + 5, // k + 500, // maxDistance + (entity) => entity.type === 'enemy' +); +``` + +#### raycast + +射线检测(返回所有命中): + +```typescript +const hits = spatialIndex.raycast( + origin, // 射线起点 + direction, // 射线方向(应归一化) + maxDistance, // 最大检测距离 + filter // 可选过滤器 +); + +// hits 按距离排序 +for (const hit of hits) { + console.log(`命中 ${hit.target} at ${hit.point}, 距离 ${hit.distance}`); +} +``` + +#### raycastFirst + +射线检测(仅返回第一个命中): + +```typescript +const hit = spatialIndex.raycastFirst(origin, direction, 1000); +if (hit) { + dealDamage(hit.target, calculateDamage(hit.distance)); +} +``` + +### 属性 + +```typescript +// 获取索引中的对象数量 +console.log(spatialIndex.count); + +// 获取所有对象 +const all = spatialIndex.getAll(); +``` + +## AOI 兴趣区域 API + +### createGridAOI + +```typescript +function createGridAOI(cellSize?: number): GridAOI +``` + +创建基于网格的 AOI 管理器。 + +**参数:** +- `cellSize` - 网格单元格大小(建议为平均视野范围的 1-2 倍) + +### 观察者管理 + +#### addObserver + +添加观察者: + +```typescript +aoi.addObserver(player, position, { + viewRange: 200, // 视野范围 + observable: true // 是否可被其他观察者看到(默认 true) +}); + +// NPC 只观察不被观察 +aoi.addObserver(camera, position, { + viewRange: 500, + observable: false +}); +``` + +#### removeObserver + +移除观察者: + +```typescript +aoi.removeObserver(player); +``` + +#### updatePosition + +更新位置(自动触发进入/离开事件): + +```typescript +aoi.updatePosition(player, newPosition); +``` + +#### updateViewRange + +更新视野范围: + +```typescript +// 获得增益后视野扩大 +aoi.updateViewRange(player, 300); +``` + +### 查询方法 + +#### getEntitiesInView + +获取观察者视野内的所有实体: + +```typescript +const visible = aoi.getEntitiesInView(player); +for (const entity of visible) { + updateEntityForPlayer(player, entity); +} +``` + +#### getObserversOf + +获取能看到指定实体的所有观察者: + +```typescript +const observers = aoi.getObserversOf(monster); +for (const observer of observers) { + notifyMonsterMoved(observer, monster); +} +``` + +#### canSee + +检查是否可见: + +```typescript +if (aoi.canSee(player, enemy)) { + enemy.showHealthBar(); +} +``` + +### 事件系统 + +#### 全局事件监听 + +```typescript +aoi.addListener((event) => { + switch (event.type) { + case 'enter': + console.log(`${event.observer} 看到了 ${event.target}`); + break; + case 'exit': + console.log(`${event.target} 离开了 ${event.observer} 的视野`); + break; + } +}); +``` + +#### 实体特定事件监听 + +```typescript +// 只监听特定玩家的视野事件 +aoi.addEntityListener(player, (event) => { + if (event.type === 'enter') { + sendToClient(player, 'entity_enter', event.target); + } else if (event.type === 'exit') { + sendToClient(player, 'entity_exit', event.target); + } +}); +``` + +#### 事件类型 + +```typescript +interface IAOIEvent { + type: 'enter' | 'exit' | 'update'; + observer: T; // 观察者(谁看到了变化) + target: T; // 目标(发生变化的对象) + position: IVector2; // 目标位置 +} +``` + +## 工具函数 + +### 边界框创建 + +```typescript +import { + createBounds, + createBoundsFromCenter, + createBoundsFromCircle +} from '@esengine/spatial'; + +// 从角点创建 +const bounds1 = createBounds(0, 0, 100, 100); + +// 从中心点和尺寸创建 +const bounds2 = createBoundsFromCenter({ x: 50, y: 50 }, 100, 100); + +// 从圆形创建(包围盒) +const bounds3 = createBoundsFromCircle({ x: 50, y: 50 }, 50); +``` + +### 几何检测 + +```typescript +import { + isPointInBounds, + boundsIntersect, + boundsIntersectsCircle, + distance, + distanceSquared +} from '@esengine/spatial'; + +// 点在边界内? +if (isPointInBounds(point, bounds)) { ... } + +// 两个边界框相交? +if (boundsIntersect(boundsA, boundsB)) { ... } + +// 边界框与圆形相交? +if (boundsIntersectsCircle(bounds, center, radius)) { ... } + +// 距离计算 +const dist = distance(pointA, pointB); +const distSq = distanceSquared(pointA, pointB); // 更快,避免 sqrt +``` + +## 实际示例 + +### 范围攻击检测 + +```typescript +class CombatSystem { + private spatialIndex: ISpatialIndex; + + dealAreaDamage(center: IVector2, radius: number, damage: number): void { + const targets = this.spatialIndex.findInRadius( + center, + radius, + (entity) => entity.hasComponent(HealthComponent) + ); + + for (const target of targets) { + const health = target.getComponent(HealthComponent); + health.takeDamage(damage); + } + } + + findNearestEnemy(position: IVector2, team: string): Entity | null { + return this.spatialIndex.findNearest( + position, + undefined, // 无距离限制 + (entity) => { + const teamComp = entity.getComponent(TeamComponent); + return teamComp && teamComp.team !== team; + } + ); + } +} +``` + +### MMO 同步系统 + +```typescript +class SyncSystem { + private aoi: IAOIManager; + + constructor() { + this.aoi = createGridAOI(100); + + // 监听进入/离开事件 + this.aoi.addListener((event) => { + const packet = this.createSyncPacket(event); + this.sendToPlayer(event.observer, packet); + }); + } + + onPlayerJoin(player: Player): void { + this.aoi.addObserver(player, player.position, { + viewRange: player.viewRange + }); + } + + onPlayerMove(player: Player, newPosition: IVector2): void { + this.aoi.updatePosition(player, newPosition); + } + + onPlayerLeave(player: Player): void { + this.aoi.removeObserver(player); + } + + // 广播给所有能看到某玩家的其他玩家 + broadcastToObservers(player: Player, packet: Packet): void { + const observers = this.aoi.getObserversOf(player); + for (const observer of observers) { + this.sendToPlayer(observer, packet); + } + } +} +``` + +### NPC AI 感知 + +```typescript +class AIPerceptionSystem { + private aoi: IAOIManager; + + constructor() { + this.aoi = createGridAOI(50); + } + + setupNPC(npc: Entity): void { + const perception = npc.getComponent(PerceptionComponent); + + this.aoi.addObserver(npc, npc.position, { + viewRange: perception.range + }); + + // 监听该 NPC 的感知事件 + this.aoi.addEntityListener(npc, (event) => { + const ai = npc.getComponent(AIComponent); + + if (event.type === 'enter') { + ai.onTargetDetected(event.target); + } else if (event.type === 'exit') { + ai.onTargetLost(event.target); + } + }); + } + + update(): void { + // 更新所有 NPC 位置 + for (const npc of this.npcs) { + this.aoi.updatePosition(npc, npc.position); + } + } +} +``` + +## 蓝图节点 + +### 空间查询节点 + +- `FindInRadius` - 查找半径内的对象 +- `FindInRect` - 查找矩形内的对象 +- `FindNearest` - 查找最近的对象 +- `FindKNearest` - 查找最近的 K 个对象 +- `Raycast` - 射线检测 +- `RaycastFirst` - 射线检测(仅第一个) + +### AOI 节点 + +- `GetEntitiesInView` - 获取视野内实体 +- `GetObserversOf` - 获取观察者 +- `CanSee` - 检查可见性 +- `OnEntityEnterView` - 进入视野事件 +- `OnEntityExitView` - 离开视野事件 + +## 服务令牌 + +在依赖注入场景中使用: + +```typescript +import { + SpatialIndexToken, + SpatialQueryToken, + AOIManagerToken, + createGridSpatialIndex, + createGridAOI +} from '@esengine/spatial'; + +// 注册服务 +services.register(SpatialIndexToken, createGridSpatialIndex(100)); +services.register(AOIManagerToken, createGridAOI(100)); + +// 获取服务 +const spatialIndex = services.get(SpatialIndexToken); +const aoiManager = services.get(AOIManagerToken); +``` + +## 性能优化 + +1. **选择合适的 cellSize** + - 太小:内存占用高,单元格数量多 + - 太大:单元格内对象多,遍历慢 + - 经验法则:对象平均间距的 1-2 倍 + +2. **使用过滤器减少结果** + ```typescript + // 在空间查询阶段就过滤,而不是事后过滤 + spatialIndex.findInRadius(center, radius, (e) => e.type === 'enemy'); + ``` + +3. **使用 distanceSquared 代替 distance** + ```typescript + // 避免 sqrt 计算 + if (distanceSquared(a, b) < threshold * threshold) { ... } + ``` + +4. **批量更新优化** + ```typescript + // 如果有大量对象同时移动,考虑禁用事件后批量更新 + ``` diff --git a/docs/modules/timer/index.md b/docs/modules/timer/index.md new file mode 100644 index 00000000..50375bc7 --- /dev/null +++ b/docs/modules/timer/index.md @@ -0,0 +1,479 @@ +# 定时器系统 (Timer) + +`@esengine/timer` 提供了一个灵活的定时器和冷却系统,用于游戏中的延迟执行、重复任务、技能冷却等场景。 + +## 安装 + +```bash +npm install @esengine/timer +``` + +## 快速开始 + +```typescript +import { createTimerService } from '@esengine/timer'; + +// 创建定时器服务 +const timerService = createTimerService(); + +// 一次性定时器(1秒后执行) +const handle = timerService.schedule('myTimer', 1000, () => { + console.log('Timer fired!'); +}); + +// 重复定时器(每100毫秒执行) +timerService.scheduleRepeating('heartbeat', 100, () => { + console.log('Tick'); +}); + +// 冷却系统(5秒冷却) +timerService.startCooldown('skill_fireball', 5000); + +if (timerService.isCooldownReady('skill_fireball')) { + // 可以使用技能 + useFireball(); + timerService.startCooldown('skill_fireball', 5000); +} + +// 游戏循环中更新 +function gameLoop(deltaTime: number) { + timerService.update(deltaTime); +} +``` + +## 核心概念 + +### 定时器 vs 冷却 + +| 特性 | 定时器 (Timer) | 冷却 (Cooldown) | +|------|---------------|-----------------| +| 用途 | 延迟执行代码 | 限制操作频率 | +| 回调 | 有回调函数 | 无回调函数 | +| 重复 | 支持重复执行 | 一次性 | +| 查询 | 查询剩余时间 | 查询进度/是否就绪 | + +### TimerHandle + +调度定时器后返回的句柄对象,用于控制定时器: + +```typescript +interface TimerHandle { + readonly id: string; // 定时器 ID + readonly isValid: boolean; // 是否有效(未被取消) + cancel(): void; // 取消定时器 +} +``` + +### TimerInfo + +定时器信息对象: + +```typescript +interface TimerInfo { + readonly id: string; // 定时器 ID + readonly remaining: number; // 剩余时间(毫秒) + readonly repeating: boolean; // 是否重复执行 + readonly interval?: number; // 间隔时间(仅重复定时器) +} +``` + +### CooldownInfo + +冷却信息对象: + +```typescript +interface CooldownInfo { + readonly id: string; // 冷却 ID + readonly duration: number; // 总持续时间(毫秒) + readonly remaining: number; // 剩余时间(毫秒) + readonly progress: number; // 进度(0-1,0=刚开始,1=结束) + readonly isReady: boolean; // 是否已就绪 +} +``` + +## API 参考 + +### createTimerService + +```typescript +function createTimerService(config?: TimerServiceConfig): ITimerService +``` + +**配置选项:** + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| `maxTimers` | `number` | `0` | 最大定时器数量(0 表示无限制) | +| `maxCooldowns` | `number` | `0` | 最大冷却数量(0 表示无限制) | + +### 定时器 API + +#### schedule + +调度一次性定时器: + +```typescript +const handle = timerService.schedule('explosion', 2000, () => { + createExplosion(); +}); + +// 提前取消 +handle.cancel(); +``` + +#### scheduleRepeating + +调度重复定时器: + +```typescript +// 每秒执行 +timerService.scheduleRepeating('regen', 1000, () => { + player.hp += 5; +}); + +// 立即执行一次,然后每秒重复 +timerService.scheduleRepeating('tick', 1000, () => { + console.log('Tick'); +}, true); // immediate = true +``` + +#### cancel / cancelById + +取消定时器: + +```typescript +// 通过句柄取消 +handle.cancel(); +// 或 +timerService.cancel(handle); + +// 通过 ID 取消 +timerService.cancelById('regen'); +``` + +#### hasTimer + +检查定时器是否存在: + +```typescript +if (timerService.hasTimer('explosion')) { + console.log('Explosion is pending'); +} +``` + +#### getTimerInfo + +获取定时器信息: + +```typescript +const info = timerService.getTimerInfo('explosion'); +if (info) { + console.log(`剩余时间: ${info.remaining}ms`); + console.log(`是否重复: ${info.repeating}`); +} +``` + +### 冷却 API + +#### startCooldown + +开始冷却: + +```typescript +// 5秒冷却 +timerService.startCooldown('skill_fireball', 5000); +``` + +#### isCooldownReady / isOnCooldown + +检查冷却状态: + +```typescript +if (timerService.isCooldownReady('skill_fireball')) { + // 可以使用技能 + castFireball(); + timerService.startCooldown('skill_fireball', 5000); +} else { + console.log('技能还在冷却中'); +} + +// 或使用 isOnCooldown +if (timerService.isOnCooldown('skill_fireball')) { + console.log('冷却中...'); +} +``` + +#### getCooldownProgress / getCooldownRemaining + +获取冷却进度: + +```typescript +// 进度 0-1(0=刚开始,1=完成) +const progress = timerService.getCooldownProgress('skill_fireball'); +console.log(`冷却进度: ${(progress * 100).toFixed(0)}%`); + +// 剩余时间(毫秒) +const remaining = timerService.getCooldownRemaining('skill_fireball'); +console.log(`剩余时间: ${(remaining / 1000).toFixed(1)}s`); +``` + +#### getCooldownInfo + +获取完整冷却信息: + +```typescript +const info = timerService.getCooldownInfo('skill_fireball'); +if (info) { + console.log(`总时长: ${info.duration}ms`); + console.log(`剩余: ${info.remaining}ms`); + console.log(`进度: ${info.progress}`); + console.log(`就绪: ${info.isReady}`); +} +``` + +#### resetCooldown / clearAllCooldowns + +重置冷却: + +```typescript +// 重置单个冷却 +timerService.resetCooldown('skill_fireball'); + +// 清除所有冷却(例如角色复活时) +timerService.clearAllCooldowns(); +``` + +### 生命周期 + +#### update + +更新定时器服务(需要每帧调用): + +```typescript +function gameLoop(deltaTime: number) { + // deltaTime 单位是毫秒 + timerService.update(deltaTime); +} +``` + +#### clear + +清除所有定时器和冷却: + +```typescript +timerService.clear(); +``` + +### 调试属性 + +```typescript +// 获取活跃定时器数量 +console.log(timerService.activeTimerCount); + +// 获取活跃冷却数量 +console.log(timerService.activeCooldownCount); + +// 获取所有活跃定时器 ID +const timerIds = timerService.getActiveTimerIds(); + +// 获取所有活跃冷却 ID +const cooldownIds = timerService.getActiveCooldownIds(); +``` + +## 实际示例 + +### 技能冷却系统 + +```typescript +import { createTimerService, type ITimerService } from '@esengine/timer'; + +class SkillSystem { + private timerService: ITimerService; + private skills: Map = new Map(); + + constructor() { + this.timerService = createTimerService(); + } + + registerSkill(id: string, data: SkillData): void { + this.skills.set(id, data); + } + + useSkill(skillId: string): boolean { + const skill = this.skills.get(skillId); + if (!skill) return false; + + // 检查冷却 + if (!this.timerService.isCooldownReady(skillId)) { + const remaining = this.timerService.getCooldownRemaining(skillId); + console.log(`技能 ${skillId} 冷却中,剩余 ${remaining}ms`); + return false; + } + + // 使用技能 + this.executeSkill(skill); + + // 开始冷却 + this.timerService.startCooldown(skillId, skill.cooldown); + return true; + } + + getSkillCooldownProgress(skillId: string): number { + return this.timerService.getCooldownProgress(skillId); + } + + update(dt: number): void { + this.timerService.update(dt); + } +} + +interface SkillData { + cooldown: number; + // ... other properties +} +``` + +### 延迟和定时效果 + +```typescript +class EffectSystem { + private timerService: ITimerService; + + constructor(timerService: ITimerService) { + this.timerService = timerService; + } + + // 延迟爆炸 + scheduleExplosion(position: { x: number; y: number }, delay: number): void { + this.timerService.schedule(`explosion_${Date.now()}`, delay, () => { + this.createExplosion(position); + }); + } + + // DOT 伤害(每秒造成伤害) + applyDOT(target: Entity, damage: number, duration: number): void { + const dotId = `dot_${target.id}_${Date.now()}`; + let elapsed = 0; + + this.timerService.scheduleRepeating(dotId, 1000, () => { + elapsed += 1000; + target.takeDamage(damage); + + if (elapsed >= duration) { + this.timerService.cancelById(dotId); + } + }); + } + + // BUFF 效果(持续一段时间) + applyBuff(target: Entity, buffId: string, duration: number): void { + target.addBuff(buffId); + + this.timerService.schedule(`buff_expire_${buffId}`, duration, () => { + target.removeBuff(buffId); + }); + } +} +``` + +### 与 ECS 集成 + +```typescript +import { Component, EntitySystem, Matcher } from '@esengine/ecs-framework'; +import { createTimerService, type ITimerService } from '@esengine/timer'; + +// 定时器组件 +class TimerComponent extends Component { + timerService: ITimerService; + + constructor() { + super(); + this.timerService = createTimerService(); + } +} + +// 定时器系统 +class TimerSystem extends EntitySystem { + constructor() { + super(Matcher.all(TimerComponent)); + } + + protected processEntity(entity: Entity, dt: number): void { + const timer = entity.getComponent(TimerComponent); + timer.timerService.update(dt); + } +} + +// 冷却组件(用于共享冷却) +class CooldownComponent extends Component { + constructor(public timerService: ITimerService) { + super(); + } +} +``` + +## 蓝图节点 + +Timer 模块提供了可视化脚本支持的蓝图节点: + +### 冷却节点 + +- `StartCooldown` - 开始冷却 +- `IsCooldownReady` - 检查冷却是否就绪 +- `GetCooldownProgress` - 获取冷却进度 +- `GetCooldownInfo` - 获取详细冷却信息 +- `ResetCooldown` - 重置冷却 + +### 定时器节点 + +- `HasTimer` - 检查定时器是否存在 +- `CancelTimer` - 取消定时器 +- `GetTimerRemaining` - 获取定时器剩余时间 + +## 服务令牌 + +在依赖注入场景中使用: + +```typescript +import { TimerServiceToken, createTimerService } from '@esengine/timer'; + +// 注册服务 +services.register(TimerServiceToken, createTimerService()); + +// 获取服务 +const timerService = services.get(TimerServiceToken); +``` + +## 最佳实践 + +1. **使用有意义的 ID**:使用描述性的 ID 便于调试和管理 + ```typescript + // 好 + timerService.startCooldown('skill_fireball', 5000); + + // 不好 + timerService.startCooldown('cd1', 5000); + ``` + +2. **避免重复 ID**:相同 ID 的定时器会覆盖之前的 + ```typescript + // 使用唯一 ID + const uniqueId = `explosion_${entity.id}_${Date.now()}`; + timerService.schedule(uniqueId, 1000, callback); + ``` + +3. **及时清理**:在适当时机清理不需要的定时器和冷却 + ```typescript + // 实体销毁时 + onDestroy() { + this.timerService.cancelById(this.timerId); + } + ``` + +4. **配置限制**:在生产环境考虑设置最大数量限制 + ```typescript + const timerService = createTimerService({ + maxTimers: 1000, + maxCooldowns: 500 + }); + ```