Compare commits
5 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e31e9101b | ||
|
|
d66c18041e | ||
|
|
881ffad3bc | ||
|
|
4a16e30794 | ||
|
|
76691cc198 |
@@ -49,21 +49,6 @@ function createSidebar(t, prefix = '') {
|
|||||||
{ text: t.sidebar.persistentEntity, link: `${prefix}/guide/persistent-entity` }
|
{ text: t.sidebar.persistentEntity, link: `${prefix}/guide/persistent-entity` }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: t.sidebar.behaviorTree,
|
|
||||||
link: `${prefix}/guide/behavior-tree/`,
|
|
||||||
items: [
|
|
||||||
{ text: t.sidebar.btGettingStarted, link: `${prefix}/guide/behavior-tree/getting-started` },
|
|
||||||
{ text: t.sidebar.btCoreConcepts, link: `${prefix}/guide/behavior-tree/core-concepts` },
|
|
||||||
{ text: t.sidebar.btEditorGuide, link: `${prefix}/guide/behavior-tree/editor-guide` },
|
|
||||||
{ text: t.sidebar.btEditorWorkflow, link: `${prefix}/guide/behavior-tree/editor-workflow` },
|
|
||||||
{ text: t.sidebar.btCustomActions, link: `${prefix}/guide/behavior-tree/custom-actions` },
|
|
||||||
{ text: t.sidebar.btCocosIntegration, link: `${prefix}/guide/behavior-tree/cocos-integration` },
|
|
||||||
{ text: t.sidebar.btLayaIntegration, link: `${prefix}/guide/behavior-tree/laya-integration` },
|
|
||||||
{ text: t.sidebar.btAdvancedUsage, link: `${prefix}/guide/behavior-tree/advanced-usage` },
|
|
||||||
{ text: t.sidebar.btBestPractices, link: `${prefix}/guide/behavior-tree/best-practices` }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{ text: t.sidebar.serialization, link: `${prefix}/guide/serialization` },
|
{ text: t.sidebar.serialization, link: `${prefix}/guide/serialization` },
|
||||||
{ text: t.sidebar.eventSystem, link: `${prefix}/guide/event-system` },
|
{ text: t.sidebar.eventSystem, link: `${prefix}/guide/event-system` },
|
||||||
{ text: t.sidebar.timeAndTimers, link: `${prefix}/guide/time-and-timers` },
|
{ text: t.sidebar.timeAndTimers, link: `${prefix}/guide/time-and-timers` },
|
||||||
@@ -89,6 +74,64 @@ function createSidebar(t, prefix = '') {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
// 模块总览侧边栏 | Modules overview sidebar
|
||||||
|
[`${prefix}/modules/`]: [
|
||||||
|
{
|
||||||
|
text: t.sidebar.modulesOverview,
|
||||||
|
link: `${prefix}/modules/`,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: t.sidebar.aiModules,
|
||||||
|
collapsed: false,
|
||||||
|
items: [
|
||||||
|
{ text: t.sidebar.behaviorTree, link: `${prefix}/modules/behavior-tree/` },
|
||||||
|
{ text: t.sidebar.fsm, link: `${prefix}/modules/fsm/` }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t.sidebar.gameplayModules,
|
||||||
|
collapsed: false,
|
||||||
|
items: [
|
||||||
|
{ text: t.sidebar.timer, link: `${prefix}/modules/timer/` },
|
||||||
|
{ text: t.sidebar.spatial, link: `${prefix}/modules/spatial/` },
|
||||||
|
{ text: t.sidebar.pathfinding, link: `${prefix}/modules/pathfinding/` }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t.sidebar.toolModules,
|
||||||
|
collapsed: false,
|
||||||
|
items: [
|
||||||
|
{ text: t.sidebar.blueprint, link: `${prefix}/modules/blueprint/` },
|
||||||
|
{ text: t.sidebar.procgen, link: `${prefix}/modules/procgen/` }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t.sidebar.networkModules,
|
||||||
|
collapsed: false,
|
||||||
|
items: [
|
||||||
|
{ text: t.sidebar.network, link: `${prefix}/modules/network/` }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// 行为树模块侧边栏 | Behavior tree module sidebar
|
||||||
|
[`${prefix}/modules/behavior-tree/`]: [
|
||||||
|
{
|
||||||
|
text: t.sidebar.behaviorTree,
|
||||||
|
items: [
|
||||||
|
{ text: t.sidebar.btGettingStarted, link: `${prefix}/modules/behavior-tree/getting-started` },
|
||||||
|
{ text: t.sidebar.btCoreConcepts, link: `${prefix}/modules/behavior-tree/core-concepts` },
|
||||||
|
{ text: t.sidebar.btEditorGuide, link: `${prefix}/modules/behavior-tree/editor-guide` },
|
||||||
|
{ text: t.sidebar.btEditorWorkflow, link: `${prefix}/modules/behavior-tree/editor-workflow` },
|
||||||
|
{ text: t.sidebar.btCustomActions, link: `${prefix}/modules/behavior-tree/custom-actions` },
|
||||||
|
{ text: t.sidebar.btCocosIntegration, link: `${prefix}/modules/behavior-tree/cocos-integration` },
|
||||||
|
{ text: t.sidebar.btLayaIntegration, link: `${prefix}/modules/behavior-tree/laya-integration` },
|
||||||
|
{ text: t.sidebar.btAdvancedUsage, link: `${prefix}/modules/behavior-tree/advanced-usage` },
|
||||||
|
{ text: t.sidebar.btBestPractices, link: `${prefix}/modules/behavior-tree/best-practices` }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
[`${prefix}/examples/`]: [
|
[`${prefix}/examples/`]: [
|
||||||
{
|
{
|
||||||
text: t.sidebar.examples,
|
text: t.sidebar.examples,
|
||||||
@@ -173,6 +216,7 @@ function createNav(t, prefix = '') {
|
|||||||
{ text: t.nav.home, link: `${prefix}/` },
|
{ text: t.nav.home, link: `${prefix}/` },
|
||||||
{ text: t.nav.quickStart, link: `${prefix}/guide/getting-started` },
|
{ text: t.nav.quickStart, link: `${prefix}/guide/getting-started` },
|
||||||
{ text: t.nav.guide, link: `${prefix}/guide/` },
|
{ text: t.nav.guide, link: `${prefix}/guide/` },
|
||||||
|
{ text: t.nav.modules, link: `${prefix}/modules/` },
|
||||||
{ text: t.nav.api, link: `${prefix}/api/README` },
|
{ text: t.nav.api, link: `${prefix}/api/README` },
|
||||||
{
|
{
|
||||||
text: t.nav.examples,
|
text: t.nav.examples,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"quickStart": "Quick Start",
|
"quickStart": "Quick Start",
|
||||||
"guide": "Guide",
|
"guide": "Guide",
|
||||||
|
"modules": "Modules",
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"examples": "Examples",
|
"examples": "Examples",
|
||||||
"workerDemo": "Worker System Demo",
|
"workerDemo": "Worker System Demo",
|
||||||
@@ -54,7 +55,26 @@
|
|||||||
"utilities": "Utilities",
|
"utilities": "Utilities",
|
||||||
"interfaces": "Interfaces",
|
"interfaces": "Interfaces",
|
||||||
"decorators": "Decorators",
|
"decorators": "Decorators",
|
||||||
"enums": "Enums"
|
"enums": "Enums",
|
||||||
|
"modulesOverview": "Modules Overview",
|
||||||
|
"aiModules": "AI Modules",
|
||||||
|
"gameplayModules": "Gameplay",
|
||||||
|
"toolModules": "Tools",
|
||||||
|
"networkModules": "Network",
|
||||||
|
"fsm": "State Machine (FSM)",
|
||||||
|
"fsmOverview": "Overview",
|
||||||
|
"timer": "Timer System",
|
||||||
|
"timerOverview": "Overview",
|
||||||
|
"spatial": "Spatial Index",
|
||||||
|
"spatialOverview": "Overview",
|
||||||
|
"pathfinding": "Pathfinding",
|
||||||
|
"pathfindingOverview": "Overview",
|
||||||
|
"blueprint": "Visual Scripting",
|
||||||
|
"blueprintOverview": "Overview",
|
||||||
|
"procgen": "Procedural Generation",
|
||||||
|
"procgenOverview": "Overview",
|
||||||
|
"network": "Network Sync",
|
||||||
|
"networkOverview": "Overview"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "ESEngine - High-performance TypeScript ECS Framework",
|
"title": "ESEngine - High-performance TypeScript ECS Framework",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"home": "首页",
|
"home": "首页",
|
||||||
"quickStart": "快速开始",
|
"quickStart": "快速开始",
|
||||||
"guide": "指南",
|
"guide": "指南",
|
||||||
|
"modules": "模块",
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"examples": "示例",
|
"examples": "示例",
|
||||||
"workerDemo": "Worker系统演示",
|
"workerDemo": "Worker系统演示",
|
||||||
@@ -54,7 +55,26 @@
|
|||||||
"utilities": "工具类",
|
"utilities": "工具类",
|
||||||
"interfaces": "接口",
|
"interfaces": "接口",
|
||||||
"decorators": "装饰器",
|
"decorators": "装饰器",
|
||||||
"enums": "枚举"
|
"enums": "枚举",
|
||||||
|
"modulesOverview": "模块总览",
|
||||||
|
"aiModules": "AI 模块",
|
||||||
|
"gameplayModules": "游戏逻辑",
|
||||||
|
"toolModules": "工具模块",
|
||||||
|
"networkModules": "网络模块",
|
||||||
|
"fsm": "状态机 (FSM)",
|
||||||
|
"fsmOverview": "概述",
|
||||||
|
"timer": "定时器系统",
|
||||||
|
"timerOverview": "概述",
|
||||||
|
"spatial": "空间索引",
|
||||||
|
"spatialOverview": "概述",
|
||||||
|
"pathfinding": "寻路系统",
|
||||||
|
"pathfindingOverview": "概述",
|
||||||
|
"blueprint": "可视化脚本",
|
||||||
|
"blueprintOverview": "概述",
|
||||||
|
"procgen": "程序化生成",
|
||||||
|
"procgenOverview": "概述",
|
||||||
|
"network": "网络同步",
|
||||||
|
"networkOverview": "概述"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "ESEngine - 高性能 TypeScript ECS 框架",
|
"title": "ESEngine - 高性能 TypeScript ECS 框架",
|
||||||
|
|||||||
@@ -2,23 +2,24 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--vp-nav-height: 64px;
|
--vp-nav-height: 64px;
|
||||||
|
|
||||||
--es-bg-base: #1e1e1e;
|
--es-bg-base: #1a1a1a;
|
||||||
--es-bg-elevated: #252526;
|
--es-bg-elevated: #222222;
|
||||||
--es-bg-overlay: #2d2d2d;
|
--es-bg-overlay: #2a2a2a;
|
||||||
--es-bg-input: #3c3c3c;
|
--es-bg-input: #333333;
|
||||||
--es-bg-inset: #181818;
|
--es-bg-inset: #151515;
|
||||||
--es-bg-hover: #2a2d2e;
|
--es-bg-hover: #2a2d2e;
|
||||||
--es-bg-active: #37373d;
|
--es-bg-active: #37373d;
|
||||||
--es-bg-sidebar: #262626;
|
--es-bg-sidebar: #1e1e1e;
|
||||||
--es-bg-card: #2a2a2a;
|
--es-bg-card: #242424;
|
||||||
--es-bg-header: #2d2d2d;
|
--es-bg-header: #1e1e1e;
|
||||||
|
|
||||||
--es-text-primary: #cccccc;
|
/* 提高文字对比度 | Improve text contrast */
|
||||||
--es-text-secondary: #9d9d9d;
|
--es-text-primary: #e0e0e0;
|
||||||
--es-text-tertiary: #6a6a6a;
|
--es-text-secondary: #b0b0b0;
|
||||||
|
--es-text-tertiary: #888888;
|
||||||
--es-text-inverse: #ffffff;
|
--es-text-inverse: #ffffff;
|
||||||
--es-text-muted: #aaaaaa;
|
--es-text-muted: #c0c0c0;
|
||||||
--es-text-dim: #6a6a6a;
|
--es-text-dim: #888888;
|
||||||
|
|
||||||
--es-font-xs: 11px;
|
--es-font-xs: 11px;
|
||||||
--es-font-sm: 12px;
|
--es-font-sm: 12px;
|
||||||
|
|||||||
404
docs/en/modules/blueprint/index.md
Normal file
404
docs/en/modules/blueprint/index.md
Normal file
@@ -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<T>(nodeId: string, pinName: string): T;
|
||||||
|
setOutput(nodeId: string, pinName: string, value: unknown): void;
|
||||||
|
getVariable<T>(name: string): T;
|
||||||
|
setVariable(name: string, value: unknown): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execution Result
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ExecutionResult {
|
||||||
|
outputs?: Record<string, unknown>; // 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<number>(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<BlueprintAsset> {
|
||||||
|
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
|
||||||
316
docs/en/modules/fsm/index.md
Normal file
316
docs/en/modules/fsm/index.md
Normal file
@@ -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<PlayerState>('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<TState, TContext> {
|
||||||
|
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<string, unknown>; // Metadata
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transition Conditions
|
||||||
|
|
||||||
|
Define conditional state transitions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Context {
|
||||||
|
isMoving: boolean;
|
||||||
|
isRunning: boolean;
|
||||||
|
isGrounded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fsm = createStateMachine<PlayerState, Context>('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<TState extends string, TContext = unknown>(
|
||||||
|
initialState: TState,
|
||||||
|
options?: StateMachineOptions<TContext>
|
||||||
|
): IStateMachine<TState, TContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<CharacterState, CharacterContext>('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<string>;
|
||||||
|
|
||||||
|
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
|
||||||
54
docs/en/modules/index.md
Normal file
54
docs/en/modules/index.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Modules
|
||||||
|
|
||||||
|
ESEngine provides a rich set of modules that can be imported as needed.
|
||||||
|
|
||||||
|
## Module List
|
||||||
|
|
||||||
|
### AI Modules
|
||||||
|
|
||||||
|
| Module | Package | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| [Behavior Tree](/en/modules/behavior-tree/) | `@esengine/behavior-tree` | AI behavior tree with visual editor |
|
||||||
|
| [State Machine](/en/modules/fsm/) | `@esengine/fsm` | Finite state machine for character/AI states |
|
||||||
|
|
||||||
|
### Gameplay
|
||||||
|
|
||||||
|
| Module | Package | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| [Timer](/en/modules/timer/) | `@esengine/timer` | Timer and cooldown system |
|
||||||
|
| [Spatial](/en/modules/spatial/) | `@esengine/spatial` | Spatial queries, AOI management |
|
||||||
|
| [Pathfinding](/en/modules/pathfinding/) | `@esengine/pathfinding` | A* pathfinding, NavMesh navigation |
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
| Module | Package | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| [Blueprint](/en/modules/blueprint/) | `@esengine/blueprint` | Visual scripting system |
|
||||||
|
| [Procgen](/en/modules/procgen/) | `@esengine/procgen` | Noise functions, random utilities |
|
||||||
|
|
||||||
|
### Network
|
||||||
|
|
||||||
|
| Module | Package | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| [Network](/en/modules/network/) | `@esengine/network` | Multiplayer game networking |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
All modules can be installed independently:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install a single module
|
||||||
|
npm install @esengine/behavior-tree
|
||||||
|
|
||||||
|
# Or use CLI to add to existing project
|
||||||
|
npx @esengine/cli add behavior-tree
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Compatibility
|
||||||
|
|
||||||
|
All modules are pure TypeScript and compatible with:
|
||||||
|
|
||||||
|
- Cocos Creator 3.x
|
||||||
|
- Laya 3.x
|
||||||
|
- Node.js
|
||||||
|
- Browser
|
||||||
299
docs/en/modules/pathfinding/index.md
Normal file
299
docs/en/modules/pathfinding/index.md
Normal file
@@ -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<string> = 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 |
|
||||||
396
docs/en/modules/procgen/index.md
Normal file
396
docs/en/modules/procgen/index.md
Normal file
@@ -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<string>;
|
||||||
|
|
||||||
|
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
|
||||||
322
docs/en/modules/spatial/index.md
Normal file
322
docs/en/modules/spatial/index.md
Normal file
@@ -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<Entity>(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<Entity>(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<T> {
|
||||||
|
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<T>(cellSize?: number): GridSpatialIndex<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<T>(cellSize?: number): GridAOI<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<Entity>;
|
||||||
|
|
||||||
|
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<Player>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.aoi = createGridAOI<Player>(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));
|
||||||
|
```
|
||||||
352
docs/en/modules/timer/index.md
Normal file
352
docs/en/modules/timer/index.md
Normal file
@@ -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<string, SkillData> = 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);
|
||||||
|
```
|
||||||
507
docs/modules/blueprint/index.md
Normal file
507
docs/modules/blueprint/index.md
Normal file
@@ -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<T>(nodeId: string, pinName: string): T;
|
||||||
|
|
||||||
|
// 设置输出值
|
||||||
|
setOutput(nodeId: string, pinName: string, value: unknown): void;
|
||||||
|
|
||||||
|
// 变量访问
|
||||||
|
getVariable<T>(name: string): T;
|
||||||
|
setVariable(name: string, value: unknown): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行结果
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ExecutionResult {
|
||||||
|
outputs?: Record<string, unknown>; // 输出值
|
||||||
|
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<number>(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<BlueprintAsset> {
|
||||||
|
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 中执行重计算
|
||||||
337
docs/modules/fsm/index.md
Normal file
337
docs/modules/fsm/index.md
Normal file
@@ -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<PlayerState>('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<TState, TContext> {
|
||||||
|
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<string, unknown>; // 元数据
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 转换条件
|
||||||
|
|
||||||
|
可以定义带条件的状态转换:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Context {
|
||||||
|
isMoving: boolean;
|
||||||
|
isRunning: boolean;
|
||||||
|
isGrounded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fsm = createStateMachine<PlayerState, Context>('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<TState extends string, TContext = unknown>(
|
||||||
|
initialState: TState,
|
||||||
|
options?: StateMachineOptions<TContext>
|
||||||
|
): IStateMachine<TState, TContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `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<CharacterState, CharacterContext>('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<string>;
|
||||||
|
|
||||||
|
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` - 重置状态机
|
||||||
54
docs/modules/index.md
Normal file
54
docs/modules/index.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# 功能模块
|
||||||
|
|
||||||
|
ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中。
|
||||||
|
|
||||||
|
## 模块列表
|
||||||
|
|
||||||
|
### AI 模块
|
||||||
|
|
||||||
|
| 模块 | 包名 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| [行为树](/modules/behavior-tree/) | `@esengine/behavior-tree` | AI 行为树系统,支持可视化编辑 |
|
||||||
|
| [状态机](/modules/fsm/) | `@esengine/fsm` | 有限状态机,用于角色/AI 状态管理 |
|
||||||
|
|
||||||
|
### 游戏逻辑
|
||||||
|
|
||||||
|
| 模块 | 包名 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| [定时器](/modules/timer/) | `@esengine/timer` | 定时器和冷却系统 |
|
||||||
|
| [空间索引](/modules/spatial/) | `@esengine/spatial` | 空间查询、AOI 兴趣区域管理 |
|
||||||
|
| [寻路系统](/modules/pathfinding/) | `@esengine/pathfinding` | A* 寻路、NavMesh 导航网格 |
|
||||||
|
|
||||||
|
### 工具模块
|
||||||
|
|
||||||
|
| 模块 | 包名 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 |
|
||||||
|
| [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 |
|
||||||
|
|
||||||
|
### 网络模块
|
||||||
|
|
||||||
|
| 模块 | 包名 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
所有模块都可以独立安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装单个模块
|
||||||
|
npm install @esengine/behavior-tree
|
||||||
|
|
||||||
|
# 或使用 CLI 添加到现有项目
|
||||||
|
npx @esengine/cli add behavior-tree
|
||||||
|
```
|
||||||
|
|
||||||
|
## 平台兼容性
|
||||||
|
|
||||||
|
所有功能模块都是纯 TypeScript 实现,兼容:
|
||||||
|
|
||||||
|
- Cocos Creator 3.x
|
||||||
|
- Laya 3.x
|
||||||
|
- Node.js
|
||||||
|
- 浏览器
|
||||||
502
docs/modules/pathfinding/index.md
Normal file
502
docs/modules/pathfinding/index.md
Normal file
@@ -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<string> = 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) | 较低 (多边形数) |
|
||||||
|
| 精度 | 网格对齐 | 连续坐标 |
|
||||||
|
| 动态修改 | 容易 | 需要重建 |
|
||||||
|
| 设置复杂度 | 简单 | 较复杂 |
|
||||||
557
docs/modules/procgen/index.md
Normal file
557
docs/modules/procgen/index.md
Normal file
@@ -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<string>;
|
||||||
|
private lootTables: Map<string, LootItem[]> = 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,控制频率增长速度
|
||||||
600
docs/modules/spatial/index.md
Normal file
600
docs/modules/spatial/index.md
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
# 空间索引系统 (Spatial)
|
||||||
|
|
||||||
|
`@esengine/spatial` 提供了高效的空间查询和索引功能,包括范围查询、最近邻查询、射线检测和 AOI(兴趣区域)管理。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/spatial
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 空间索引
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createGridSpatialIndex } from '@esengine/spatial';
|
||||||
|
|
||||||
|
// 创建空间索引(网格单元格大小为 100)
|
||||||
|
const spatialIndex = createGridSpatialIndex<Entity>(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<Entity>(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<T> {
|
||||||
|
readonly target: T; // 命中的对象
|
||||||
|
readonly point: IVector2; // 命中点坐标
|
||||||
|
readonly normal: IVector2; // 命中点法线
|
||||||
|
readonly distance: number; // 距离射线起点的距离
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 空间索引 API
|
||||||
|
|
||||||
|
### createGridSpatialIndex
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createGridSpatialIndex<T>(cellSize?: number): GridSpatialIndex<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
创建基于均匀网格的空间索引。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `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<T>(cellSize?: number): GridAOI<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
创建基于网格的 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<T> {
|
||||||
|
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<Entity>;
|
||||||
|
|
||||||
|
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<Player>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.aoi = createGridAOI<Player>(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<Entity>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.aoi = createGridAOI<Entity>(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
|
||||||
|
// 如果有大量对象同时移动,考虑禁用事件后批量更新
|
||||||
|
```
|
||||||
479
docs/modules/timer/index.md
Normal file
479
docs/modules/timer/index.md
Normal file
@@ -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<string, SkillData> = 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
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# @esengine/spatial
|
# @esengine/spatial
|
||||||
|
|
||||||
|
## 1.0.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [`d66c180`](https://github.com/esengine/esengine/commit/d66c18041ebffa67b4dd12a026075e22dc1f5d36) Thanks [@esengine](https://github.com/esengine)! - fix(spatial): 修复 GridAOI 可见性更新问题
|
||||||
|
- 修复 `addObserver` 时现有观察者无法检测到新实体的问题
|
||||||
|
- 修复实体远距离移动时观察者可见性未正确更新的问题
|
||||||
|
|
||||||
## 1.0.1
|
## 1.0.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@esengine/spatial",
|
"name": "@esengine/spatial",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"description": "Spatial query and indexing system for ECS Framework / ECS 框架的空间查询和索引系统",
|
"description": "Spatial query and indexing system for ECS Framework / ECS 框架的空间查询和索引系统",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -107,8 +107,13 @@ export class GridAOI<T> implements IAOIManager<T> {
|
|||||||
this._observers.set(entity, data);
|
this._observers.set(entity, data);
|
||||||
this._addToCell(cellKey, data);
|
this._addToCell(cellKey, data);
|
||||||
|
|
||||||
// Initial visibility check
|
// Initial visibility check for this observer
|
||||||
this._updateVisibility(data);
|
this._updateVisibility(data);
|
||||||
|
|
||||||
|
// Notify other observers about this new entity
|
||||||
|
if (data.observable) {
|
||||||
|
this._updateObserversOfEntity(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -398,40 +403,32 @@ export class GridAOI<T> implements IAOIManager<T> {
|
|||||||
* @en Update other observers' visibility of an entity
|
* @en Update other observers' visibility of an entity
|
||||||
*/
|
*/
|
||||||
private _updateObserversOfEntity(movedData: AOIObserverData<T>): void {
|
private _updateObserversOfEntity(movedData: AOIObserverData<T>): void {
|
||||||
const cellRadius = Math.ceil(this._getMaxViewRange() / this._cellSize) + 1;
|
// Check all observers for visibility changes
|
||||||
const centerCell = this._getCellCoords(movedData.position);
|
// This handles both: observers who can now see the entity (enter)
|
||||||
|
// and observers who could see it before but can't anymore (exit)
|
||||||
|
for (const [, otherData] of this._observers) {
|
||||||
|
if (otherData === movedData) continue;
|
||||||
|
|
||||||
for (let dx = -cellRadius; dx <= cellRadius; dx++) {
|
const distSq = distanceSquared(otherData.position, movedData.position);
|
||||||
for (let dy = -cellRadius; dy <= cellRadius; dy++) {
|
const wasVisible = otherData.visibleEntities.has(movedData.entity);
|
||||||
const cellKey = `${centerCell.x + dx},${centerCell.y + dy}`;
|
const isVisible = distSq <= otherData.viewRangeSq;
|
||||||
const cell = this._cells.get(cellKey);
|
|
||||||
if (!cell) continue;
|
|
||||||
|
|
||||||
for (const otherData of cell) {
|
if (isVisible && !wasVisible) {
|
||||||
if (otherData === movedData) continue;
|
otherData.visibleEntities.add(movedData.entity);
|
||||||
|
this._emitEvent({
|
||||||
const distSq = distanceSquared(otherData.position, movedData.position);
|
type: 'enter',
|
||||||
const wasVisible = otherData.visibleEntities.has(movedData.entity);
|
observer: otherData.entity,
|
||||||
const isVisible = distSq <= otherData.viewRangeSq;
|
target: movedData.entity,
|
||||||
|
position: movedData.position
|
||||||
if (isVisible && !wasVisible) {
|
}, otherData);
|
||||||
otherData.visibleEntities.add(movedData.entity);
|
} else if (!isVisible && wasVisible) {
|
||||||
this._emitEvent({
|
otherData.visibleEntities.delete(movedData.entity);
|
||||||
type: 'enter',
|
this._emitEvent({
|
||||||
observer: otherData.entity,
|
type: 'exit',
|
||||||
target: movedData.entity,
|
observer: otherData.entity,
|
||||||
position: movedData.position
|
target: movedData.entity,
|
||||||
}, otherData);
|
position: movedData.position
|
||||||
} else if (!isVisible && wasVisible) {
|
}, otherData);
|
||||||
otherData.visibleEntities.delete(movedData.entity);
|
|
||||||
this._emitEvent({
|
|
||||||
type: 'exit',
|
|
||||||
observer: otherData.entity,
|
|
||||||
target: movedData.entity,
|
|
||||||
position: movedData.position
|
|
||||||
}, otherData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# @esengine/cli
|
# @esengine/cli
|
||||||
|
|
||||||
|
## 1.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- [`d66c180`](https://github.com/esengine/esengine/commit/d66c18041ebffa67b4dd12a026075e22dc1f5d36) Thanks [@esengine](https://github.com/esengine)! - feat(cli): 添加模块管理命令
|
||||||
|
- 新增 `list` 命令:按分类显示可用模块
|
||||||
|
- 新增 `add [modules...]` 命令:添加模块到项目,支持交互式选择
|
||||||
|
- 新增 `remove [modules...]` 命令:从项目移除模块,支持确认提示
|
||||||
|
|
||||||
## 1.1.0
|
## 1.1.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@esengine/cli",
|
"name": "@esengine/cli",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"description": "CLI tool for adding ESEngine ECS framework to existing projects",
|
"description": "CLI tool for adding ESEngine ECS framework to existing projects",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import * as path from 'node:path';
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { getPlatformChoices, getPlatforms, getAdapter } from './adapters/index.js';
|
import { getPlatformChoices, getPlatforms, getAdapter } from './adapters/index.js';
|
||||||
import type { PlatformType, ProjectConfig } from './adapters/types.js';
|
import type { PlatformType, ProjectConfig } from './adapters/types.js';
|
||||||
|
import { AVAILABLE_MODULES, getModuleById, getAllModuleIds, type ModuleInfo } from './modules.js';
|
||||||
|
|
||||||
const VERSION = '1.0.0';
|
const VERSION = '1.1.0';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 打印 Logo
|
* @zh 打印 Logo
|
||||||
@@ -297,12 +298,275 @@ async function initCommand(options: { platform?: string }): Promise<void> {
|
|||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Module Management Commands
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 列出可用模块
|
||||||
|
* @en List available modules
|
||||||
|
*/
|
||||||
|
function listCommand(options: { category?: string }): void {
|
||||||
|
printLogo();
|
||||||
|
|
||||||
|
console.log(chalk.bold(' Available Modules:\n'));
|
||||||
|
|
||||||
|
const categories = ['core', 'ai', 'utility', 'physics', 'rendering', 'network'] as const;
|
||||||
|
const categoryNames: Record<string, string> = {
|
||||||
|
core: '核心 | Core',
|
||||||
|
ai: 'AI',
|
||||||
|
utility: '工具 | Utility',
|
||||||
|
physics: '物理 | Physics',
|
||||||
|
rendering: '渲染 | Rendering',
|
||||||
|
network: '网络 | Network'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const modules = AVAILABLE_MODULES.filter(m => m.category === category);
|
||||||
|
if (modules.length === 0) continue;
|
||||||
|
if (options.category && options.category !== category) continue;
|
||||||
|
|
||||||
|
console.log(chalk.cyan(` ─── ${categoryNames[category]} ───`));
|
||||||
|
for (const mod of modules) {
|
||||||
|
console.log(` ${chalk.green(mod.id.padEnd(15))} ${chalk.gray(mod.package)}`);
|
||||||
|
console.log(` ${' '.repeat(15)} ${chalk.dim(mod.description)}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.gray(' Use `esengine add <module>` to add a module to your project.'));
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 添加模块到项目
|
||||||
|
* @en Add module to project
|
||||||
|
*/
|
||||||
|
async function addCommand(moduleIds: string[], options: { yes?: boolean }): Promise<void> {
|
||||||
|
printLogo();
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const packageJsonPath = path.join(cwd, 'package.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(packageJsonPath)) {
|
||||||
|
console.log(chalk.red(' ✗ No package.json found. Run `npm init` first.'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate modules
|
||||||
|
const validModules: ModuleInfo[] = [];
|
||||||
|
const invalidIds: string[] = [];
|
||||||
|
|
||||||
|
for (const id of moduleIds) {
|
||||||
|
const mod = getModuleById(id);
|
||||||
|
if (mod) {
|
||||||
|
validModules.push(mod);
|
||||||
|
} else {
|
||||||
|
invalidIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidIds.length > 0) {
|
||||||
|
console.log(chalk.red(` ✗ Unknown module(s): ${invalidIds.join(', ')}`));
|
||||||
|
console.log(chalk.gray(` Available: ${getAllModuleIds().join(', ')}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validModules.length === 0) {
|
||||||
|
// Interactive selection
|
||||||
|
const response = await prompts({
|
||||||
|
type: 'multiselect',
|
||||||
|
name: 'modules',
|
||||||
|
message: 'Select modules to add:',
|
||||||
|
choices: AVAILABLE_MODULES.map(m => ({
|
||||||
|
title: `${m.id} - ${m.description}`,
|
||||||
|
value: m.id,
|
||||||
|
selected: false
|
||||||
|
})),
|
||||||
|
min: 1
|
||||||
|
}, {
|
||||||
|
onCancel: () => {
|
||||||
|
console.log(chalk.yellow('\n Cancelled.'));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const id of response.modules) {
|
||||||
|
const mod = getModuleById(id);
|
||||||
|
if (mod) validModules.push(mod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validModules.length === 0) {
|
||||||
|
console.log(chalk.yellow(' No modules selected.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.bold('\n Adding modules:\n'));
|
||||||
|
for (const mod of validModules) {
|
||||||
|
console.log(` ${chalk.green('+')} ${mod.package}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm
|
||||||
|
if (!options.yes) {
|
||||||
|
const confirm = await prompts({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'proceed',
|
||||||
|
message: 'Proceed with installation?',
|
||||||
|
initial: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirm.proceed) {
|
||||||
|
console.log(chalk.yellow('\n Cancelled.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install
|
||||||
|
console.log();
|
||||||
|
const deps: Record<string, string> = {};
|
||||||
|
for (const mod of validModules) {
|
||||||
|
deps[mod.package] = mod.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = installDependencies(cwd, deps);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log(chalk.bold('\n Done!'));
|
||||||
|
console.log(chalk.gray('\n Import modules in your code:'));
|
||||||
|
for (const mod of validModules) {
|
||||||
|
console.log(chalk.cyan(` import { ... } from '${mod.package}';`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 从项目移除模块
|
||||||
|
* @en Remove module from project
|
||||||
|
*/
|
||||||
|
async function removeCommand(moduleIds: string[], options: { yes?: boolean }): Promise<void> {
|
||||||
|
printLogo();
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const packageJsonPath = path.join(cwd, 'package.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(packageJsonPath)) {
|
||||||
|
console.log(chalk.red(' ✗ No package.json found.'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
const deps = pkg.dependencies || {};
|
||||||
|
|
||||||
|
// Find installed modules
|
||||||
|
const installed = AVAILABLE_MODULES.filter(m => deps[m.package]);
|
||||||
|
|
||||||
|
if (installed.length === 0) {
|
||||||
|
console.log(chalk.yellow(' No ESEngine modules installed.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate modules to remove
|
||||||
|
let toRemove: ModuleInfo[] = [];
|
||||||
|
|
||||||
|
if (moduleIds.length === 0) {
|
||||||
|
// Interactive selection
|
||||||
|
const response = await prompts({
|
||||||
|
type: 'multiselect',
|
||||||
|
name: 'modules',
|
||||||
|
message: 'Select modules to remove:',
|
||||||
|
choices: installed.map(m => ({
|
||||||
|
title: `${m.id} - ${m.package}`,
|
||||||
|
value: m.id
|
||||||
|
})),
|
||||||
|
min: 1
|
||||||
|
}, {
|
||||||
|
onCancel: () => {
|
||||||
|
console.log(chalk.yellow('\n Cancelled.'));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const id of response.modules) {
|
||||||
|
const mod = getModuleById(id);
|
||||||
|
if (mod) toRemove.push(mod);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const id of moduleIds) {
|
||||||
|
const mod = getModuleById(id);
|
||||||
|
if (mod && deps[mod.package]) {
|
||||||
|
toRemove.push(mod);
|
||||||
|
} else if (!mod) {
|
||||||
|
console.log(chalk.yellow(` ⚠ Unknown module: ${id}`));
|
||||||
|
} else {
|
||||||
|
console.log(chalk.yellow(` ⚠ Module not installed: ${id}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toRemove.length === 0) {
|
||||||
|
console.log(chalk.yellow(' No modules to remove.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.bold('\n Removing modules:\n'));
|
||||||
|
for (const mod of toRemove) {
|
||||||
|
console.log(` ${chalk.red('-')} ${mod.package}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm
|
||||||
|
if (!options.yes) {
|
||||||
|
const confirm = await prompts({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'proceed',
|
||||||
|
message: 'Proceed with removal?',
|
||||||
|
initial: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirm.proceed) {
|
||||||
|
console.log(chalk.yellow('\n Cancelled.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from package.json
|
||||||
|
for (const mod of toRemove) {
|
||||||
|
delete deps[mod.package];
|
||||||
|
}
|
||||||
|
pkg.dependencies = deps;
|
||||||
|
fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
// Run uninstall
|
||||||
|
const pm = detectPackageManager(cwd);
|
||||||
|
const packages = toRemove.map(m => m.package).join(' ');
|
||||||
|
const uninstallCmd = pm === 'pnpm'
|
||||||
|
? `pnpm remove ${packages}`
|
||||||
|
: pm === 'yarn'
|
||||||
|
? `yarn remove ${packages}`
|
||||||
|
: `npm uninstall ${packages}`;
|
||||||
|
|
||||||
|
console.log(chalk.gray(`\n Running ${uninstallCmd}...`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(uninstallCmd, { cwd, stdio: 'inherit' });
|
||||||
|
console.log(chalk.bold('\n Done!'));
|
||||||
|
} catch {
|
||||||
|
console.log(chalk.yellow(`\n ⚠ Failed to run uninstall. Modules removed from package.json.`));
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CLI Setup
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
// Setup CLI
|
// Setup CLI
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name('esengine')
|
.name('esengine')
|
||||||
.description('CLI tool for adding ESEngine ECS to your project')
|
.description('CLI tool for ESEngine ECS framework')
|
||||||
.version(VERSION);
|
.version(VERSION);
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -311,10 +575,30 @@ program
|
|||||||
.option('-p, --platform <platform>', 'Target platform (cocos, cocos2, laya, nodejs)')
|
.option('-p, --platform <platform>', 'Target platform (cocos, cocos2, laya, nodejs)')
|
||||||
.action(initCommand);
|
.action(initCommand);
|
||||||
|
|
||||||
// Default command: run init
|
program
|
||||||
|
.command('list')
|
||||||
|
.alias('ls')
|
||||||
|
.description('List available modules')
|
||||||
|
.option('-c, --category <category>', 'Filter by category (core, ai, utility, physics, rendering, network)')
|
||||||
|
.action(listCommand);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('add [modules...]')
|
||||||
|
.description('Add modules to your project')
|
||||||
|
.option('-y, --yes', 'Skip confirmation')
|
||||||
|
.action(addCommand);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('remove [modules...]')
|
||||||
|
.alias('rm')
|
||||||
|
.description('Remove modules from your project')
|
||||||
|
.option('-y, --yes', 'Skip confirmation')
|
||||||
|
.action(removeCommand);
|
||||||
|
|
||||||
|
// Default command: show help
|
||||||
program
|
program
|
||||||
.action(() => {
|
.action(() => {
|
||||||
initCommand({});
|
program.help();
|
||||||
});
|
});
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|||||||
122
packages/tools/cli/src/modules.ts
Normal file
122
packages/tools/cli/src/modules.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* @zh ESEngine 可用模块定义
|
||||||
|
* @en ESEngine Available Modules Definition
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModuleInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
package: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
category: 'core' | 'ai' | 'physics' | 'rendering' | 'network' | 'utility';
|
||||||
|
dependencies?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 可用模块列表
|
||||||
|
* @en Available modules list
|
||||||
|
*/
|
||||||
|
export const AVAILABLE_MODULES: ModuleInfo[] = [
|
||||||
|
// Core
|
||||||
|
{
|
||||||
|
id: 'core',
|
||||||
|
name: 'ECS Core',
|
||||||
|
package: '@esengine/ecs-framework',
|
||||||
|
version: 'latest',
|
||||||
|
description: 'ECS 核心框架 | Core ECS framework',
|
||||||
|
category: 'core'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'math',
|
||||||
|
name: 'Math',
|
||||||
|
package: '@esengine/ecs-framework-math',
|
||||||
|
version: 'latest',
|
||||||
|
description: '数学库 (向量、矩阵) | Math library (vectors, matrices)',
|
||||||
|
category: 'core'
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI
|
||||||
|
{
|
||||||
|
id: 'fsm',
|
||||||
|
name: 'FSM',
|
||||||
|
package: '@esengine/fsm',
|
||||||
|
version: 'latest',
|
||||||
|
description: '有限状态机 | Finite State Machine',
|
||||||
|
category: 'ai'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'behavior-tree',
|
||||||
|
name: 'Behavior Tree',
|
||||||
|
package: '@esengine/behavior-tree',
|
||||||
|
version: 'latest',
|
||||||
|
description: '行为树 AI 系统 | Behavior Tree AI system',
|
||||||
|
category: 'ai'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pathfinding',
|
||||||
|
name: 'Pathfinding',
|
||||||
|
package: '@esengine/pathfinding',
|
||||||
|
version: 'latest',
|
||||||
|
description: '寻路系统 (A*, NavMesh) | Pathfinding (A*, NavMesh)',
|
||||||
|
category: 'ai'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
{
|
||||||
|
id: 'timer',
|
||||||
|
name: 'Timer',
|
||||||
|
package: '@esengine/timer',
|
||||||
|
version: 'latest',
|
||||||
|
description: '定时器和冷却系统 | Timer and cooldown system',
|
||||||
|
category: 'utility'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spatial',
|
||||||
|
name: 'Spatial',
|
||||||
|
package: '@esengine/spatial',
|
||||||
|
version: 'latest',
|
||||||
|
description: '空间索引和 AOI 系统 | Spatial index and AOI system',
|
||||||
|
category: 'utility'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'procgen',
|
||||||
|
name: 'Procgen',
|
||||||
|
package: '@esengine/procgen',
|
||||||
|
version: 'latest',
|
||||||
|
description: '程序化生成 (噪声、随机) | Procedural generation',
|
||||||
|
category: 'utility'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blueprint',
|
||||||
|
name: 'Blueprint',
|
||||||
|
package: '@esengine/blueprint',
|
||||||
|
version: 'latest',
|
||||||
|
description: '可视化脚本系统 | Visual scripting system',
|
||||||
|
category: 'utility'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取模块信息
|
||||||
|
* @en Get module info by id
|
||||||
|
*/
|
||||||
|
export function getModuleById(id: string): ModuleInfo | undefined {
|
||||||
|
return AVAILABLE_MODULES.find(m => m.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 按分类获取模块
|
||||||
|
* @en Get modules by category
|
||||||
|
*/
|
||||||
|
export function getModulesByCategory(category: ModuleInfo['category']): ModuleInfo[] {
|
||||||
|
return AVAILABLE_MODULES.filter(m => m.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取所有模块 ID
|
||||||
|
* @en Get all module IDs
|
||||||
|
*/
|
||||||
|
export function getAllModuleIds(): string[] {
|
||||||
|
return AVAILABLE_MODULES.map(m => m.id);
|
||||||
|
}
|
||||||
8
packages/tools/demos/CHANGELOG.md
Normal file
8
packages/tools/demos/CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# @esengine/demos
|
||||||
|
|
||||||
|
## 1.0.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [[`d66c180`](https://github.com/esengine/esengine/commit/d66c18041ebffa67b4dd12a026075e22dc1f5d36)]:
|
||||||
|
- @esengine/spatial@1.0.2
|
||||||
26
packages/tools/demos/package.json
Normal file
26
packages/tools/demos/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@esengine/demos",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Demo tests for ESEngine modules documentation",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "tsx src/index.ts",
|
||||||
|
"test:timer": "tsx src/timer.demo.ts",
|
||||||
|
"test:fsm": "tsx src/fsm.demo.ts",
|
||||||
|
"test:pathfinding": "tsx src/pathfinding.demo.ts",
|
||||||
|
"test:procgen": "tsx src/procgen.demo.ts",
|
||||||
|
"test:spatial": "tsx src/spatial.demo.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@esengine/timer": "workspace:*",
|
||||||
|
"@esengine/fsm": "workspace:*",
|
||||||
|
"@esengine/pathfinding": "workspace:*",
|
||||||
|
"@esengine/procgen": "workspace:*",
|
||||||
|
"@esengine/spatial": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
163
packages/tools/demos/src/fsm.demo.ts
Normal file
163
packages/tools/demos/src/fsm.demo.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* FSM Module Demo - Tests APIs from docs/modules/fsm/index.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createStateMachine } from '@esengine/fsm';
|
||||||
|
import { assert, section, demoHeader, demoFooter } from './utils.js';
|
||||||
|
|
||||||
|
type PlayerState = 'idle' | 'walk' | 'run' | 'jump';
|
||||||
|
|
||||||
|
export async function runFSMDemo(): Promise<void> {
|
||||||
|
demoHeader('FSM Module Demo');
|
||||||
|
|
||||||
|
// 1. Basic Creation
|
||||||
|
section('1. createStateMachine()');
|
||||||
|
const fsm = createStateMachine<PlayerState>('idle');
|
||||||
|
assert(fsm !== null, 'State machine created');
|
||||||
|
assert(fsm.current === 'idle', 'Initial state is idle');
|
||||||
|
|
||||||
|
// 2. Define States
|
||||||
|
section('2. defineState()');
|
||||||
|
let enterCalled = false;
|
||||||
|
let exitCalled = false;
|
||||||
|
let updateCalled = false;
|
||||||
|
|
||||||
|
fsm.defineState('idle', {
|
||||||
|
onEnter: () => { enterCalled = true; },
|
||||||
|
onExit: () => { exitCalled = true; },
|
||||||
|
onUpdate: () => { updateCalled = true; }
|
||||||
|
});
|
||||||
|
fsm.defineState('walk', {});
|
||||||
|
fsm.defineState('run', {});
|
||||||
|
fsm.defineState('jump', {});
|
||||||
|
|
||||||
|
assert(fsm.hasState('idle'), 'hasState() returns true');
|
||||||
|
assert(fsm.hasState('walk'), 'walk state exists');
|
||||||
|
|
||||||
|
// 3. Manual Transition
|
||||||
|
section('3. transition()');
|
||||||
|
fsm.transition('walk');
|
||||||
|
assert(fsm.current === 'walk', 'Transitioned to walk');
|
||||||
|
assert(exitCalled, 'onExit called on idle');
|
||||||
|
assert(fsm.previous === 'idle', 'previous is idle');
|
||||||
|
|
||||||
|
// 4. State with Context
|
||||||
|
section('4. Context Support');
|
||||||
|
interface Context {
|
||||||
|
speed: number;
|
||||||
|
isMoving: boolean;
|
||||||
|
}
|
||||||
|
const fsmCtx = createStateMachine<PlayerState, Context>('idle', {
|
||||||
|
context: { speed: 0, isMoving: false }
|
||||||
|
});
|
||||||
|
fsmCtx.defineState('idle', {
|
||||||
|
onEnter: (ctx) => { ctx.speed = 0; }
|
||||||
|
});
|
||||||
|
fsmCtx.defineState('walk', {
|
||||||
|
onEnter: (ctx) => { ctx.speed = 100; }
|
||||||
|
});
|
||||||
|
|
||||||
|
fsmCtx.transition('walk');
|
||||||
|
assert(fsmCtx.context.speed === 100, 'Context updated on enter');
|
||||||
|
|
||||||
|
// 5. Transition Conditions
|
||||||
|
section('5. defineTransition() with conditions');
|
||||||
|
const fsmTrans = createStateMachine<PlayerState, Context>('idle', {
|
||||||
|
context: { speed: 0, isMoving: false }
|
||||||
|
});
|
||||||
|
fsmTrans.defineState('idle', {});
|
||||||
|
fsmTrans.defineState('walk', {});
|
||||||
|
|
||||||
|
fsmTrans.defineTransition('idle', 'walk', (ctx) => ctx.isMoving);
|
||||||
|
|
||||||
|
fsmTrans.evaluateTransitions();
|
||||||
|
assert(fsmTrans.current === 'idle', 'No transition when condition false');
|
||||||
|
|
||||||
|
fsmTrans.context.isMoving = true;
|
||||||
|
fsmTrans.evaluateTransitions();
|
||||||
|
assert(fsmTrans.current === 'walk', 'Transitions when condition true');
|
||||||
|
|
||||||
|
// 6. Transition Priority
|
||||||
|
section('6. Transition Priority');
|
||||||
|
const fsmPri = createStateMachine<'a' | 'b' | 'c'>('a');
|
||||||
|
fsmPri.defineState('a', {});
|
||||||
|
fsmPri.defineState('b', {});
|
||||||
|
fsmPri.defineState('c', {});
|
||||||
|
|
||||||
|
fsmPri.defineTransition('a', 'b', () => true, 1);
|
||||||
|
fsmPri.defineTransition('a', 'c', () => true, 10);
|
||||||
|
|
||||||
|
fsmPri.evaluateTransitions();
|
||||||
|
assert(fsmPri.current === 'c', 'Higher priority (10) wins');
|
||||||
|
|
||||||
|
// 7. Update
|
||||||
|
section('7. update()');
|
||||||
|
const fsmUpdate = createStateMachine<PlayerState>('idle');
|
||||||
|
let updateCount = 0;
|
||||||
|
fsmUpdate.defineState('idle', {
|
||||||
|
onUpdate: () => { updateCount++; }
|
||||||
|
});
|
||||||
|
fsmUpdate.update(16);
|
||||||
|
fsmUpdate.update(16);
|
||||||
|
assert(updateCount === 2, 'onUpdate called on each update');
|
||||||
|
|
||||||
|
// 8. Event Listeners
|
||||||
|
section('8. Event Listeners');
|
||||||
|
const fsmEvents = createStateMachine<PlayerState>('idle');
|
||||||
|
fsmEvents.defineState('idle', {});
|
||||||
|
fsmEvents.defineState('walk', {});
|
||||||
|
|
||||||
|
let enterEvent = false;
|
||||||
|
let exitEvent = false;
|
||||||
|
let changeEvent = false;
|
||||||
|
|
||||||
|
fsmEvents.onEnter('walk', () => { enterEvent = true; });
|
||||||
|
fsmEvents.onExit('idle', () => { exitEvent = true; });
|
||||||
|
fsmEvents.onChange(() => { changeEvent = true; });
|
||||||
|
|
||||||
|
fsmEvents.transition('walk');
|
||||||
|
assert(enterEvent, 'onEnter listener called');
|
||||||
|
assert(exitEvent, 'onExit listener called');
|
||||||
|
assert(changeEvent, 'onChange listener called');
|
||||||
|
|
||||||
|
// 9. getStates / getTransitionsFrom
|
||||||
|
section('9. Query Methods');
|
||||||
|
const states = fsmEvents.getStates();
|
||||||
|
assert(states.length >= 2, 'getStates() returns states');
|
||||||
|
|
||||||
|
// 10. canTransition
|
||||||
|
section('10. canTransition()');
|
||||||
|
const fsmCan = createStateMachine<PlayerState, Context>('idle', {
|
||||||
|
context: { speed: 0, isMoving: false }
|
||||||
|
});
|
||||||
|
fsmCan.defineState('idle', {});
|
||||||
|
fsmCan.defineState('walk', {});
|
||||||
|
fsmCan.defineTransition('idle', 'walk', (ctx) => ctx.isMoving);
|
||||||
|
|
||||||
|
assert(!fsmCan.canTransition('walk'), 'Cannot transition when condition false');
|
||||||
|
fsmCan.context.isMoving = true;
|
||||||
|
assert(fsmCan.canTransition('walk'), 'Can transition when condition true');
|
||||||
|
|
||||||
|
// 11. Reset
|
||||||
|
section('11. reset()');
|
||||||
|
fsmCan.transition('walk');
|
||||||
|
fsmCan.reset('idle');
|
||||||
|
assert(fsmCan.current === 'idle', 'Reset to idle');
|
||||||
|
|
||||||
|
// 12. History
|
||||||
|
section('12. getHistory()');
|
||||||
|
const fsmHist = createStateMachine<PlayerState>('idle', { enableHistory: true });
|
||||||
|
fsmHist.defineState('idle', {});
|
||||||
|
fsmHist.defineState('walk', {});
|
||||||
|
fsmHist.defineState('run', {});
|
||||||
|
|
||||||
|
fsmHist.transition('walk');
|
||||||
|
fsmHist.transition('run');
|
||||||
|
|
||||||
|
const history = fsmHist.getHistory();
|
||||||
|
assert(history.length >= 2, 'History recorded');
|
||||||
|
|
||||||
|
demoFooter('FSM Demo');
|
||||||
|
}
|
||||||
|
|
||||||
|
runFSMDemo().catch(console.error);
|
||||||
75
packages/tools/demos/src/index.ts
Normal file
75
packages/tools/demos/src/index.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* ESEngine Module Demos - Run all demos to verify documentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { runTimerDemo } from './timer.demo.js';
|
||||||
|
import { runFSMDemo } from './fsm.demo.js';
|
||||||
|
import { runPathfindingDemo } from './pathfinding.demo.js';
|
||||||
|
import { runProcgenDemo } from './procgen.demo.js';
|
||||||
|
import { runSpatialDemo } from './spatial.demo.js';
|
||||||
|
|
||||||
|
async function runAllDemos(): Promise<void> {
|
||||||
|
console.log('\n');
|
||||||
|
console.log('╔═══════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ ESEngine Module Documentation Tests ║');
|
||||||
|
console.log('╚═══════════════════════════════════════════════════════════╝');
|
||||||
|
console.log('\n');
|
||||||
|
|
||||||
|
const demos = [
|
||||||
|
{ name: 'Timer', fn: runTimerDemo },
|
||||||
|
{ name: 'FSM', fn: runFSMDemo },
|
||||||
|
{ name: 'Pathfinding', fn: runPathfindingDemo },
|
||||||
|
{ name: 'Procgen', fn: runProcgenDemo },
|
||||||
|
{ name: 'Spatial', fn: runSpatialDemo },
|
||||||
|
];
|
||||||
|
|
||||||
|
const results: { name: string; passed: boolean; error?: string }[] = [];
|
||||||
|
|
||||||
|
for (const demo of demos) {
|
||||||
|
try {
|
||||||
|
await demo.fn();
|
||||||
|
results.push({ name: demo.name, passed: true });
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
name: demo.name,
|
||||||
|
passed: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('\n');
|
||||||
|
console.log('╔═══════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ Summary ║');
|
||||||
|
console.log('╚═══════════════════════════════════════════════════════════╝');
|
||||||
|
console.log('\n');
|
||||||
|
|
||||||
|
let allPassed = true;
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.passed) {
|
||||||
|
console.log(` ✓ ${result.name}: PASSED`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✗ ${result.name}: FAILED - ${result.error}`);
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n');
|
||||||
|
if (allPassed) {
|
||||||
|
console.log(' ══════════════════════════════════════');
|
||||||
|
console.log(' ALL DOCUMENTATION TESTS PASSED ✓');
|
||||||
|
console.log(' ══════════════════════════════════════');
|
||||||
|
} else {
|
||||||
|
console.log(' ══════════════════════════════════════');
|
||||||
|
console.log(' SOME TESTS FAILED ✗');
|
||||||
|
console.log(' ══════════════════════════════════════');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
runAllDemos().catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
152
packages/tools/demos/src/pathfinding.demo.ts
Normal file
152
packages/tools/demos/src/pathfinding.demo.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Pathfinding Module Demo - Tests APIs from docs/modules/pathfinding/index.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createGridMap,
|
||||||
|
createAStarPathfinder,
|
||||||
|
createLineOfSightSmoother,
|
||||||
|
createCatmullRomSmoother,
|
||||||
|
manhattanDistance,
|
||||||
|
octileDistance
|
||||||
|
} from '@esengine/pathfinding';
|
||||||
|
import { assert, section, demoHeader, demoFooter } from './utils.js';
|
||||||
|
|
||||||
|
export async function runPathfindingDemo(): Promise<void> {
|
||||||
|
demoHeader('Pathfinding Module Demo');
|
||||||
|
|
||||||
|
// 1. Create Grid Map
|
||||||
|
section('1. createGridMap()');
|
||||||
|
const grid = createGridMap(20, 20);
|
||||||
|
assert(grid !== null, 'Grid created');
|
||||||
|
assert(grid.width === 20, 'Width is 20');
|
||||||
|
assert(grid.height === 20, 'Height is 20');
|
||||||
|
|
||||||
|
// 2. Walkability
|
||||||
|
section('2. setWalkable() / isWalkable()');
|
||||||
|
assert(grid.isWalkable(5, 5), 'Initially walkable');
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
assert(!grid.isWalkable(5, 5), 'Set to not walkable');
|
||||||
|
grid.setWalkable(5, 5, true);
|
||||||
|
assert(grid.isWalkable(5, 5), 'Restored to walkable');
|
||||||
|
|
||||||
|
// 3. Set Obstacles
|
||||||
|
section('3. Setting Obstacles');
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
grid.setWalkable(5, 6, false);
|
||||||
|
grid.setWalkable(5, 7, false);
|
||||||
|
assert(!grid.isWalkable(5, 6), 'Obstacle set');
|
||||||
|
|
||||||
|
// 4. Create Pathfinder
|
||||||
|
section('4. createAStarPathfinder()');
|
||||||
|
const pathfinder = createAStarPathfinder(grid);
|
||||||
|
assert(pathfinder !== null, 'Pathfinder created');
|
||||||
|
|
||||||
|
// 5. Find Path
|
||||||
|
section('5. findPath()');
|
||||||
|
const result = pathfinder.findPath(0, 0, 15, 15);
|
||||||
|
assert(result.found, 'Path found');
|
||||||
|
assert(result.path.length > 0, `Path has ${result.path.length} points`);
|
||||||
|
assert(result.cost > 0, `Path cost: ${result.cost.toFixed(2)}`);
|
||||||
|
assert(result.nodesSearched > 0, `Searched ${result.nodesSearched} nodes`);
|
||||||
|
|
||||||
|
// 6. Path Blocked
|
||||||
|
section('6. Path Blocked');
|
||||||
|
// Create a wall
|
||||||
|
for (let y = 0; y < 20; y++) {
|
||||||
|
grid.setWalkable(10, y, false);
|
||||||
|
}
|
||||||
|
const blocked = pathfinder.findPath(0, 0, 15, 15);
|
||||||
|
assert(!blocked.found, 'No path when fully blocked');
|
||||||
|
|
||||||
|
// Clear wall
|
||||||
|
for (let y = 0; y < 20; y++) {
|
||||||
|
grid.setWalkable(10, y, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Movement Cost
|
||||||
|
section('7. setCost()');
|
||||||
|
const gridCost = createGridMap(10, 10);
|
||||||
|
gridCost.setCost(5, 5, 10); // High cost tile
|
||||||
|
const costResult = createAStarPathfinder(gridCost).findPath(0, 0, 9, 9);
|
||||||
|
assert(costResult.found, 'Path found with cost');
|
||||||
|
|
||||||
|
// 8. Heuristics
|
||||||
|
section('8. Heuristic Functions');
|
||||||
|
const d1 = manhattanDistance({ x: 0, y: 0 }, { x: 3, y: 4 });
|
||||||
|
assert(d1 === 7, `Manhattan distance: ${d1}`);
|
||||||
|
|
||||||
|
const d2 = octileDistance({ x: 0, y: 0 }, { x: 3, y: 4 });
|
||||||
|
assert(d2 > 0, `Octile distance: ${d2.toFixed(2)}`);
|
||||||
|
|
||||||
|
// 9. Grid Options
|
||||||
|
section('9. Grid Options');
|
||||||
|
const gridOpts = createGridMap(10, 10, {
|
||||||
|
allowDiagonal: false,
|
||||||
|
heuristic: manhattanDistance
|
||||||
|
});
|
||||||
|
assert(gridOpts !== null, 'Grid with options created');
|
||||||
|
|
||||||
|
// 10. Path Smoothing - Line of Sight
|
||||||
|
section('10. Line of Sight Smoother');
|
||||||
|
const gridSmooth = createGridMap(20, 20);
|
||||||
|
const pf = createAStarPathfinder(gridSmooth);
|
||||||
|
const rawPath = pf.findPath(0, 0, 10, 10);
|
||||||
|
|
||||||
|
const losSmoother = createLineOfSightSmoother();
|
||||||
|
const smoothed = losSmoother.smooth(rawPath.path, gridSmooth);
|
||||||
|
assert(smoothed.length <= rawPath.path.length, `Smoothed: ${rawPath.path.length} -> ${smoothed.length} points`);
|
||||||
|
|
||||||
|
// 11. Catmull-Rom Smoother
|
||||||
|
section('11. Catmull-Rom Smoother');
|
||||||
|
const crSmoother = createCatmullRomSmoother(5, 0.5);
|
||||||
|
const curved = crSmoother.smooth(rawPath.path, gridSmooth);
|
||||||
|
assert(curved.length >= rawPath.path.length, `Curved path has ${curved.length} points`);
|
||||||
|
|
||||||
|
// 12. loadFromArray
|
||||||
|
section('12. loadFromArray()');
|
||||||
|
const gridArr = createGridMap(5, 3);
|
||||||
|
gridArr.loadFromArray([
|
||||||
|
[0, 0, 0, 1, 0],
|
||||||
|
[0, 1, 0, 1, 0],
|
||||||
|
[0, 1, 0, 0, 0]
|
||||||
|
]);
|
||||||
|
assert(!gridArr.isWalkable(3, 0), 'Loaded obstacle at (3,0)');
|
||||||
|
assert(!gridArr.isWalkable(1, 1), 'Loaded obstacle at (1,1)');
|
||||||
|
assert(gridArr.isWalkable(0, 0), 'Loaded walkable at (0,0)');
|
||||||
|
|
||||||
|
// 13. loadFromString
|
||||||
|
section('13. loadFromString()');
|
||||||
|
const gridStr = createGridMap(5, 3);
|
||||||
|
gridStr.loadFromString(`
|
||||||
|
.....
|
||||||
|
.#.#.
|
||||||
|
.#...
|
||||||
|
`);
|
||||||
|
assert(!gridStr.isWalkable(1, 1), 'Loaded # as obstacle');
|
||||||
|
assert(gridStr.isWalkable(0, 0), 'Loaded . as walkable');
|
||||||
|
|
||||||
|
// 14. setRectWalkable
|
||||||
|
section('14. setRectWalkable()');
|
||||||
|
const gridRect = createGridMap(10, 10);
|
||||||
|
gridRect.setRectWalkable(2, 2, 4, 4, false);
|
||||||
|
assert(!gridRect.isWalkable(3, 3), 'Rect set as obstacle');
|
||||||
|
assert(gridRect.isWalkable(0, 0), 'Outside rect is walkable');
|
||||||
|
|
||||||
|
// 15. Pathfinder Options
|
||||||
|
section('15. Pathfinder Options');
|
||||||
|
const limitedResult = pathfinder.findPath(0, 0, 15, 15, {
|
||||||
|
maxNodes: 100,
|
||||||
|
heuristicWeight: 1.5
|
||||||
|
});
|
||||||
|
assert(limitedResult !== null, 'findPath with options works');
|
||||||
|
|
||||||
|
// 16. Reset Grid
|
||||||
|
section('16. reset()');
|
||||||
|
grid.reset();
|
||||||
|
assert(grid.isWalkable(5, 5), 'Grid reset - all walkable');
|
||||||
|
|
||||||
|
demoFooter('Pathfinding Demo');
|
||||||
|
}
|
||||||
|
|
||||||
|
runPathfindingDemo().catch(console.error);
|
||||||
196
packages/tools/demos/src/procgen.demo.ts
Normal file
196
packages/tools/demos/src/procgen.demo.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Procgen Module Demo - Tests APIs from docs/modules/procgen/index.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPerlinNoise,
|
||||||
|
createSimplexNoise,
|
||||||
|
createWorleyNoise,
|
||||||
|
createFBM,
|
||||||
|
createSeededRandom,
|
||||||
|
createWeightedRandom,
|
||||||
|
shuffle,
|
||||||
|
shuffleCopy,
|
||||||
|
pickOne,
|
||||||
|
sample,
|
||||||
|
sampleWithReplacement,
|
||||||
|
weightedPick,
|
||||||
|
weightedPickFromMap
|
||||||
|
} from '@esengine/procgen';
|
||||||
|
import { assert, section, demoHeader, demoFooter } from './utils.js';
|
||||||
|
|
||||||
|
export async function runProcgenDemo(): Promise<void> {
|
||||||
|
demoHeader('Procgen Module Demo');
|
||||||
|
|
||||||
|
// 1. Perlin Noise
|
||||||
|
section('1. createPerlinNoise()');
|
||||||
|
const perlin = createPerlinNoise(12345);
|
||||||
|
assert(perlin !== null, 'Perlin noise created');
|
||||||
|
|
||||||
|
const val2d = perlin.noise2D(0.5, 0.5);
|
||||||
|
assert(val2d >= -1 && val2d <= 1, `2D noise value in [-1,1]: ${val2d.toFixed(3)}`);
|
||||||
|
|
||||||
|
const val3d = perlin.noise3D(0.5, 0.5, 0.5);
|
||||||
|
assert(val3d >= -1 && val3d <= 1, `3D noise value in [-1,1]: ${val3d.toFixed(3)}`);
|
||||||
|
|
||||||
|
// 2. Simplex Noise
|
||||||
|
section('2. createSimplexNoise()');
|
||||||
|
const simplex = createSimplexNoise(12345);
|
||||||
|
const sval = simplex.noise2D(0.5, 0.5);
|
||||||
|
assert(sval >= -1 && sval <= 1, `Simplex value: ${sval.toFixed(3)}`);
|
||||||
|
|
||||||
|
// 3. Worley Noise
|
||||||
|
section('3. createWorleyNoise()');
|
||||||
|
const worley = createWorleyNoise(12345);
|
||||||
|
const wval = worley.noise2D(0.5, 0.5);
|
||||||
|
assert(wval >= 0, `Worley distance: ${wval.toFixed(3)}`);
|
||||||
|
|
||||||
|
// 4. FBM
|
||||||
|
section('4. createFBM()');
|
||||||
|
const fbm = createFBM(perlin, {
|
||||||
|
octaves: 6,
|
||||||
|
lacunarity: 2.0,
|
||||||
|
persistence: 0.5
|
||||||
|
});
|
||||||
|
const fbmVal = fbm.noise2D(0.1, 0.1);
|
||||||
|
assert(typeof fbmVal === 'number', `FBM value: ${fbmVal.toFixed(3)}`);
|
||||||
|
|
||||||
|
const ridged = fbm.ridged2D(0.1, 0.1);
|
||||||
|
assert(typeof ridged === 'number', `Ridged FBM: ${ridged.toFixed(3)}`);
|
||||||
|
|
||||||
|
const turb = fbm.turbulence2D(0.1, 0.1);
|
||||||
|
assert(turb >= 0, `Turbulence: ${turb.toFixed(3)}`);
|
||||||
|
|
||||||
|
// 5. Seeded Random
|
||||||
|
section('5. createSeededRandom()');
|
||||||
|
const rng = createSeededRandom(42);
|
||||||
|
assert(rng !== null, 'RNG created');
|
||||||
|
|
||||||
|
const r1 = rng.next();
|
||||||
|
assert(r1 >= 0 && r1 < 1, `next() in [0,1): ${r1.toFixed(3)}`);
|
||||||
|
|
||||||
|
const r2 = rng.nextInt(1, 10);
|
||||||
|
assert(r2 >= 1 && r2 <= 10, `nextInt(1,10): ${r2}`);
|
||||||
|
|
||||||
|
const r3 = rng.nextFloat(0, 100);
|
||||||
|
assert(r3 >= 0 && r3 < 100, `nextFloat(0,100): ${r3.toFixed(2)}`);
|
||||||
|
|
||||||
|
const r4 = rng.nextBool();
|
||||||
|
assert(typeof r4 === 'boolean', `nextBool(): ${r4}`);
|
||||||
|
|
||||||
|
const r5 = rng.nextBool(0.9);
|
||||||
|
assert(typeof r5 === 'boolean', `nextBool(0.9): ${r5}`);
|
||||||
|
|
||||||
|
// 6. Deterministic
|
||||||
|
section('6. Deterministic Sequences');
|
||||||
|
const rng1 = createSeededRandom(42);
|
||||||
|
const rng2 = createSeededRandom(42);
|
||||||
|
const seq1 = [rng1.next(), rng1.next(), rng1.next()];
|
||||||
|
const seq2 = [rng2.next(), rng2.next(), rng2.next()];
|
||||||
|
assert(seq1[0] === seq2[0] && seq1[1] === seq2[1] && seq1[2] === seq2[2],
|
||||||
|
'Same seed produces same sequence');
|
||||||
|
|
||||||
|
// 7. Distributions
|
||||||
|
section('7. Distribution Methods');
|
||||||
|
const rngDist = createSeededRandom(42);
|
||||||
|
|
||||||
|
const gauss = rngDist.nextGaussian();
|
||||||
|
assert(typeof gauss === 'number', `nextGaussian(): ${gauss.toFixed(3)}`);
|
||||||
|
|
||||||
|
const gauss2 = rngDist.nextGaussian(100, 15);
|
||||||
|
assert(typeof gauss2 === 'number', `nextGaussian(100,15): ${gauss2.toFixed(1)}`);
|
||||||
|
|
||||||
|
const exp = rngDist.nextExponential();
|
||||||
|
assert(exp >= 0, `nextExponential(): ${exp.toFixed(3)}`);
|
||||||
|
|
||||||
|
// 8. Geometry Methods
|
||||||
|
section('8. Geometry Methods');
|
||||||
|
const rngGeo = createSeededRandom(42);
|
||||||
|
|
||||||
|
const pointCircle = rngGeo.nextPointInCircle(50);
|
||||||
|
assert(pointCircle.x !== undefined && pointCircle.y !== undefined,
|
||||||
|
`nextPointInCircle: (${pointCircle.x.toFixed(1)}, ${pointCircle.y.toFixed(1)})`);
|
||||||
|
|
||||||
|
const pointOnCircle = rngGeo.nextPointOnCircle(50);
|
||||||
|
const dist = Math.sqrt(pointOnCircle.x ** 2 + pointOnCircle.y ** 2);
|
||||||
|
assert(Math.abs(dist - 50) < 0.01, `nextPointOnCircle radius ~50: ${dist.toFixed(2)}`);
|
||||||
|
|
||||||
|
const dir = rngGeo.nextDirection2D();
|
||||||
|
const len = Math.sqrt(dir.x ** 2 + dir.y ** 2);
|
||||||
|
assert(Math.abs(len - 1) < 0.01, `nextDirection2D length ~1: ${len.toFixed(3)}`);
|
||||||
|
|
||||||
|
// 9. Weighted Random
|
||||||
|
section('9. createWeightedRandom()');
|
||||||
|
const rngW = createSeededRandom(42);
|
||||||
|
const loot = createWeightedRandom([
|
||||||
|
{ value: 'common', weight: 60 },
|
||||||
|
{ value: 'rare', weight: 30 },
|
||||||
|
{ value: 'epic', weight: 10 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert(loot.size === 3, 'Has 3 items');
|
||||||
|
assert(loot.totalWeight === 100, 'Total weight is 100');
|
||||||
|
assert(loot.getProbability(0) === 0.6, 'Common probability is 0.6');
|
||||||
|
|
||||||
|
const picked = loot.pick(rngW);
|
||||||
|
assert(['common', 'rare', 'epic'].includes(picked), `Picked: ${picked}`);
|
||||||
|
|
||||||
|
// 10. Shuffle
|
||||||
|
section('10. shuffle() / shuffleCopy()');
|
||||||
|
const rngS = createSeededRandom(42);
|
||||||
|
const arr = [1, 2, 3, 4, 5];
|
||||||
|
const copy = shuffleCopy(arr, rngS);
|
||||||
|
assert(copy.length === 5, 'Shuffled copy has same length');
|
||||||
|
assert(arr[0] === 1, 'Original unchanged');
|
||||||
|
|
||||||
|
shuffle(arr, rngS);
|
||||||
|
assert(arr.length === 5, 'In-place shuffle preserves length');
|
||||||
|
|
||||||
|
// 11. pickOne
|
||||||
|
section('11. pickOne()');
|
||||||
|
const rngP = createSeededRandom(42);
|
||||||
|
const items = ['a', 'b', 'c', 'd'];
|
||||||
|
const picked2 = pickOne(items, rngP);
|
||||||
|
assert(items.includes(picked2), `Picked: ${picked2}`);
|
||||||
|
|
||||||
|
// 12. sample
|
||||||
|
section('12. sample() / sampleWithReplacement()');
|
||||||
|
const rngSamp = createSeededRandom(42);
|
||||||
|
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
|
||||||
|
const sampled = sample(nums, 3, rngSamp);
|
||||||
|
assert(sampled.length === 3, 'Sampled 3 items');
|
||||||
|
assert(new Set(sampled).size === 3, 'All unique');
|
||||||
|
|
||||||
|
const withRep = sampleWithReplacement(nums, 5, rngSamp);
|
||||||
|
assert(withRep.length === 5, 'Sampled 5 with replacement');
|
||||||
|
|
||||||
|
// 13. weightedPick
|
||||||
|
section('13. weightedPick() / weightedPickFromMap()');
|
||||||
|
const rngWP = createSeededRandom(42);
|
||||||
|
const item = weightedPick([
|
||||||
|
{ value: 'a', weight: 1 },
|
||||||
|
{ value: 'b', weight: 2 }
|
||||||
|
], rngWP);
|
||||||
|
assert(['a', 'b'].includes(item), `weightedPick: ${item}`);
|
||||||
|
|
||||||
|
const item2 = weightedPickFromMap({
|
||||||
|
'common': 60,
|
||||||
|
'rare': 30
|
||||||
|
}, rngWP);
|
||||||
|
assert(['common', 'rare'].includes(item2), `weightedPickFromMap: ${item2}`);
|
||||||
|
|
||||||
|
// 14. Reset
|
||||||
|
section('14. reset()');
|
||||||
|
const rngReset = createSeededRandom(42);
|
||||||
|
const first = rngReset.next();
|
||||||
|
rngReset.next();
|
||||||
|
rngReset.next();
|
||||||
|
rngReset.reset();
|
||||||
|
const afterReset = rngReset.next();
|
||||||
|
assert(first === afterReset, 'Reset restores initial state');
|
||||||
|
|
||||||
|
demoFooter('Procgen Demo');
|
||||||
|
}
|
||||||
|
|
||||||
|
runProcgenDemo().catch(console.error);
|
||||||
224
packages/tools/demos/src/spatial.demo.ts
Normal file
224
packages/tools/demos/src/spatial.demo.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* Spatial Module Demo - Tests APIs from docs/modules/spatial/index.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createGridSpatialIndex,
|
||||||
|
createGridAOI,
|
||||||
|
createBounds,
|
||||||
|
createBoundsFromCenter,
|
||||||
|
createBoundsFromCircle,
|
||||||
|
isPointInBounds,
|
||||||
|
boundsIntersect,
|
||||||
|
distance,
|
||||||
|
distanceSquared
|
||||||
|
} from '@esengine/spatial';
|
||||||
|
import { assert, section, demoHeader, demoFooter } from './utils.js';
|
||||||
|
|
||||||
|
interface Entity {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSpatialDemo(): Promise<void> {
|
||||||
|
demoHeader('Spatial Module Demo');
|
||||||
|
|
||||||
|
// 1. Create Spatial Index
|
||||||
|
section('1. createGridSpatialIndex()');
|
||||||
|
const spatial = createGridSpatialIndex<Entity>(100);
|
||||||
|
assert(spatial !== null, 'Spatial index created');
|
||||||
|
assert(spatial.count === 0, 'Initially empty');
|
||||||
|
|
||||||
|
// 2. Insert
|
||||||
|
section('2. insert()');
|
||||||
|
const player: Entity = { id: 1, type: 'player' };
|
||||||
|
const enemy1: Entity = { id: 2, type: 'enemy' };
|
||||||
|
const enemy2: Entity = { id: 3, type: 'enemy' };
|
||||||
|
|
||||||
|
spatial.insert(player, { x: 100, y: 200 });
|
||||||
|
spatial.insert(enemy1, { x: 150, y: 250 });
|
||||||
|
spatial.insert(enemy2, { x: 500, y: 600 });
|
||||||
|
|
||||||
|
assert(spatial.count === 3, 'Count is 3');
|
||||||
|
|
||||||
|
// 3. findInRadius
|
||||||
|
section('3. findInRadius()');
|
||||||
|
const nearby = spatial.findInRadius({ x: 100, y: 200 }, 100);
|
||||||
|
assert(nearby.length === 2, `Found ${nearby.length} entities in radius`);
|
||||||
|
assert(nearby.includes(player), 'Found player');
|
||||||
|
assert(nearby.includes(enemy1), 'Found enemy1');
|
||||||
|
assert(!nearby.includes(enemy2), 'enemy2 is too far');
|
||||||
|
|
||||||
|
// 4. findInRadius with filter
|
||||||
|
section('4. findInRadius() with filter');
|
||||||
|
const enemies = spatial.findInRadius(
|
||||||
|
{ x: 100, y: 200 },
|
||||||
|
100,
|
||||||
|
(e) => e.type === 'enemy'
|
||||||
|
);
|
||||||
|
assert(enemies.length === 1, 'Found 1 enemy');
|
||||||
|
assert(enemies[0] === enemy1, 'Found enemy1');
|
||||||
|
|
||||||
|
// 5. findNearest
|
||||||
|
section('5. findNearest()');
|
||||||
|
const nearest = spatial.findNearest({ x: 100, y: 200 });
|
||||||
|
assert(nearest === player || nearest === enemy1, 'Found nearest entity');
|
||||||
|
|
||||||
|
const nearestEnemy = spatial.findNearest(
|
||||||
|
{ x: 100, y: 200 },
|
||||||
|
undefined,
|
||||||
|
(e) => e.type === 'enemy'
|
||||||
|
);
|
||||||
|
assert(nearestEnemy === enemy1, 'Found nearest enemy');
|
||||||
|
|
||||||
|
// 6. findKNearest
|
||||||
|
section('6. findKNearest()');
|
||||||
|
const k2 = spatial.findKNearest({ x: 100, y: 200 }, 2, 1000);
|
||||||
|
assert(k2.length === 2, 'Found 2 nearest');
|
||||||
|
|
||||||
|
// 7. Update position
|
||||||
|
section('7. update()');
|
||||||
|
spatial.update(player, { x: 400, y: 400 });
|
||||||
|
const afterMove = spatial.findInRadius({ x: 100, y: 200 }, 100);
|
||||||
|
assert(!afterMove.includes(player), 'Player moved away');
|
||||||
|
|
||||||
|
// 8. Remove
|
||||||
|
section('8. remove()');
|
||||||
|
spatial.remove(enemy2);
|
||||||
|
assert(spatial.count === 2, 'Count is 2 after remove');
|
||||||
|
|
||||||
|
// 9. findInRect
|
||||||
|
section('9. findInRect()');
|
||||||
|
const bounds = createBounds(0, 0, 200, 300);
|
||||||
|
spatial.update(player, { x: 100, y: 200 });
|
||||||
|
const inRect = spatial.findInRect(bounds);
|
||||||
|
assert(inRect.length >= 1, `Found ${inRect.length} in rect`);
|
||||||
|
|
||||||
|
// 10. Raycast
|
||||||
|
section('10. raycast()');
|
||||||
|
spatial.update(player, { x: 100, y: 0 });
|
||||||
|
spatial.update(enemy1, { x: 100, y: 200 });
|
||||||
|
|
||||||
|
const hits = spatial.raycast(
|
||||||
|
{ x: 100, y: -100 },
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
500
|
||||||
|
);
|
||||||
|
assert(hits.length >= 1, `Raycast hit ${hits.length} entities`);
|
||||||
|
|
||||||
|
// 11. raycastFirst
|
||||||
|
section('11. raycastFirst()');
|
||||||
|
const firstHit = spatial.raycastFirst(
|
||||||
|
{ x: 100, y: -100 },
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
500
|
||||||
|
);
|
||||||
|
if (firstHit) {
|
||||||
|
assert(firstHit.target !== null, 'Hit has target');
|
||||||
|
assert(firstHit.distance >= 0, `Hit distance: ${firstHit.distance.toFixed(1)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Clear
|
||||||
|
section('12. clear()');
|
||||||
|
spatial.clear();
|
||||||
|
assert(spatial.count === 0, 'Cleared');
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AOI Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// 13. Create AOI
|
||||||
|
section('13. createGridAOI()');
|
||||||
|
const aoi = createGridAOI<Entity>(100);
|
||||||
|
assert(aoi !== null, 'AOI created');
|
||||||
|
|
||||||
|
// 14. Add Observers
|
||||||
|
section('14. addObserver()');
|
||||||
|
const p1: Entity = { id: 1, type: 'player' };
|
||||||
|
const p2: Entity = { id: 2, type: 'player' };
|
||||||
|
|
||||||
|
aoi.addObserver(p1, { x: 100, y: 100 }, { viewRange: 200 });
|
||||||
|
aoi.addObserver(p2, { x: 150, y: 150 }, { viewRange: 200 });
|
||||||
|
|
||||||
|
// 15. getEntitiesInView
|
||||||
|
section('15. getEntitiesInView()');
|
||||||
|
const visible = aoi.getEntitiesInView(p1);
|
||||||
|
assert(visible.includes(p2), 'p1 can see p2');
|
||||||
|
|
||||||
|
// 16. canSee
|
||||||
|
section('16. canSee()');
|
||||||
|
assert(aoi.canSee(p1, p2), 'p1 can see p2');
|
||||||
|
|
||||||
|
// 17. updatePosition
|
||||||
|
section('17. updatePosition()');
|
||||||
|
aoi.updatePosition(p2, { x: 1000, y: 1000 });
|
||||||
|
assert(!aoi.canSee(p1, p2), 'p1 cannot see p2 after move');
|
||||||
|
|
||||||
|
// 18. getObserversOf
|
||||||
|
section('18. getObserversOf()');
|
||||||
|
aoi.updatePosition(p2, { x: 120, y: 120 });
|
||||||
|
const observers = aoi.getObserversOf(p2);
|
||||||
|
assert(observers.includes(p1), 'p1 observes p2');
|
||||||
|
|
||||||
|
// 19. Event Listener
|
||||||
|
section('19. addListener()');
|
||||||
|
let eventCount = 0;
|
||||||
|
aoi.addListener((event) => {
|
||||||
|
eventCount++;
|
||||||
|
});
|
||||||
|
aoi.updatePosition(p2, { x: 2000, y: 2000 }); // Should trigger exit
|
||||||
|
aoi.updatePosition(p2, { x: 130, y: 130 }); // Should trigger enter
|
||||||
|
assert(eventCount >= 1, `Events triggered: ${eventCount}`);
|
||||||
|
|
||||||
|
// 20. updateViewRange
|
||||||
|
section('20. updateViewRange()');
|
||||||
|
aoi.updateViewRange(p1, 50);
|
||||||
|
aoi.updatePosition(p2, { x: 200, y: 200 });
|
||||||
|
assert(!aoi.canSee(p1, p2), 'Cannot see after view range reduced');
|
||||||
|
|
||||||
|
// 21. removeObserver
|
||||||
|
section('21. removeObserver()');
|
||||||
|
aoi.removeObserver(p2);
|
||||||
|
const afterRemove = aoi.getEntitiesInView(p1);
|
||||||
|
assert(!afterRemove.includes(p2), 'p2 removed from AOI');
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// 22. Bounds Creation
|
||||||
|
section('22. Bounds Creation');
|
||||||
|
const b1 = createBounds(0, 0, 100, 100);
|
||||||
|
assert(b1.minX === 0 && b1.maxX === 100, 'createBounds works');
|
||||||
|
|
||||||
|
const b2 = createBoundsFromCenter({ x: 50, y: 50 }, 100, 100);
|
||||||
|
assert(b2.minX === 0 && b2.maxX === 100, 'createBoundsFromCenter works');
|
||||||
|
|
||||||
|
const b3 = createBoundsFromCircle({ x: 50, y: 50 }, 50);
|
||||||
|
assert(b3.minX === 0 && b3.maxX === 100, 'createBoundsFromCircle works');
|
||||||
|
|
||||||
|
// 23. Point in Bounds
|
||||||
|
section('23. isPointInBounds()');
|
||||||
|
assert(isPointInBounds({ x: 50, y: 50 }, b1), 'Point inside');
|
||||||
|
assert(!isPointInBounds({ x: 150, y: 150 }, b1), 'Point outside');
|
||||||
|
|
||||||
|
// 24. Bounds Intersect
|
||||||
|
section('24. boundsIntersect()');
|
||||||
|
const ba = createBounds(0, 0, 100, 100);
|
||||||
|
const bb = createBounds(50, 50, 150, 150);
|
||||||
|
const bc = createBounds(200, 200, 300, 300);
|
||||||
|
assert(boundsIntersect(ba, bb), 'Overlapping bounds intersect');
|
||||||
|
assert(!boundsIntersect(ba, bc), 'Separate bounds do not intersect');
|
||||||
|
|
||||||
|
// 25. Distance
|
||||||
|
section('25. distance() / distanceSquared()');
|
||||||
|
const d = distance({ x: 0, y: 0 }, { x: 3, y: 4 });
|
||||||
|
assert(Math.abs(d - 5) < 0.001, `Distance: ${d}`);
|
||||||
|
|
||||||
|
const dsq = distanceSquared({ x: 0, y: 0 }, { x: 3, y: 4 });
|
||||||
|
assert(dsq === 25, `Distance squared: ${dsq}`);
|
||||||
|
|
||||||
|
demoFooter('Spatial Demo');
|
||||||
|
}
|
||||||
|
|
||||||
|
runSpatialDemo().catch(console.error);
|
||||||
107
packages/tools/demos/src/timer.demo.ts
Normal file
107
packages/tools/demos/src/timer.demo.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Timer Module Demo - Tests APIs from docs/modules/timer/index.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createTimerService } from '@esengine/timer';
|
||||||
|
import { assert, section, demoHeader, demoFooter } from './utils.js';
|
||||||
|
|
||||||
|
export async function runTimerDemo(): Promise<void> {
|
||||||
|
demoHeader('Timer Module Demo');
|
||||||
|
|
||||||
|
// 1. Basic Creation
|
||||||
|
section('1. createTimerService()');
|
||||||
|
const timerService = createTimerService();
|
||||||
|
assert(timerService !== null, 'Service created');
|
||||||
|
assert(timerService.activeTimerCount === 0, 'Initial timer count is 0');
|
||||||
|
assert(timerService.activeCooldownCount === 0, 'Initial cooldown count is 0');
|
||||||
|
|
||||||
|
// 2. One-shot Timer
|
||||||
|
section('2. schedule() - One-shot Timer');
|
||||||
|
let fired = false;
|
||||||
|
const handle = timerService.schedule('test', 100, () => { fired = true; });
|
||||||
|
assert(handle.id === 'test', 'Handle.id correct');
|
||||||
|
assert(handle.isValid === true, 'Handle.isValid is true');
|
||||||
|
assert(timerService.hasTimer('test'), 'hasTimer() returns true');
|
||||||
|
|
||||||
|
timerService.update(50);
|
||||||
|
assert(!fired, 'Timer not fired at 50ms');
|
||||||
|
timerService.update(60);
|
||||||
|
assert(fired, 'Timer fired after 110ms');
|
||||||
|
assert(!timerService.hasTimer('test'), 'Timer removed after firing');
|
||||||
|
|
||||||
|
// 3. Repeating Timer
|
||||||
|
section('3. scheduleRepeating()');
|
||||||
|
let count = 0;
|
||||||
|
timerService.scheduleRepeating('repeat', 50, () => { count++; });
|
||||||
|
timerService.update(50);
|
||||||
|
assert(count === 1, 'Fires once at 50ms');
|
||||||
|
timerService.update(50);
|
||||||
|
assert(count === 2, 'Fires twice at 100ms');
|
||||||
|
timerService.cancelById('repeat');
|
||||||
|
timerService.update(100);
|
||||||
|
assert(count === 2, 'Stopped after cancel');
|
||||||
|
|
||||||
|
// 4. Timer Cancellation
|
||||||
|
section('4. cancel()');
|
||||||
|
let cancelled = false;
|
||||||
|
const h = timerService.schedule('cancel', 1000, () => { cancelled = true; });
|
||||||
|
h.cancel();
|
||||||
|
assert(!h.isValid, 'Handle invalid after cancel');
|
||||||
|
timerService.update(2000);
|
||||||
|
assert(!cancelled, 'Cancelled timer does not fire');
|
||||||
|
|
||||||
|
// 5. Timer Info
|
||||||
|
section('5. getTimerInfo()');
|
||||||
|
timerService.schedule('info', 500, () => {});
|
||||||
|
const info = timerService.getTimerInfo('info');
|
||||||
|
assert(info !== null, 'Returns info object');
|
||||||
|
assert(info!.id === 'info', 'Info.id correct');
|
||||||
|
assert(info!.repeating === false, 'Info.repeating is false');
|
||||||
|
timerService.cancelById('info');
|
||||||
|
|
||||||
|
// 6. Cooldown System
|
||||||
|
section('6. Cooldown API');
|
||||||
|
timerService.startCooldown('skill', 200);
|
||||||
|
assert(!timerService.isCooldownReady('skill'), 'Not ready initially');
|
||||||
|
assert(timerService.isOnCooldown('skill'), 'isOnCooldown true');
|
||||||
|
|
||||||
|
timerService.update(100);
|
||||||
|
const progress = timerService.getCooldownProgress('skill');
|
||||||
|
assert(progress >= 0.4 && progress <= 0.6, `Progress ~0.5 (got ${progress.toFixed(2)})`);
|
||||||
|
|
||||||
|
timerService.update(150);
|
||||||
|
assert(timerService.isCooldownReady('skill'), 'Ready after duration');
|
||||||
|
|
||||||
|
// 7. Cooldown Info
|
||||||
|
section('7. getCooldownInfo()');
|
||||||
|
timerService.startCooldown('cd', 300);
|
||||||
|
timerService.update(150);
|
||||||
|
const cdInfo = timerService.getCooldownInfo('cd');
|
||||||
|
assert(cdInfo !== null, 'Returns cooldown info');
|
||||||
|
assert(cdInfo!.duration === 300, 'Duration is 300');
|
||||||
|
assert(!cdInfo!.isReady, 'isReady is false');
|
||||||
|
|
||||||
|
// 8. Reset Cooldown
|
||||||
|
section('8. resetCooldown()');
|
||||||
|
timerService.startCooldown('reset', 500);
|
||||||
|
timerService.update(100);
|
||||||
|
timerService.resetCooldown('reset');
|
||||||
|
assert(timerService.isCooldownReady('reset'), 'Ready after reset');
|
||||||
|
|
||||||
|
// 9. Clear All
|
||||||
|
section('9. clear()');
|
||||||
|
timerService.schedule('t1', 1000, () => {});
|
||||||
|
timerService.startCooldown('c1', 1000);
|
||||||
|
timerService.clear();
|
||||||
|
assert(timerService.activeTimerCount === 0, 'Timers cleared');
|
||||||
|
assert(timerService.activeCooldownCount === 0, 'Cooldowns cleared');
|
||||||
|
|
||||||
|
// 10. Config Options
|
||||||
|
section('10. Config Options');
|
||||||
|
const limited = createTimerService({ maxTimers: 2, maxCooldowns: 1 });
|
||||||
|
assert(limited !== null, 'Created with config');
|
||||||
|
|
||||||
|
demoFooter('Timer Demo');
|
||||||
|
}
|
||||||
|
|
||||||
|
runTimerDemo().catch(console.error);
|
||||||
41
packages/tools/demos/src/utils.ts
Normal file
41
packages/tools/demos/src/utils.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @zh Demo 测试工具函数
|
||||||
|
* @en Demo test utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 断言条件为真,否则抛出错误
|
||||||
|
* @en Assert condition is true, otherwise throw error
|
||||||
|
*/
|
||||||
|
export function assert(condition: boolean, message: string): void {
|
||||||
|
if (!condition) throw new Error(`FAILED: ${message}`);
|
||||||
|
console.log(` ✓ ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 打印测试章节标题
|
||||||
|
* @en Print test section header
|
||||||
|
*/
|
||||||
|
export function section(name: string): void {
|
||||||
|
console.log(`\n▶ ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 打印 Demo 开始标题
|
||||||
|
* @en Print demo start header
|
||||||
|
*/
|
||||||
|
export function demoHeader(name: string): void {
|
||||||
|
console.log('═══════════════════════════════════════');
|
||||||
|
console.log(` ${name}`);
|
||||||
|
console.log('═══════════════════════════════════════');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 打印 Demo 结束标题
|
||||||
|
* @en Print demo end header
|
||||||
|
*/
|
||||||
|
export function demoFooter(name: string): void {
|
||||||
|
console.log('\n═══════════════════════════════════════');
|
||||||
|
console.log(` ${name}: ALL TESTS PASSED ✓`);
|
||||||
|
console.log('═══════════════════════════════════════\n');
|
||||||
|
}
|
||||||
12
packages/tools/demos/tsconfig.json
Normal file
12
packages/tools/demos/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ES2022"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -2056,6 +2056,31 @@ importers:
|
|||||||
specifier: ^5.8.3
|
specifier: ^5.8.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/tools/demos:
|
||||||
|
dependencies:
|
||||||
|
'@esengine/fsm':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../framework/fsm
|
||||||
|
'@esengine/pathfinding':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../framework/pathfinding
|
||||||
|
'@esengine/procgen':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../framework/procgen
|
||||||
|
'@esengine/spatial':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../framework/spatial
|
||||||
|
'@esengine/timer':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../framework/timer
|
||||||
|
devDependencies:
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.21.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.8.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/tools/sdk:
|
packages/tools/sdk:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@esengine/asset-system':
|
'@esengine/asset-system':
|
||||||
|
|||||||
Reference in New Issue
Block a user