Compare commits
91 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d95dfdaf | ||
|
|
bffe90b6a1 | ||
|
|
e90a42b1c9 | ||
|
|
30173f0764 | ||
|
|
12da6bd609 | ||
|
|
6b5b4efa72 | ||
|
|
51334dfc50 | ||
|
|
2035355e22 | ||
|
|
9e5f037d5d | ||
|
|
43be62b4cb | ||
|
|
c902dd7291 | ||
|
|
0d33cf0097 | ||
|
|
45de62e453 | ||
|
|
b983cbf87a | ||
|
|
34583b23af | ||
|
|
f2c3a24404 | ||
|
|
3bfb8a1c9b | ||
|
|
2ee8d87647 | ||
|
|
2d537dc10c | ||
|
|
c2acd14fce | ||
|
|
7f631793d4 | ||
|
|
2e84942ea1 | ||
|
|
d0057333a7 | ||
|
|
54c8ff4d8f | ||
|
|
caf3be72cd | ||
|
|
ec3e449681 | ||
|
|
b95a46edaf | ||
|
|
f493f2d6cc | ||
|
|
6970394717 | ||
|
|
0e4b66aac4 | ||
|
|
7399e91a5b | ||
|
|
c84addaa0b | ||
|
|
61da38faf5 | ||
|
|
f333b81298 | ||
|
|
69bb6bd946 | ||
|
|
3b6fc8266f | ||
|
|
db22bd3028 | ||
|
|
b80e967829 | ||
|
|
9e87eb39b9 | ||
|
|
ff549f3c2a | ||
|
|
15c1d98305 | ||
|
|
4a3d8c3962 | ||
|
|
0f5aa633d8 | ||
|
|
85171a0a5c | ||
|
|
35d81880a7 | ||
|
|
71022abc99 | ||
|
|
87f71e2251 | ||
|
|
b9ea8d14cf | ||
|
|
10d0fb1d5c | ||
|
|
71e111415f | ||
|
|
0de45279e6 | ||
|
|
cc6f12d470 | ||
|
|
902c0a1074 | ||
|
|
d3e489aad3 | ||
|
|
12051d987f | ||
|
|
b38fe5ebf4 | ||
|
|
f01ce1e320 | ||
|
|
094133a71a | ||
|
|
3e5b7783be | ||
|
|
ebcb4d00a8 | ||
|
|
d2af9caae9 | ||
|
|
bb696c6a60 | ||
|
|
ffd35a71cd | ||
|
|
1f3a76aabe | ||
|
|
ddc7d1f726 | ||
|
|
04b08f3f07 | ||
|
|
d9969d0b08 | ||
|
|
bdbbf8a80a | ||
|
|
1368473c71 | ||
|
|
b28169b186 | ||
|
|
e2598b2292 | ||
|
|
2e3889abed | ||
|
|
d21caa974e | ||
|
|
a08a84b7db | ||
|
|
449bd420a6 | ||
|
|
1f297ac769 | ||
|
|
4cf868a769 | ||
|
|
afdeb00b4d | ||
|
|
764ce67742 | ||
|
|
61a13baca2 | ||
|
|
1cfa64aa0f | ||
|
|
3b978384c7 | ||
|
|
10c3891abd | ||
|
|
18af48a0fc | ||
|
|
d4cef828e1 | ||
|
|
2d46ccf896 | ||
|
|
fb8bde6485 | ||
|
|
30437dc5d5 | ||
|
|
9f84c2f870 | ||
|
|
e9ea52d9b3 | ||
|
|
0662b07445 |
@@ -49,7 +49,6 @@
|
||||
"@esengine/material-editor",
|
||||
"@esengine/shader-editor",
|
||||
"@esengine/world-streaming-editor",
|
||||
"@esengine/node-editor",
|
||||
"@esengine/sdk",
|
||||
"@esengine/worker-generator",
|
||||
"@esengine/engine"
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -63,9 +63,9 @@ jobs:
|
||||
- name: Build framework packages
|
||||
run: |
|
||||
pnpm --filter @esengine/ecs-framework build
|
||||
pnpm --filter @esengine/blueprint build
|
||||
pnpm --filter @esengine/ecs-framework-math build
|
||||
pnpm --filter @esengine/behavior-tree build
|
||||
pnpm --filter @esengine/blueprint build
|
||||
pnpm --filter @esengine/fsm build
|
||||
pnpm --filter @esengine/timer build
|
||||
pnpm --filter @esengine/spatial build
|
||||
|
||||
6
.github/workflows/release-changesets.yml
vendored
6
.github/workflows/release-changesets.yml
vendored
@@ -45,9 +45,9 @@ jobs:
|
||||
run: |
|
||||
# Only build packages managed by Changesets (not in ignore list)
|
||||
pnpm --filter "@esengine/ecs-framework" build
|
||||
pnpm --filter "@esengine/blueprint" build
|
||||
pnpm --filter "@esengine/ecs-framework-math" build
|
||||
pnpm --filter "@esengine/behavior-tree" build
|
||||
pnpm --filter "@esengine/blueprint" build
|
||||
pnpm --filter "@esengine/fsm" build
|
||||
pnpm --filter "@esengine/timer" build
|
||||
pnpm --filter "@esengine/spatial" build
|
||||
@@ -57,8 +57,12 @@ jobs:
|
||||
pnpm --filter "@esengine/rpc" build
|
||||
pnpm --filter "@esengine/network" build
|
||||
pnpm --filter "@esengine/server" build
|
||||
pnpm --filter "@esengine/database-drivers" build
|
||||
pnpm --filter "@esengine/database" build
|
||||
pnpm --filter "@esengine/transaction" build
|
||||
pnpm --filter "@esengine/cli" build
|
||||
pnpm --filter "create-esengine-server" build
|
||||
pnpm --filter "@esengine/node-editor" build
|
||||
|
||||
- name: Create Release Pull Request or Publish
|
||||
id: changesets
|
||||
|
||||
22
README.md
22
README.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
||||
| **Timer** | Timer and cooldown systems | No |
|
||||
| **Spatial** | Spatial indexing and queries (QuadTree, Grid) | No |
|
||||
| **Pathfinding** | A* and navigation mesh pathfinding | No |
|
||||
| **Network** | Client/server networking with TSRPC | No |
|
||||
| **Procgen** | Procedural generation (noise, random, sampling) | No |
|
||||
| **RPC** | High-performance RPC communication framework | No |
|
||||
| **Server** | Game server framework with rooms, auth, rate limiting | No |
|
||||
| **Network** | Client networking with prediction, AOI, delta compression | No |
|
||||
| **Transaction** | Game transaction system with Redis/Memory storage | No |
|
||||
| **World Streaming** | Open world chunk loading and streaming | No |
|
||||
|
||||
> All framework modules can be used standalone with any rendering engine.
|
||||
|
||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # State machines
|
||||
npm install @esengine/timer # Timers & cooldowns
|
||||
npm install @esengine/spatial # Spatial indexing
|
||||
npm install @esengine/pathfinding # Pathfinding
|
||||
npm install @esengine/network # Networking
|
||||
npm install @esengine/procgen # Procedural generation
|
||||
npm install @esengine/rpc # RPC framework
|
||||
npm install @esengine/server # Game server
|
||||
npm install @esengine/network # Client networking
|
||||
npm install @esengine/transaction # Transaction system
|
||||
npm install @esengine/world-streaming # World streaming
|
||||
```
|
||||
|
||||
### ESEngine Runtime (Optional)
|
||||
@@ -218,6 +228,7 @@ If you want a complete engine solution with rendering:
|
||||
A visual editor built with Tauri for scene management:
|
||||
|
||||
- Download from [Releases](https://github.com/esengine/esengine/releases)
|
||||
- [Build from source](./packages/editor/editor-app/README.md)
|
||||
- Supports behavior tree editing, tilemap painting, visual scripting
|
||||
|
||||
## Project Structure
|
||||
@@ -235,7 +246,11 @@ esengine/
|
||||
│ │ ├── spatial/ # Spatial queries
|
||||
│ │ ├── pathfinding/ # Pathfinding
|
||||
│ │ ├── procgen/ # Procedural generation
|
||||
│ │ └── network/ # Networking
|
||||
│ │ ├── rpc/ # RPC framework
|
||||
│ │ ├── server/ # Game server
|
||||
│ │ ├── network/ # Client networking
|
||||
│ │ ├── transaction/ # Transaction system
|
||||
│ │ └── world-streaming/ # World streaming
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine runtime
|
||||
│ ├── rendering/ # Rendering modules
|
||||
@@ -267,6 +282,7 @@ pnpm test
|
||||
|
||||
- [ECS Framework Guide](./packages/framework/core/README.md)
|
||||
- [Behavior Tree Guide](./packages/framework/behavior-tree/README.md)
|
||||
- [Editor Setup Guide](./packages/editor/editor-app/README.md) ([中文](./packages/editor/editor-app/README_CN.md))
|
||||
- [API Reference](https://esengine.cn/api/README)
|
||||
|
||||
## Community
|
||||
|
||||
22
README_CN.md
22
README_CN.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
||||
| **定时器** | 定时器和冷却系统 | 否 |
|
||||
| **空间索引** | 空间查询(四叉树、网格) | 否 |
|
||||
| **寻路** | A* 和导航网格寻路 | 否 |
|
||||
| **网络** | 客户端/服务端网络通信 (TSRPC) | 否 |
|
||||
| **程序化生成** | 噪声、随机、采样等生成算法 | 否 |
|
||||
| **RPC** | 高性能 RPC 通信框架 | 否 |
|
||||
| **服务端** | 游戏服务器框架,支持房间、认证、速率限制 | 否 |
|
||||
| **网络** | 客户端网络,支持预测、AOI、增量压缩 | 否 |
|
||||
| **事务系统** | 游戏事务系统,支持 Redis/内存存储 | 否 |
|
||||
| **世界流送** | 开放世界分块加载和流送 | 否 |
|
||||
|
||||
> 所有框架模块都可以独立使用,无需依赖特定渲染引擎。
|
||||
|
||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # 状态机
|
||||
npm install @esengine/timer # 定时器和冷却
|
||||
npm install @esengine/spatial # 空间索引
|
||||
npm install @esengine/pathfinding # 寻路
|
||||
npm install @esengine/network # 网络
|
||||
npm install @esengine/procgen # 程序化生成
|
||||
npm install @esengine/rpc # RPC 框架
|
||||
npm install @esengine/server # 游戏服务器
|
||||
npm install @esengine/network # 客户端网络
|
||||
npm install @esengine/transaction # 事务系统
|
||||
npm install @esengine/world-streaming # 世界流送
|
||||
```
|
||||
|
||||
### ESEngine 运行时(可选)
|
||||
@@ -218,6 +228,7 @@ npm install @esengine/network # 网络
|
||||
基于 Tauri 构建的可视化编辑器:
|
||||
|
||||
- 从 [Releases](https://github.com/esengine/esengine/releases) 下载
|
||||
- [从源码构建](./packages/editor/editor-app/README.md)
|
||||
- 支持行为树编辑、Tilemap 绘制、可视化脚本
|
||||
|
||||
## 项目结构
|
||||
@@ -235,7 +246,11 @@ esengine/
|
||||
│ │ ├── spatial/ # 空间查询
|
||||
│ │ ├── pathfinding/ # 寻路
|
||||
│ │ ├── procgen/ # 程序化生成
|
||||
│ │ └── network/ # 网络
|
||||
│ │ ├── rpc/ # RPC 框架
|
||||
│ │ ├── server/ # 游戏服务器
|
||||
│ │ ├── network/ # 客户端网络
|
||||
│ │ ├── transaction/ # 事务系统
|
||||
│ │ └── world-streaming/ # 世界流送
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine 运行时
|
||||
│ ├── rendering/ # 渲染模块
|
||||
@@ -267,6 +282,7 @@ pnpm test
|
||||
|
||||
- [ECS 框架指南](./packages/framework/core/README.md)
|
||||
- [行为树指南](./packages/framework/behavior-tree/README.md)
|
||||
- [编辑器启动指南](./packages/editor/editor-app/README_CN.md) ([English](./packages/editor/editor-app/README.md))
|
||||
- [API 参考](https://esengine.cn/api/README)
|
||||
|
||||
## 社区
|
||||
|
||||
@@ -3,8 +3,12 @@ import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import vue from '@astrojs/vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
export default defineConfig({
|
||||
markdown: {
|
||||
rehypePlugins: [rehypeRaw],
|
||||
},
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'ESEngine',
|
||||
@@ -96,6 +100,8 @@ export default defineConfig({
|
||||
{ label: '最佳实践', slug: 'guide/scene/best-practices', translations: { en: 'Best Practices' } },
|
||||
],
|
||||
},
|
||||
{ label: '场景管理器', slug: 'guide/scene-manager', translations: { en: 'SceneManager' } },
|
||||
{ label: '持久实体', slug: 'guide/persistent-entity', translations: { en: 'Persistent Entity' } },
|
||||
{
|
||||
label: '序列化',
|
||||
translations: { en: 'Serialization' },
|
||||
@@ -232,6 +238,8 @@ export default defineConfig({
|
||||
translations: { en: 'Blueprint' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/blueprint', translations: { en: 'Overview' } },
|
||||
{ label: '编辑器使用指南', slug: 'modules/blueprint/editor-guide', translations: { en: 'Editor Guide' } },
|
||||
{ label: 'Cocos Creator 编辑器', slug: 'modules/blueprint/cocos-editor', translations: { en: 'Cocos Creator Editor' } },
|
||||
{ label: '虚拟机 API', slug: 'modules/blueprint/vm', translations: { en: 'VM API' } },
|
||||
{ label: '自定义节点', slug: 'modules/blueprint/custom-nodes', translations: { en: 'Custom Nodes' } },
|
||||
{ label: '内置节点', slug: 'modules/blueprint/nodes', translations: { en: 'Built-in Nodes' } },
|
||||
@@ -239,6 +247,14 @@ export default defineConfig({
|
||||
{ label: '实际示例', slug: 'modules/blueprint/examples', translations: { en: 'Examples' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '数学库',
|
||||
translations: { en: 'Math' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/math', translations: { en: 'Overview' } },
|
||||
{ label: '蓝图节点', slug: 'modules/math/blueprint-nodes', translations: { en: 'Blueprint Nodes' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '程序生成',
|
||||
translations: { en: 'Procgen' },
|
||||
@@ -255,6 +271,9 @@ export default defineConfig({
|
||||
translations: { en: 'RPC' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/rpc', translations: { en: 'Overview' } },
|
||||
{ label: '服务端', slug: 'modules/rpc/server', translations: { en: 'Server' } },
|
||||
{ label: '客户端', slug: 'modules/rpc/client', translations: { en: 'Client' } },
|
||||
{ label: '编解码', slug: 'modules/rpc/codec', translations: { en: 'Codec' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -264,10 +283,59 @@ export default defineConfig({
|
||||
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
|
||||
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
|
||||
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
|
||||
{ label: 'HTTP 路由', slug: 'modules/network/http', translations: { en: 'HTTP Routing' } },
|
||||
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
|
||||
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
|
||||
{ label: '分布式房间', slug: 'modules/network/distributed', translations: { en: 'Distributed Rooms' } },
|
||||
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
|
||||
{ label: '定点数同步', slug: 'modules/network/fixed-point', translations: { en: 'Fixed-Point Sync' } },
|
||||
{ label: '客户端预测', slug: 'modules/network/prediction', translations: { en: 'Prediction' } },
|
||||
{ label: 'AOI 兴趣区域', slug: 'modules/network/aoi', translations: { en: 'AOI' } },
|
||||
{ label: '增量压缩', slug: 'modules/network/delta', translations: { en: 'Delta Compression' } },
|
||||
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '事务系统',
|
||||
translations: { en: 'Transaction' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/transaction', translations: { en: 'Overview' } },
|
||||
{ label: '核心概念', slug: 'modules/transaction/core', translations: { en: 'Core Concepts' } },
|
||||
{ label: '存储层', slug: 'modules/transaction/storage', translations: { en: 'Storage Layer' } },
|
||||
{ label: '操作', slug: 'modules/transaction/operations', translations: { en: 'Operations' } },
|
||||
{ label: '分布式事务', slug: 'modules/transaction/distributed', translations: { en: 'Distributed' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '数据库',
|
||||
translations: { en: 'Database' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/database', translations: { en: 'Overview' } },
|
||||
{ label: '仓储模式', slug: 'modules/database/repository', translations: { en: 'Repository' } },
|
||||
{ label: '用户仓储', slug: 'modules/database/user', translations: { en: 'User Repository' } },
|
||||
{ label: '查询构建器', slug: 'modules/database/query', translations: { en: 'Query Builder' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '数据库驱动',
|
||||
translations: { en: 'Database Drivers' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/database-drivers', translations: { en: 'Overview' } },
|
||||
{ label: 'MongoDB', slug: 'modules/database-drivers/mongo', translations: { en: 'MongoDB' } },
|
||||
{ label: 'Redis', slug: 'modules/database-drivers/redis', translations: { en: 'Redis' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '世界流式加载',
|
||||
translations: { en: 'World Streaming' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/world-streaming', translations: { en: 'Overview' } },
|
||||
{ label: '区块管理', slug: 'modules/world-streaming/chunk-manager', translations: { en: 'Chunk Manager' } },
|
||||
{ label: '流式系统', slug: 'modules/world-streaming/streaming-system', translations: { en: 'Streaming System' } },
|
||||
{ label: '序列化', slug: 'modules/world-streaming/serialization', translations: { en: 'Serialization' } },
|
||||
{ label: '实际示例', slug: 'modules/world-streaming/examples', translations: { en: 'Examples' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -292,6 +360,8 @@ export default defineConfig({
|
||||
{ label: '@esengine/fsm', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/fsm/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/timer', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/timer/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/network', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/network/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/transaction', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/transaction/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/rpc', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/rpc/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/cli', link: 'https://github.com/esengine/esengine/blob/master/packages/tools/cli/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@astrojs/vue": "^5.1.3",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"astro": "^5.6.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.26"
|
||||
|
||||
462
docs/public/js/blueprint-graph.js
Normal file
462
docs/public/js/blueprint-graph.js
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Blueprint Graph Renderer
|
||||
* Custom layout algorithm designed specifically for blueprint-style graphs
|
||||
*/
|
||||
(function() {
|
||||
const PIN_COLORS = {
|
||||
exec: '#ffffff',
|
||||
entity: '#00a0e0',
|
||||
component: '#7030c0',
|
||||
float: '#7ecd32',
|
||||
int: '#1cc4c4',
|
||||
bool: '#8c0000',
|
||||
string: '#e060e0',
|
||||
any: '#707070'
|
||||
};
|
||||
|
||||
const HEADER_CLASSES = {
|
||||
event: 'event',
|
||||
function: 'function',
|
||||
pure: 'pure',
|
||||
flow: 'flow',
|
||||
math: 'math',
|
||||
time: 'time',
|
||||
debug: 'debug',
|
||||
variable: 'variable'
|
||||
};
|
||||
|
||||
const H_GAP = 50; // Horizontal gap between columns
|
||||
const V_GAP = 25; // Vertical gap between nodes
|
||||
const START_X = 20;
|
||||
const START_Y = 20;
|
||||
|
||||
function estimateNodeSize(node) {
|
||||
const headerHeight = 28; // Match CSS: min-height 28px
|
||||
const pinRowHeight = 22; // Match CSS: pin row ~22px
|
||||
const bodyPadding = 12; // Top + bottom padding
|
||||
|
||||
// Count all pins in body (each pin is its own row now)
|
||||
const inputExecCount = node.inputs ? node.inputs.filter(p => p.type === 'exec').length : 0;
|
||||
const inputDataCount = node.inputs ? node.inputs.filter(p => p.type !== 'exec').length : 0;
|
||||
const outputExecCount = node.outputs ? node.outputs.filter(p => p.type === 'exec' && !p.inHeader).length : 0;
|
||||
const outputDataCount = node.outputs ? node.outputs.filter(p => p.type !== 'exec' && !p.inHeader).length : 0;
|
||||
const totalPins = inputExecCount + inputDataCount + outputExecCount + outputDataCount;
|
||||
|
||||
// Calculate height: header + body padding + all pin rows
|
||||
const bodyHeight = totalPins > 0 ? bodyPadding + (totalPins * pinRowHeight) : 0;
|
||||
const height = headerHeight + bodyHeight;
|
||||
|
||||
// Calculate width based on content
|
||||
let maxLabelLen = node.title.length;
|
||||
if (node.inputs) {
|
||||
node.inputs.forEach(p => {
|
||||
const len = (p.label || '').length + (p.value ? String(p.value).length + 3 : 0);
|
||||
maxLabelLen = Math.max(maxLabelLen, len);
|
||||
});
|
||||
}
|
||||
if (node.outputs) {
|
||||
node.outputs.forEach(p => {
|
||||
maxLabelLen = Math.max(maxLabelLen, (p.label || '').length);
|
||||
});
|
||||
}
|
||||
|
||||
const width = Math.max(110, Math.min(170, maxLabelLen * 8 + 40));
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart blueprint layout algorithm
|
||||
*
|
||||
* Uses weighted graph analysis:
|
||||
* - All connections matter (exec has higher weight)
|
||||
* - Topological sort for X ordering
|
||||
* - Force-directed optimization for Y positions
|
||||
*/
|
||||
function autoLayout(graphData, maxWidth) {
|
||||
const nodes = graphData.nodes;
|
||||
const connections = graphData.connections;
|
||||
|
||||
if (nodes.length === 0) return { positions: {}, sizes: {} };
|
||||
|
||||
// Calculate node sizes
|
||||
const nodeSizes = {};
|
||||
nodes.forEach(n => { nodeSizes[n.id] = estimateNodeSize(n); });
|
||||
|
||||
// Build maps
|
||||
const pinToNode = {};
|
||||
const nodeById = {};
|
||||
nodes.forEach(n => {
|
||||
nodeById[n.id] = n;
|
||||
(n.inputs || []).forEach(p => { pinToNode[p.id] = n.id; });
|
||||
(n.outputs || []).forEach(p => { pinToNode[p.id] = n.id; });
|
||||
});
|
||||
|
||||
// Build weighted adjacency: outgoing[nodeId] = [{to, weight}]
|
||||
const outgoing = {};
|
||||
const incoming = {};
|
||||
nodes.forEach(n => { outgoing[n.id] = []; incoming[n.id] = []; });
|
||||
|
||||
connections.forEach(c => {
|
||||
const from = pinToNode[c.from];
|
||||
const to = pinToNode[c.to];
|
||||
if (!from || !to || from === to) return;
|
||||
|
||||
const weight = c.type === 'exec' ? 3 : 1;
|
||||
outgoing[from].push({ to, weight });
|
||||
incoming[to].push({ from, weight });
|
||||
});
|
||||
|
||||
// Calculate node "depth" using weighted longest path
|
||||
const nodeDepth = {};
|
||||
const visited = new Set();
|
||||
const inProcess = new Set();
|
||||
|
||||
function calcDepth(nodeId) {
|
||||
if (visited.has(nodeId)) return nodeDepth[nodeId];
|
||||
if (inProcess.has(nodeId)) return 0; // Cycle detected
|
||||
|
||||
inProcess.add(nodeId);
|
||||
|
||||
let maxPrevDepth = -1;
|
||||
incoming[nodeId].forEach(({ from, weight }) => {
|
||||
const prevDepth = calcDepth(from);
|
||||
maxPrevDepth = Math.max(maxPrevDepth, prevDepth);
|
||||
});
|
||||
|
||||
inProcess.delete(nodeId);
|
||||
visited.add(nodeId);
|
||||
nodeDepth[nodeId] = maxPrevDepth + 1;
|
||||
return nodeDepth[nodeId];
|
||||
}
|
||||
|
||||
// Calculate depth for all nodes
|
||||
nodes.forEach(n => calcDepth(n.id));
|
||||
|
||||
// Group nodes by depth (column)
|
||||
const columnNodes = {};
|
||||
nodes.forEach(n => {
|
||||
const depth = nodeDepth[n.id];
|
||||
if (!columnNodes[depth]) columnNodes[depth] = [];
|
||||
columnNodes[depth].push(n.id);
|
||||
});
|
||||
|
||||
// Sort columns
|
||||
const sortedColumns = Object.keys(columnNodes).map(Number).sort((a, b) => a - b);
|
||||
|
||||
// Calculate X positions
|
||||
const columnX = {};
|
||||
let currentX = START_X;
|
||||
sortedColumns.forEach(col => {
|
||||
columnX[col] = currentX;
|
||||
let maxW = 0;
|
||||
columnNodes[col].forEach(id => {
|
||||
maxW = Math.max(maxW, nodeSizes[id].width);
|
||||
});
|
||||
currentX += maxW + H_GAP;
|
||||
});
|
||||
|
||||
// Initialize Y positions - simple stacking first
|
||||
const positions = {};
|
||||
sortedColumns.forEach(col => {
|
||||
let y = START_Y;
|
||||
columnNodes[col].forEach(id => {
|
||||
positions[id] = { x: columnX[col], y };
|
||||
y += nodeSizes[id].height + V_GAP;
|
||||
});
|
||||
});
|
||||
|
||||
// Force-directed optimization for Y positions (few iterations)
|
||||
for (let iter = 0; iter < 5; iter++) {
|
||||
const forces = {};
|
||||
nodes.forEach(n => { forces[n.id] = 0; });
|
||||
|
||||
// Calculate forces from connections
|
||||
connections.forEach(c => {
|
||||
const from = pinToNode[c.from];
|
||||
const to = pinToNode[c.to];
|
||||
if (!from || !to || from === to) return;
|
||||
|
||||
const weight = c.type === 'exec' ? 2 : 1;
|
||||
const fromY = positions[from].y + nodeSizes[from].height / 2;
|
||||
const toY = positions[to].y + nodeSizes[to].height / 2;
|
||||
const diff = toY - fromY;
|
||||
|
||||
// Pull nodes toward each other
|
||||
forces[from] += diff * 0.1 * weight;
|
||||
forces[to] -= diff * 0.1 * weight;
|
||||
});
|
||||
|
||||
// Apply forces
|
||||
nodes.forEach(n => {
|
||||
positions[n.id].y += forces[n.id];
|
||||
positions[n.id].y = Math.max(START_Y, positions[n.id].y);
|
||||
});
|
||||
|
||||
// Resolve overlaps within columns
|
||||
sortedColumns.forEach(col => {
|
||||
const nodesInCol = columnNodes[col];
|
||||
nodesInCol.sort((a, b) => positions[a].y - positions[b].y);
|
||||
|
||||
for (let i = 1; i < nodesInCol.length; i++) {
|
||||
const prevId = nodesInCol[i - 1];
|
||||
const currId = nodesInCol[i];
|
||||
const minY = positions[prevId].y + nodeSizes[prevId].height + V_GAP;
|
||||
if (positions[currId].y < minY) {
|
||||
positions[currId].y = minY;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { positions, sizes: nodeSizes };
|
||||
}
|
||||
|
||||
function renderPinSvg(type, filled = true) {
|
||||
const color = PIN_COLORS[type] || PIN_COLORS.any;
|
||||
if (type === 'exec') {
|
||||
return `<svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="${filled ? '#fff' : 'none'}" stroke="${filled ? 'none' : '#fff'}" stroke-width="2"/></svg>`;
|
||||
}
|
||||
return `<svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="${filled ? color : 'none'}" stroke="${filled ? 'none' : color}" stroke-width="2"/></svg>`;
|
||||
}
|
||||
|
||||
function renderNode(node, position, size) {
|
||||
const isEvent = node.category === 'event';
|
||||
const headerClass = HEADER_CLASSES[node.category] || 'function';
|
||||
|
||||
let html = `<div class="bp-node" style="left: ${position.x}px; top: ${position.y}px; width: ${size.width}px;">`;
|
||||
html += `<div class="bp-node-header ${headerClass}">`;
|
||||
if (isEvent) html += `<span class="bp-node-header-icon"></span>`;
|
||||
html += `<span class="bp-node-header-title">${node.title}</span>`;
|
||||
|
||||
const headerExec = node.outputs && node.outputs.find(p => p.type === 'exec' && p.inHeader);
|
||||
if (headerExec) {
|
||||
html += `<span class="bp-header-exec" data-pin="${headerExec.id}">${renderPinSvg('exec')}</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
// Separate exec and data pins (matching node-editor order)
|
||||
const inputExecPins = (node.inputs || []).filter(p => p.type === 'exec');
|
||||
const inputDataPins = (node.inputs || []).filter(p => p.type !== 'exec');
|
||||
const outputExecPins = (node.outputs || []).filter(p => p.type === 'exec' && !p.inHeader);
|
||||
const outputDataPins = (node.outputs || []).filter(p => p.type !== 'exec' && !p.inHeader);
|
||||
|
||||
const hasBody = inputExecPins.length > 0 || inputDataPins.length > 0 ||
|
||||
outputDataPins.length > 0 || outputExecPins.length > 0;
|
||||
|
||||
if (hasBody) {
|
||||
html += `<div class="bp-node-body">`;
|
||||
|
||||
// Input exec pins first
|
||||
inputExecPins.forEach(pin => {
|
||||
const filled = pin.connected !== false;
|
||||
html += `<div class="bp-pin-row input">`;
|
||||
html += `<span class="bp-pin" data-pin="${pin.id}">${renderPinSvg(pin.type, filled)}</span>`;
|
||||
html += `<span class="bp-pin-label">${pin.label || ''}</span>`;
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
// Input data pins
|
||||
inputDataPins.forEach(pin => {
|
||||
const filled = pin.connected !== false;
|
||||
html += `<div class="bp-pin-row input">`;
|
||||
html += `<span class="bp-pin" data-pin="${pin.id}">${renderPinSvg(pin.type, filled)}</span>`;
|
||||
html += `<span class="bp-pin-label">${pin.label || ''}</span>`;
|
||||
if (pin.value !== undefined) html += `<span class="bp-pin-value">${pin.value}</span>`;
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
// Output data pins (pin first, then label - CSS row-reverse will flip them)
|
||||
outputDataPins.forEach(pin => {
|
||||
html += `<div class="bp-pin-row output">`;
|
||||
html += `<span class="bp-pin" data-pin="${pin.id}">${renderPinSvg(pin.type)}</span>`;
|
||||
html += `<span class="bp-pin-label">${pin.label || ''}</span>`;
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
// Output exec pins
|
||||
outputExecPins.forEach(pin => {
|
||||
html += `<div class="bp-pin-row output">`;
|
||||
html += `<span class="bp-pin" data-pin="${pin.id}">${renderPinSvg(pin.type)}</span>`;
|
||||
html += `<span class="bp-pin-label">${pin.label || ''}</span>`;
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup drag-to-scroll for graph container
|
||||
* Works with native overflow:auto scrolling
|
||||
*/
|
||||
function setupDragScroll(container) {
|
||||
let isDragging = false;
|
||||
let startX = 0, startY = 0;
|
||||
let scrollLeft = 0, scrollTop = 0;
|
||||
|
||||
container.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
isDragging = true;
|
||||
startX = e.pageX - container.offsetLeft;
|
||||
startY = e.pageY - container.offsetTop;
|
||||
scrollLeft = container.scrollLeft;
|
||||
scrollTop = container.scrollTop;
|
||||
container.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
container.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
const x = e.pageX - container.offsetLeft;
|
||||
const y = e.pageY - container.offsetTop;
|
||||
container.scrollLeft = scrollLeft - (x - startX);
|
||||
container.scrollTop = scrollTop - (y - startY);
|
||||
});
|
||||
|
||||
container.addEventListener('mouseup', () => {
|
||||
isDragging = false;
|
||||
container.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
container.addEventListener('mouseleave', () => {
|
||||
isDragging = false;
|
||||
container.style.cursor = 'grab';
|
||||
});
|
||||
}
|
||||
|
||||
function renderConnections(container, graphData) {
|
||||
const svg = container.querySelector('.bp-connections');
|
||||
if (!svg) return;
|
||||
|
||||
const content = container.querySelector('.bp-graph-content') || container;
|
||||
const graphRect = content.getBoundingClientRect();
|
||||
|
||||
graphData.connections.forEach(c => {
|
||||
const fromPin = container.querySelector(`[data-pin="${c.from}"]`);
|
||||
const toPin = container.querySelector(`[data-pin="${c.to}"]`);
|
||||
if (!fromPin || !toPin) return;
|
||||
|
||||
const fromRect = fromPin.getBoundingClientRect();
|
||||
const toRect = toPin.getBoundingClientRect();
|
||||
|
||||
const x1 = fromRect.left - graphRect.left + fromRect.width / 2;
|
||||
const y1 = fromRect.top - graphRect.top + fromRect.height / 2;
|
||||
const x2 = toRect.left - graphRect.left + toRect.width / 2;
|
||||
const y2 = toRect.top - graphRect.top + toRect.height / 2;
|
||||
|
||||
// Simple bezier curve
|
||||
const dx = Math.abs(x2 - x1) * 0.5;
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`);
|
||||
path.setAttribute('class', `bp-conn ${c.type || 'exec'}`);
|
||||
svg.appendChild(path);
|
||||
});
|
||||
}
|
||||
|
||||
function initBlueprintGraphs() {
|
||||
document.querySelectorAll('.bp-graph[data-graph]').forEach(container => {
|
||||
try {
|
||||
const graphData = JSON.parse(container.dataset.graph);
|
||||
if (!graphData.nodes || graphData.nodes.length === 0) {
|
||||
console.warn('Blueprint graph has no nodes');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get container width for layout calculation
|
||||
let containerWidth = container.parentElement?.offsetWidth || 0;
|
||||
if (containerWidth < 200) {
|
||||
containerWidth = 650;
|
||||
}
|
||||
|
||||
const { positions, sizes } = autoLayout(graphData, containerWidth - 30);
|
||||
|
||||
let maxX = 0, maxY = 0;
|
||||
graphData.nodes.forEach(n => {
|
||||
const pos = positions[n.id];
|
||||
const size = sizes[n.id];
|
||||
if (pos && size) {
|
||||
maxX = Math.max(maxX, pos.x + size.width);
|
||||
maxY = Math.max(maxY, pos.y + size.height);
|
||||
}
|
||||
});
|
||||
|
||||
// Add generous padding to ensure all nodes visible
|
||||
maxX += 80;
|
||||
maxY += 80;
|
||||
|
||||
// Set minimum height but allow natural expansion
|
||||
const containerHeight = Math.max(maxY, 200);
|
||||
container.style.minHeight = containerHeight + 'px';
|
||||
|
||||
let html = `<div class="bp-graph-content" style="width:${maxX}px;height:${maxY}px;position:relative;">`;
|
||||
html += `<svg class="bp-connections" width="${maxX}" height="${maxY}"></svg>`;
|
||||
graphData.nodes.forEach(n => {
|
||||
if (positions[n.id] && sizes[n.id]) {
|
||||
html += renderNode(n, positions[n.id], sizes[n.id]);
|
||||
}
|
||||
});
|
||||
html += `</div>`;
|
||||
container.innerHTML = html;
|
||||
|
||||
// Setup drag-to-scroll
|
||||
setupDragScroll(container);
|
||||
|
||||
requestAnimationFrame(() => renderConnections(container, graphData));
|
||||
} catch (e) {
|
||||
console.error('Blueprint graph error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy format
|
||||
document.querySelectorAll('.bp-graph:not([data-graph])').forEach(graph => {
|
||||
const nodes = graph.querySelectorAll('.bp-node');
|
||||
let maxX = 0, maxY = 0;
|
||||
nodes.forEach(node => {
|
||||
const left = parseInt(node.style.left) || 0;
|
||||
const top = parseInt(node.style.top) || 0;
|
||||
const width = parseInt(node.style.width) || 150;
|
||||
maxX = Math.max(maxX, left + width + 40);
|
||||
maxY = Math.max(maxY, top + node.offsetHeight + 40);
|
||||
});
|
||||
// Don't set fixed width - let CSS handle it
|
||||
graph.style.minHeight = Math.max(maxY, 120) + 'px';
|
||||
|
||||
const svg = graph.querySelector('.bp-connections');
|
||||
if (!svg) return;
|
||||
svg.setAttribute('width', maxX);
|
||||
svg.setAttribute('height', Math.max(maxY, 120));
|
||||
|
||||
const conns = JSON.parse(graph.dataset.connections || '[]');
|
||||
const graphRect = graph.getBoundingClientRect();
|
||||
|
||||
conns.forEach(c => {
|
||||
const fromPin = graph.querySelector(`[data-pin="${c.from}"]`);
|
||||
const toPin = graph.querySelector(`[data-pin="${c.to}"]`);
|
||||
if (!fromPin || !toPin) return;
|
||||
|
||||
const fromRect = fromPin.getBoundingClientRect();
|
||||
const toRect = toPin.getBoundingClientRect();
|
||||
|
||||
const x1 = fromRect.left - graphRect.left + fromRect.width / 2;
|
||||
const y1 = fromRect.top - graphRect.top + fromRect.height / 2;
|
||||
const x2 = toRect.left - graphRect.left + toRect.width / 2;
|
||||
const y2 = toRect.top - graphRect.top + toRect.height / 2;
|
||||
|
||||
const dx = Math.abs(x2 - x1) * 0.5;
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`);
|
||||
path.setAttribute('class', `bp-conn ${c.type || 'exec'}`);
|
||||
svg.appendChild(path);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initBlueprintGraphs);
|
||||
} else {
|
||||
initBlueprintGraphs();
|
||||
}
|
||||
})();
|
||||
@@ -18,3 +18,6 @@ import Default from '@astrojs/starlight/components/Head.astro';
|
||||
document.documentElement.dataset.theme = 'dark';
|
||||
localStorage.setItem('starlight-theme', 'dark');
|
||||
</script>
|
||||
|
||||
<!-- Blueprint graph visualization -->
|
||||
<script is:inline src="/js/blueprint-graph.js"></script>
|
||||
|
||||
@@ -359,6 +359,5 @@ class GoodScene extends Scene {
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Scene](./scene) - Learn the basics of scenes
|
||||
- [SceneManager](./scene-manager) - Learn about scene transitions
|
||||
- [WorldManager](./world-manager) - Learn about multi-world management
|
||||
- [Scene](/en/guide/scene/) - Learn the basics of scenes
|
||||
- [SceneManager](/en/guide/scene-manager/) - Learn about scene transitions
|
||||
|
||||
@@ -16,7 +16,7 @@ The ECS framework provides a platform adapter interface that allows users to imp
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
### [Browser Adapter](./platform-adapter/browser/)
|
||||
### [Browser Adapter](/en/guide/platform-adapter/browser/)
|
||||
|
||||
Supports all modern browser environments, including Chrome, Firefox, Safari, Edge, etc.
|
||||
|
||||
@@ -30,7 +30,7 @@ Supports all modern browser environments, including Chrome, Firefox, Safari, Edg
|
||||
|
||||
---
|
||||
|
||||
### [WeChat Mini Game Adapter](./platform-adapter/wechat-minigame/)
|
||||
### [WeChat Mini Game Adapter](/en/guide/platform-adapter/wechat-minigame/)
|
||||
|
||||
Designed specifically for the WeChat Mini Game environment, handling special restrictions and APIs.
|
||||
|
||||
@@ -44,7 +44,7 @@ Designed specifically for the WeChat Mini Game environment, handling special res
|
||||
|
||||
---
|
||||
|
||||
### [Node.js Adapter](./platform-adapter/nodejs/)
|
||||
### [Node.js Adapter](/en/guide/platform-adapter/nodejs/)
|
||||
|
||||
Provides support for Node.js server environments, suitable for game servers and compute servers.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ SceneManager is suitable for:
|
||||
- Automatic ECS fluent API management
|
||||
- Automatic scene lifecycle handling
|
||||
- Integrated with Core, auto-updated
|
||||
- Supports [Persistent Entity](./persistent-entity) migration across scenes (v2.3.0+)
|
||||
- Supports [Persistent Entity](/en/guide/persistent-entity/) migration across scenes (v2.3.0+)
|
||||
|
||||
## Basic Usage
|
||||
|
||||
@@ -434,7 +434,6 @@ Core (Global Services)
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Persistent Entity](./persistent-entity) - Learn how to keep entities across scene transitions
|
||||
- [WorldManager](./world-manager) - Learn about advanced multi-world isolation features
|
||||
- [Persistent Entity](/en/guide/persistent-entity/) - Learn how to keep entities across scene transitions
|
||||
|
||||
SceneManager provides simple yet powerful scene management capabilities for most games. Through Core's static methods, you can easily manage scene transitions.
|
||||
|
||||
@@ -71,6 +71,55 @@ class ConfiguredScene extends Scene {
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
For networked games, you can configure the runtime environment to distinguish between server and client logic.
|
||||
|
||||
### Global Configuration (Recommended)
|
||||
|
||||
Set the runtime environment once at the Core level - all Scenes will inherit this setting:
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
|
||||
// Method 1: Set in Core.create()
|
||||
Core.create({ runtimeEnvironment: 'server' });
|
||||
|
||||
// Method 2: Set static property directly
|
||||
Core.runtimeEnvironment = 'server';
|
||||
```
|
||||
|
||||
### Per-Scene Override
|
||||
|
||||
Individual scenes can override the global setting:
|
||||
|
||||
```typescript
|
||||
const clientScene = new Scene({ runtimeEnvironment: 'client' });
|
||||
```
|
||||
|
||||
### Environment Types
|
||||
|
||||
| Environment | Use Case |
|
||||
|-------------|----------|
|
||||
| `'standalone'` | Single-player games (default) |
|
||||
| `'server'` | Game server, authoritative logic |
|
||||
| `'client'` | Game client, rendering/input |
|
||||
|
||||
### Checking Environment in Systems
|
||||
|
||||
```typescript
|
||||
class CollectibleSpawnSystem extends EntitySystem {
|
||||
private checkCollections(): void {
|
||||
// Skip on client - only server handles authoritative logic
|
||||
if (!this.scene.isServer) return;
|
||||
|
||||
// Server-authoritative spawn logic...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [System Runtime Decorators](/en/guide/system/index#runtime-environment-decorators) for decorator-based approach.
|
||||
|
||||
### Running a Scene
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -160,6 +160,53 @@ scene.addSystem(new SystemA()); // addOrder = 0, executes first
|
||||
scene.addSystem(new SystemB()); // addOrder = 1, executes second
|
||||
```
|
||||
|
||||
## Runtime Environment Decorators
|
||||
|
||||
For networked games, you can use decorators to control which environment a system method runs in.
|
||||
|
||||
### Available Decorators
|
||||
|
||||
| Decorator | Effect |
|
||||
|-----------|--------|
|
||||
| `@ServerOnly()` | Method only executes on server |
|
||||
| `@ClientOnly()` | Method only executes on client |
|
||||
| `@NotServer()` | Method skipped on server |
|
||||
| `@NotClient()` | Method skipped on client |
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, ServerOnly, ClientOnly } from '@esengine/ecs-framework';
|
||||
|
||||
class GameSystem extends EntitySystem {
|
||||
@ServerOnly()
|
||||
private spawnEnemies(): void {
|
||||
// Only runs on server - authoritative spawn logic
|
||||
}
|
||||
|
||||
@ClientOnly()
|
||||
private playEffects(): void {
|
||||
// Only runs on client - visual effects
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Simple Conditional Check
|
||||
|
||||
For simple cases, a direct check is often clearer than decorators:
|
||||
|
||||
```typescript
|
||||
class CollectibleSystem extends EntitySystem {
|
||||
private checkCollections(): void {
|
||||
if (!this.scene.isServer) return; // Skip on client
|
||||
|
||||
// Server-authoritative logic...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Scene Runtime Environment](/en/guide/scene/index#runtime-environment) for configuration details.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [System Types](/en/guide/system/types) - Learn about different system base classes
|
||||
|
||||
@@ -182,6 +182,70 @@ export class IsHealthLow implements INodeExecutor {
|
||||
}
|
||||
```
|
||||
|
||||
## Using Custom Executors in BehaviorTreeBuilder
|
||||
|
||||
After defining a custom executor with `@NodeExecutorMetadata`, use the `.action()` method in the builder:
|
||||
|
||||
```typescript
|
||||
import { BehaviorTreeBuilder, BehaviorTreeStarter } from '@esengine/behavior-tree';
|
||||
|
||||
// Use custom executor in behavior tree
|
||||
const tree = BehaviorTreeBuilder.create('CombatAI')
|
||||
.defineBlackboardVariable('health', 100)
|
||||
.defineBlackboardVariable('target', null)
|
||||
.selector('Root')
|
||||
.sequence('AttackSequence')
|
||||
// Use custom action - matches implementationType in decorator
|
||||
.action('AttackAction', 'Attack', { damage: 25 })
|
||||
.action('MoveToTarget', 'Chase')
|
||||
.end()
|
||||
.action('WaitAction', 'Idle', { duration: 1000 })
|
||||
.end()
|
||||
.build();
|
||||
|
||||
// Start the behavior tree
|
||||
const entity = scene.createEntity('Enemy');
|
||||
BehaviorTreeStarter.start(entity, tree);
|
||||
```
|
||||
|
||||
### Builder Methods for Custom Nodes
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `.action(type, name?, config?)` | Add custom action node |
|
||||
| `.condition(type, name?, config?)` | Add custom condition node |
|
||||
| `.executeAction(name)` | Use blackboard function `action_{name}` |
|
||||
| `.executeCondition(name)` | Use blackboard function `condition_{name}` |
|
||||
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
// 1. Define custom executor
|
||||
@NodeExecutorMetadata({
|
||||
implementationType: 'AttackAction',
|
||||
nodeType: NodeType.Action,
|
||||
displayName: 'Attack',
|
||||
category: 'Combat',
|
||||
configSchema: {
|
||||
damage: { type: 'number', default: 10, supportBinding: true }
|
||||
}
|
||||
})
|
||||
class AttackAction implements INodeExecutor {
|
||||
execute(context: NodeExecutionContext): TaskStatus {
|
||||
const damage = BindingHelper.getValue<number>(context, 'damage', 10);
|
||||
console.log(`Attacking with ${damage} damage!`);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build and use
|
||||
const tree = BehaviorTreeBuilder.create('AI')
|
||||
.selector('Root')
|
||||
.action('AttackAction', 'Attack', { damage: 50 })
|
||||
.end()
|
||||
.build();
|
||||
```
|
||||
|
||||
## Registering Custom Executors
|
||||
|
||||
Executors are auto-registered via the decorator. To manually register:
|
||||
|
||||
@@ -100,6 +100,6 @@ console.log('Current state:', runtime.state);
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Core Concepts](./core-concepts/) - Understand nodes and execution
|
||||
- [Custom Actions](./custom-actions/) - Create your own nodes
|
||||
- [Editor Guide](./editor-guide/) - Visual tree creation
|
||||
- [Core Concepts](/en/modules/behavior-tree/core-concepts/) - Understand nodes and execution
|
||||
- [Custom Actions](/en/modules/behavior-tree/custom-actions/) - Create your own nodes
|
||||
- [Editor Guide](/en/modules/behavior-tree/editor-guide/) - Visual tree creation
|
||||
|
||||
383
docs/src/content/docs/en/modules/blueprint/cocos-editor.md
Normal file
383
docs/src/content/docs/en/modules/blueprint/cocos-editor.md
Normal file
@@ -0,0 +1,383 @@
|
||||
---
|
||||
title: "Cocos Creator Blueprint Editor"
|
||||
description: "Using the blueprint visual scripting system in Cocos Creator"
|
||||
---
|
||||
|
||||
This document explains how to install and use the blueprint visual scripting editor extension in Cocos Creator projects.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Copy Extension to Project
|
||||
|
||||
Copy the `cocos-node-editor` extension to your Cocos Creator project's `extensions` directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── assets/
|
||||
├── extensions/
|
||||
│ └── cocos-node-editor/ # Blueprint editor extension
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
Install dependencies in the extension directory:
|
||||
|
||||
```bash
|
||||
cd extensions/cocos-node-editor
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Enable Extension
|
||||
|
||||
1. Open Cocos Creator
|
||||
2. Go to **Extensions → Extension Manager**
|
||||
3. Find `cocos-node-editor` and enable it
|
||||
|
||||
## Opening the Blueprint Editor
|
||||
|
||||
Open the blueprint editor panel via menu **Panel → Node Editor**.
|
||||
|
||||
## Editor Interface
|
||||
|
||||
### Toolbar
|
||||
|
||||
| Button | Shortcut | Function |
|
||||
|--------|----------|----------|
|
||||
| New | - | Create empty blueprint |
|
||||
| Load | - | Load blueprint from file |
|
||||
| Save | `Ctrl+S` | Save blueprint to file |
|
||||
| Undo | `Ctrl+Z` | Undo last operation |
|
||||
| Redo | `Ctrl+Shift+Z` | Redo operation |
|
||||
| Cut | `Ctrl+X` | Cut selected nodes |
|
||||
| Copy | `Ctrl+C` | Copy selected nodes |
|
||||
| Paste | `Ctrl+V` | Paste nodes |
|
||||
| Delete | `Delete` | Delete selected items |
|
||||
| Rescan | - | Rescan project for blueprint nodes |
|
||||
|
||||
### Canvas Operations
|
||||
|
||||
- **Right-click on canvas**: Open node addition menu
|
||||
- **Drag nodes**: Move node position
|
||||
- **Click node**: Select node
|
||||
- **Ctrl+Click**: Multi-select nodes
|
||||
- **Drag pin to pin**: Create connection
|
||||
- **Scroll wheel**: Zoom canvas
|
||||
- **Middle-click drag**: Pan canvas
|
||||
|
||||
### Node Menu
|
||||
|
||||
Right-clicking on canvas shows the node menu:
|
||||
|
||||
- Search box at top for quick node search
|
||||
- Nodes grouped by category
|
||||
- Press `Enter` to quickly add first search result
|
||||
- Press `Esc` to close menu
|
||||
|
||||
## Blueprint File Format
|
||||
|
||||
Blueprints are saved as `.blueprint.json` files, fully compatible with runtime:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "blueprint",
|
||||
"metadata": {
|
||||
"name": "My Blueprint",
|
||||
"createdAt": 1704307200000,
|
||||
"modifiedAt": 1704307200000
|
||||
},
|
||||
"variables": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"type": "PrintString",
|
||||
"position": { "x": 100, "y": 200 },
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{
|
||||
"id": "conn-1",
|
||||
"fromNodeId": "node-1",
|
||||
"fromPin": "exec",
|
||||
"toNodeId": "node-2",
|
||||
"toPin": "exec"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Running Blueprints in Game
|
||||
|
||||
Use ECS system to manage and execute blueprints.
|
||||
|
||||
### 1. Define Blueprint Component
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, Property, Serialize } from '@esengine/ecs-framework';
|
||||
import type { BlueprintAsset } from '@esengine/blueprint';
|
||||
|
||||
@ECSComponent('Blueprint')
|
||||
export class BlueprintComponent extends Component {
|
||||
@Serialize()
|
||||
@Property({ type: 'asset', label: 'Blueprint Asset' })
|
||||
blueprintPath: string = '';
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Auto Start' })
|
||||
autoStart: boolean = true;
|
||||
|
||||
// Runtime data (not serialized)
|
||||
blueprintAsset: BlueprintAsset | null = null;
|
||||
vm: BlueprintVM | null = null;
|
||||
isStarted: boolean = false;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Blueprint Execution System
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintVM,
|
||||
validateBlueprintAsset
|
||||
} from '@esengine/blueprint';
|
||||
import { BlueprintComponent } from './BlueprintComponent';
|
||||
|
||||
export class BlueprintExecutionSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(BlueprintComponent));
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
const dt = Time.deltaTime;
|
||||
|
||||
for (const entity of entities) {
|
||||
const bp = entity.getComponent(BlueprintComponent)!;
|
||||
|
||||
// Skip entities without blueprint asset
|
||||
if (!bp.blueprintAsset) continue;
|
||||
|
||||
// Initialize VM
|
||||
if (!bp.vm) {
|
||||
bp.vm = new BlueprintVM(bp.blueprintAsset, entity, this.scene!);
|
||||
}
|
||||
|
||||
// Auto start
|
||||
if (bp.autoStart && !bp.isStarted) {
|
||||
bp.vm.start();
|
||||
bp.isStarted = true;
|
||||
}
|
||||
|
||||
// Update blueprint
|
||||
if (bp.isStarted) {
|
||||
bp.vm.tick(dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override onRemoved(entity: Entity): void {
|
||||
const bp = entity.getComponent(BlueprintComponent);
|
||||
if (bp?.vm && bp.isStarted) {
|
||||
bp.vm.stop();
|
||||
bp.vm = null;
|
||||
bp.isStarted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Load Blueprint and Add to Entity
|
||||
|
||||
```typescript
|
||||
import { resources, JsonAsset } from 'cc';
|
||||
import { validateBlueprintAsset } from '@esengine/blueprint';
|
||||
|
||||
// Load blueprint asset
|
||||
async function loadBlueprint(path: string): Promise<BlueprintAsset | null> {
|
||||
return new Promise((resolve) => {
|
||||
resources.load(path, JsonAsset, (err, asset) => {
|
||||
if (err || !asset) {
|
||||
console.error('Failed to load blueprint:', err);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = asset.json;
|
||||
if (validateBlueprintAsset(data)) {
|
||||
resolve(data as BlueprintAsset);
|
||||
} else {
|
||||
console.error('Invalid blueprint format');
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Create entity with blueprint
|
||||
async function createBlueprintEntity(scene: IScene, blueprintPath: string): Promise<Entity> {
|
||||
const entity = scene.createEntity('BlueprintEntity');
|
||||
|
||||
const bpComponent = entity.addComponent(BlueprintComponent);
|
||||
bpComponent.blueprintPath = blueprintPath;
|
||||
bpComponent.blueprintAsset = await loadBlueprint(blueprintPath);
|
||||
|
||||
return entity;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Register System to Scene
|
||||
|
||||
```typescript
|
||||
// During scene initialization
|
||||
scene.addSystem(new BlueprintExecutionSystem());
|
||||
```
|
||||
|
||||
## Creating Custom Nodes
|
||||
|
||||
### Using Decorators for Components
|
||||
|
||||
Use decorators to automatically generate blueprint nodes from components:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
|
||||
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: 'Health Component' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: 'Current Health', category: 'number' })
|
||||
current: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: 'Max Health', category: 'number' })
|
||||
max: number = 100;
|
||||
|
||||
@BlueprintMethod({ displayName: 'Heal', isExec: true })
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: 'Take Damage', isExec: true })
|
||||
takeDamage(amount: number): void {
|
||||
this.current = Math.max(this.current - amount, 0);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: 'Is Dead' })
|
||||
isDead(): boolean {
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Register Component Nodes
|
||||
|
||||
```typescript
|
||||
import { registerAllComponentNodes } from '@esengine/blueprint';
|
||||
|
||||
// Register all decorated components at application startup
|
||||
registerAllComponentNodes();
|
||||
```
|
||||
|
||||
### Manual Node Definition (Advanced)
|
||||
|
||||
For fully custom node logic:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
BlueprintNodeTemplate,
|
||||
INodeExecutor,
|
||||
RegisterNode,
|
||||
ExecutionContext,
|
||||
ExecutionResult
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
const MyNodeTemplate: BlueprintNodeTemplate = {
|
||||
type: 'MyCustomNode',
|
||||
title: 'My Custom Node',
|
||||
category: 'custom',
|
||||
description: 'Custom node 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' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const value = context.getInput<number>(node.id, 'value');
|
||||
return {
|
||||
outputs: { result: value * 2 },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Node Categories
|
||||
|
||||
| Category | Description | Color |
|
||||
|----------|-------------|-------|
|
||||
| `event` | Event nodes | 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 |
|
||||
| `custom` | Custom nodes | Blue-gray |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **File Organization**
|
||||
- Place blueprint files in `assets/blueprints/` directory
|
||||
- Use meaningful file names like `player-controller.blueprint.json`
|
||||
|
||||
2. **Component Design**
|
||||
- Use `@BlueprintExpose` to mark components that should be exposed to blueprints
|
||||
- Provide clear `displayName` for properties and methods
|
||||
- Mark execution methods with `isExec: true`
|
||||
|
||||
3. **Performance Considerations**
|
||||
- Avoid heavy computation in Tick events
|
||||
- Use variables to cache intermediate results
|
||||
- Pure function nodes automatically cache outputs
|
||||
|
||||
4. **Debugging Tips**
|
||||
- Use Print nodes to output intermediate values
|
||||
- Enable `vm.debug = true` to view execution logs
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: Node menu is empty?
|
||||
|
||||
A: Click the **Rescan** button to scan for blueprint node classes in your project. Make sure you have called `registerAllComponentNodes()`.
|
||||
|
||||
### Q: Blueprint doesn't execute?
|
||||
|
||||
A: Check:
|
||||
1. Entity has `BlueprintComponent` added
|
||||
2. `BlueprintExecutionSystem` is registered to scene
|
||||
3. `blueprintAsset` is correctly loaded
|
||||
4. `autoStart` is `true`
|
||||
|
||||
### Q: How to trigger custom events?
|
||||
|
||||
A: Trigger through VM:
|
||||
```typescript
|
||||
const bp = entity.getComponent(BlueprintComponent);
|
||||
bp.vm?.triggerCustomEvent('OnPickup', { item: itemEntity });
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Blueprint Runtime API](/en/modules/blueprint/) - BlueprintVM and core API
|
||||
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Detailed node creation guide
|
||||
- [Built-in Nodes](/en/modules/blueprint/nodes) - Built-in node reference
|
||||
@@ -28,13 +28,13 @@ const MyNodeTemplate: BlueprintNodeTemplate = {
|
||||
## Implementing Node Executor
|
||||
|
||||
```typescript
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
// Get input
|
||||
const value = context.getInput<number>(node.id, 'value');
|
||||
// Get input (using evaluateInput)
|
||||
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
||||
|
||||
// Execute logic
|
||||
const result = value * 2;
|
||||
@@ -100,29 +100,58 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
||||
};
|
||||
```
|
||||
|
||||
## Example: Input Handler Node
|
||||
## Example: ECS Component Operation Node
|
||||
|
||||
```typescript
|
||||
const InputMoveTemplate: BlueprintNodeTemplate = {
|
||||
type: 'InputMove',
|
||||
title: 'Get Movement Input',
|
||||
category: 'input',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'direction', type: 'vector2', direction: 'output' }
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
|
||||
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
|
||||
// Custom heal node
|
||||
const HealEntityTemplate: BlueprintNodeTemplate = {
|
||||
type: 'HealEntity',
|
||||
title: 'Heal Entity',
|
||||
category: 'gameplay',
|
||||
color: '#22aa22',
|
||||
description: 'Heal an entity with HealthComponent',
|
||||
keywords: ['heal', 'health', 'restore'],
|
||||
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'entity', type: 'entity', displayName: 'Target' },
|
||||
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
|
||||
],
|
||||
isPure: true
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(InputMoveTemplate)
|
||||
class InputMoveExecutor implements INodeExecutor {
|
||||
@RegisterNode(HealEntityTemplate)
|
||||
class HealEntityExecutor 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 } };
|
||||
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!entity || entity.isDestroyed) {
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
// Get HealthComponent
|
||||
const health = entity.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
if (health) {
|
||||
health.current = Math.min(health.current + amount, health.max);
|
||||
return {
|
||||
outputs: { newHealth: health.current },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
610
docs/src/content/docs/en/modules/blueprint/editor-guide.md
Normal file
610
docs/src/content/docs/en/modules/blueprint/editor-guide.md
Normal file
@@ -0,0 +1,610 @@
|
||||
---
|
||||
title: "Blueprint Editor User Guide"
|
||||
description: "Complete guide for using the Cocos Creator Blueprint Visual Scripting Editor"
|
||||
---
|
||||
|
||||
<script src="/js/blueprint-graph.js"></script>
|
||||
|
||||
This guide covers how to use the Blueprint Visual Scripting Editor in Cocos Creator.
|
||||
|
||||
## Download & Installation
|
||||
|
||||
### Download
|
||||
|
||||
Download the latest version from GitHub Release (Free):
|
||||
|
||||
**[Download Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
|
||||
> QQ Group: **481923584** | Website: [esengine.cn](https://esengine.cn/)
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. Extract `cocos-node-editor.zip` to your project's `extensions` directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── assets/
|
||||
├── extensions/
|
||||
│ └── cocos-node-editor/ ← Extract here
|
||||
└── ...
|
||||
```
|
||||
|
||||
2. Restart Cocos Creator
|
||||
|
||||
3. Confirm the plugin is enabled via **Extensions → Extension Manager**
|
||||
|
||||
4. Open the editor via **Panel → Node Editor**
|
||||
|
||||
## Interface Overview
|
||||
|
||||
- **Toolbar** - Located at the top, contains New, Open, Save, Undo, Redo operations
|
||||
- **Variables Panel** - Located at the top-left, for defining and managing blueprint variables
|
||||
- **Canvas Area** - Main area for placing and connecting nodes
|
||||
- **Node Menu** - Right-click on empty canvas to open, lists all available nodes by category
|
||||
|
||||
## Canvas Operations
|
||||
|
||||
| Operation | Method |
|
||||
|-----------|--------|
|
||||
| Pan canvas | Middle-click drag / Alt + Left-click drag |
|
||||
| Zoom canvas | Mouse wheel |
|
||||
| Open node menu | Right-click on empty space |
|
||||
| Box select nodes | Drag on empty canvas |
|
||||
| Additive select | Ctrl + Drag |
|
||||
| Delete selected | Delete key |
|
||||
|
||||
## Node Operations
|
||||
|
||||
### Adding Nodes
|
||||
|
||||
1. **Drag from Node Panel** - Drag nodes from the left panel onto the canvas
|
||||
2. **Right-click Menu** - Right-click on empty canvas space to select nodes
|
||||
|
||||
### Connecting Nodes
|
||||
|
||||
1. Drag from an output pin to an input pin
|
||||
2. Compatible pins will highlight
|
||||
3. Release to complete the connection
|
||||
|
||||
**Pin Type Reference:**
|
||||
|
||||
| Pin Color | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| White ▶ | Exec | Execution flow (controls order) |
|
||||
| Cyan ◆ | Entity | Entity reference |
|
||||
| Purple ◆ | Component | Component reference |
|
||||
| Light Blue ◆ | String | String value |
|
||||
| Green ◆ | Number | Numeric value |
|
||||
| Red ◆ | Boolean | Boolean value |
|
||||
| Gray ◆ | Any | Any type |
|
||||
|
||||
### Deleting Connections
|
||||
|
||||
Click a connection line to select it, then press Delete.
|
||||
|
||||
## Node Types Reference
|
||||
|
||||
### Event Nodes
|
||||
|
||||
Event nodes are entry points for blueprint execution, triggered when specific events occur.
|
||||
|
||||
| Node | Trigger | Outputs |
|
||||
|------|---------|---------|
|
||||
| **Event BeginPlay** | When blueprint starts | Exec, Self (Entity) |
|
||||
| **Event Tick** | Every frame | Exec, Delta Time |
|
||||
| **Event EndPlay** | When blueprint stops | Exec |
|
||||
|
||||
**Example: Print message on game start**
|
||||
```
|
||||
[Event BeginPlay] ──Exec──→ [Print]
|
||||
└─ Message: "Game Started!"
|
||||
```
|
||||
|
||||
### Entity Nodes
|
||||
|
||||
Nodes for operating on ECS entities.
|
||||
|
||||
| Node | Function | Inputs | Outputs |
|
||||
|------|----------|--------|---------|
|
||||
| **Get Self** | Get current entity | - | Entity |
|
||||
| **Create Entity** | Create new entity | Exec, Name | Exec, Entity |
|
||||
| **Destroy Entity** | Destroy entity | Exec, Entity | Exec |
|
||||
| **Find Entity By Name** | Find by name | Name | Entity |
|
||||
| **Find Entities By Tag** | Find by tag | Tag | Entity[] |
|
||||
| **Is Valid** | Check entity validity | Entity | Boolean |
|
||||
| **Get/Set Entity Name** | Get/Set name | Entity | String |
|
||||
| **Set Active** | Set active state | Exec, Entity, Active | Exec |
|
||||
|
||||
**Example: Create new entity**
|
||||
```
|
||||
[Event BeginPlay] ──→ [Create Entity] ──→ [Add Component]
|
||||
└─ Name: "Bullet" └─ Type: Transform
|
||||
```
|
||||
|
||||
### Component Nodes
|
||||
|
||||
Access and manipulate ECS components.
|
||||
|
||||
| Node | Function |
|
||||
|------|----------|
|
||||
| **Has Component** | Check if entity has component |
|
||||
| **Get Component** | Get component instance |
|
||||
| **Add Component** | Add component to entity |
|
||||
| **Remove Component** | Remove component |
|
||||
| **Get/Set Property** | Get/Set component property |
|
||||
|
||||
**Example: Modify Transform component**
|
||||
```
|
||||
[Get Self] ─Entity─→ [Get Component: Transform] ─Component─→ [Set Property]
|
||||
├─ Property: x
|
||||
└─ Value: 100
|
||||
```
|
||||
|
||||
### Flow Control Nodes
|
||||
|
||||
Nodes that control execution flow.
|
||||
|
||||
#### Branch
|
||||
|
||||
Conditional branching, similar to if/else.
|
||||
|
||||
```
|
||||
┌─ True ──→ [DoSomething]
|
||||
[Branch]─┤
|
||||
└─ False ─→ [DoOtherThing]
|
||||
```
|
||||
|
||||
#### Sequence
|
||||
|
||||
Execute multiple branches in order.
|
||||
|
||||
```
|
||||
┌─ Then 0 ──→ [Step1]
|
||||
[Sequence]─┼─ Then 1 ──→ [Step2]
|
||||
└─ Then 2 ──→ [Step3]
|
||||
```
|
||||
|
||||
#### For Loop
|
||||
|
||||
Execute a specified number of times.
|
||||
|
||||
```
|
||||
[For Loop] ─Loop Body─→ [Execute each iteration]
|
||||
│
|
||||
└─ Completed ────→ [Execute after loop ends]
|
||||
```
|
||||
|
||||
| Input | Description |
|
||||
|-------|-------------|
|
||||
| First Index | Starting index |
|
||||
| Last Index | Ending index |
|
||||
|
||||
| Output | Description |
|
||||
|--------|-------------|
|
||||
| Loop Body | Execute each iteration |
|
||||
| Index | Current index |
|
||||
| Completed | Execute after loop ends |
|
||||
|
||||
#### For Each
|
||||
|
||||
Iterate over array elements.
|
||||
|
||||
#### While Loop
|
||||
|
||||
Loop while condition is true.
|
||||
|
||||
#### Do Once
|
||||
|
||||
Execute only once, skip afterwards.
|
||||
|
||||
#### Flip Flop
|
||||
|
||||
Alternate between A and B outputs each execution.
|
||||
|
||||
#### Gate
|
||||
|
||||
Control whether execution passes through via Open/Close/Toggle.
|
||||
|
||||
### Time Nodes
|
||||
|
||||
| Node | Function | Output Type |
|
||||
|------|----------|-------------|
|
||||
| **Delay** | Delay execution by specified time | Exec |
|
||||
| **Get Delta Time** | Get frame delta time | Number |
|
||||
| **Get Time** | Get total runtime | Number |
|
||||
|
||||
**Example: Execute after 2 second delay**
|
||||
```
|
||||
[Event BeginPlay] ──→ [Delay] ──→ [Print]
|
||||
└─ Duration: 2.0 └─ "Executed after 2s"
|
||||
```
|
||||
|
||||
### Math Nodes
|
||||
|
||||
| Node | Function |
|
||||
|------|----------|
|
||||
| **Add / Subtract / Multiply / Divide** | Arithmetic operations |
|
||||
| **Abs** | Absolute value |
|
||||
| **Clamp** | Clamp to range |
|
||||
| **Lerp** | Linear interpolation |
|
||||
| **Min / Max** | Minimum/Maximum value |
|
||||
| **Random Range** | Random number |
|
||||
| **Sin / Cos / Tan** | Trigonometric functions |
|
||||
|
||||
### Debug Nodes
|
||||
|
||||
| Node | Function |
|
||||
|------|----------|
|
||||
| **Print** | Output to console |
|
||||
|
||||
## Variable System
|
||||
|
||||
Variables store and share data within a blueprint.
|
||||
|
||||
### Creating Variables
|
||||
|
||||
1. Click the **+** button in the Variables panel
|
||||
2. Enter variable name
|
||||
3. Select variable type
|
||||
4. Set default value (optional)
|
||||
|
||||
### Using Variables
|
||||
|
||||
- **Drag to canvas** - Creates Get or Set node
|
||||
- **Get Node** - Read variable value
|
||||
- **Set Node** - Write variable value
|
||||
|
||||
### Variable Types
|
||||
|
||||
| Type | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| Boolean | Boolean value | false |
|
||||
| Number | Numeric value | 0 |
|
||||
| String | String value | "" |
|
||||
| Entity | Entity reference | null |
|
||||
| Vector2 | 2D vector | (0, 0) |
|
||||
| Vector3 | 3D vector | (0, 0, 0) |
|
||||
|
||||
### Variable Node Error State
|
||||
|
||||
If you delete a variable but nodes still reference it:
|
||||
- Nodes display a **red border** and **warning icon**
|
||||
- You need to recreate the variable or delete these nodes
|
||||
|
||||
## Node Grouping
|
||||
|
||||
You can organize multiple nodes into a visual group box to help manage complex blueprints.
|
||||
|
||||
### Creating a Group
|
||||
|
||||
1. Box-select or Ctrl+click to select multiple nodes (at least 2)
|
||||
2. Right-click on the selected nodes
|
||||
3. Choose **Create Group**
|
||||
4. A group box will automatically wrap all selected nodes
|
||||
|
||||
### Group Operations
|
||||
|
||||
| Action | Method |
|
||||
|--------|--------|
|
||||
| Move group | Drag the group header, all nodes move together |
|
||||
| Ungroup | Right-click on group box → **Ungroup** |
|
||||
|
||||
### Features
|
||||
|
||||
- **Dynamic sizing**: Group box automatically resizes to wrap all nodes
|
||||
- **Independent movement**: You can move nodes within the group individually, and the box adjusts
|
||||
- **Editor only**: Groups are purely visual organization, no runtime impact
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Function |
|
||||
|----------|----------|
|
||||
| `Ctrl + S` | Save blueprint |
|
||||
| `Ctrl + Z` | Undo |
|
||||
| `Ctrl + Shift + Z` | Redo |
|
||||
| `Ctrl + C` | Copy selected nodes |
|
||||
| `Ctrl + X` | Cut selected nodes |
|
||||
| `Ctrl + V` | Paste nodes |
|
||||
| `Delete` | Delete selected items |
|
||||
| `Ctrl + A` | Select all |
|
||||
|
||||
## Save & Load
|
||||
|
||||
### Saving Blueprints
|
||||
|
||||
1. Click the **Save** button in the toolbar
|
||||
2. Choose save location (**must be saved in `assets/resources` directory**, otherwise Cocos Creator cannot load dynamically)
|
||||
3. File extension is `.blueprint.json`
|
||||
|
||||
> **Important**: Blueprint files must be placed in the `resources` directory for runtime loading via `cc.resources.load()`.
|
||||
|
||||
### Loading Blueprints
|
||||
|
||||
1. Click the **Open** button in the toolbar
|
||||
2. Select a `.blueprint.json` file
|
||||
|
||||
### Blueprint File Format
|
||||
|
||||
Blueprints are saved as JSON, compatible with `@esengine/blueprint` runtime:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "blueprint",
|
||||
"metadata": {
|
||||
"name": "PlayerController",
|
||||
"description": "Player control logic"
|
||||
},
|
||||
"variables": [],
|
||||
"nodes": [],
|
||||
"connections": []
|
||||
}
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example 1: Movement Control
|
||||
|
||||
Move entity every frame:
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"ex1-exec","to":"ex1-setprop","type":"exec"},{"from":"ex1-delta","to":"ex1-mul-a","type":"float"},{"from":"ex1-mul-result","to":"ex1-x","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="ex1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex1-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta Time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
|
||||
<div class="bp-node-header math">Multiply</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B (Speed)</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex1-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-setprop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Example 2: Health System
|
||||
|
||||
Check death after taking damage. `Event OnDamage` is a custom event node that can be triggered from code via `vm.triggerCustomEvent('OnDamage', { damage: 50 })`:
|
||||
|
||||
<div class="bp-graph" data-graph='{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "event", "title": "Event OnDamage", "category": "event",
|
||||
"outputs": [
|
||||
{"id": "event-exec", "type": "exec", "inHeader": true},
|
||||
{"id": "event-self", "type": "entity", "label": "Self"},
|
||||
{"id": "event-damage", "type": "float", "label": "Damage"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "getcomp", "title": "Get Component", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "getcomp-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "getcomp-entity", "type": "entity", "label": "Entity"},
|
||||
{"id": "getcomp-type", "type": "string", "label": "Type", "value": "Health", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "getcomp-out", "type": "exec"},
|
||||
{"id": "getcomp-comp", "type": "component", "label": "Component"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "getprop", "title": "Get Property", "category": "pure",
|
||||
"inputs": [
|
||||
{"id": "getprop-target", "type": "component", "label": "Target"},
|
||||
{"id": "getprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "getprop-val", "type": "float", "label": "Value"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sub", "title": "Subtract", "category": "math",
|
||||
"inputs": [
|
||||
{"id": "sub-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "sub-a", "type": "float", "label": "A"},
|
||||
{"id": "sub-b", "type": "float", "label": "B"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "sub-out", "type": "exec"},
|
||||
{"id": "sub-result", "type": "float", "label": "Result"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "setprop", "title": "Set Property", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "setprop-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "setprop-target", "type": "component", "label": "Target"},
|
||||
{"id": "setprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false},
|
||||
{"id": "setprop-val", "type": "float", "label": "Value"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "setprop-out", "type": "exec"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lte", "title": "Less Or Equal", "category": "pure",
|
||||
"inputs": [
|
||||
{"id": "lte-a", "type": "float", "label": "A"},
|
||||
{"id": "lte-b", "type": "float", "label": "B", "value": "0", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "lte-result", "type": "bool", "label": "Result"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "branch", "title": "Branch", "category": "flow",
|
||||
"inputs": [
|
||||
{"id": "branch-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "branch-cond", "type": "bool", "label": "Condition"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "branch-true", "type": "exec", "label": "True"},
|
||||
{"id": "branch-false", "type": "exec", "label": "False"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "destroy", "title": "Destroy Entity", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "destroy-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "destroy-entity", "type": "entity", "label": "Entity"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{"from": "event-exec", "to": "getcomp-exec", "type": "exec"},
|
||||
{"from": "getcomp-out", "to": "sub-exec", "type": "exec"},
|
||||
{"from": "sub-out", "to": "setprop-exec", "type": "exec"},
|
||||
{"from": "setprop-out", "to": "branch-exec", "type": "exec"},
|
||||
{"from": "branch-true", "to": "destroy-exec", "type": "exec"},
|
||||
{"from": "event-self", "to": "getcomp-entity", "type": "entity"},
|
||||
{"from": "event-self", "to": "destroy-entity", "type": "entity"},
|
||||
{"from": "getcomp-comp", "to": "getprop-target", "type": "component"},
|
||||
{"from": "getcomp-comp", "to": "setprop-target", "type": "component"},
|
||||
{"from": "getprop-val", "to": "sub-a", "type": "float"},
|
||||
{"from": "event-damage", "to": "sub-b", "type": "float"},
|
||||
{"from": "sub-result", "to": "setprop-val", "type": "float"},
|
||||
{"from": "sub-result", "to": "lte-a", "type": "float"},
|
||||
{"from": "lte-result", "to": "branch-cond", "type": "bool"}
|
||||
]
|
||||
}'></div>
|
||||
|
||||
### Example 3: Delayed Spawning
|
||||
|
||||
Spawn an enemy every 2 seconds:
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"ex3-begin-exec","to":"ex3-loop","type":"exec"},{"from":"ex3-loop-body","to":"ex3-delay","type":"exec"},{"from":"ex3-delay-done","to":"ex3-create","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="ex3-begin-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header flow">Do N Times</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-loop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">N</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex3-loop-body"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Loop Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-delay"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex3-delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 610px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-create"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Name</span>
|
||||
<span class="bp-pin-value">"Enemy"</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Q: Nodes won't connect?
|
||||
|
||||
Check if pin types are compatible. Execution pins (white) can only connect to execution pins. Data pins need matching types.
|
||||
|
||||
### Q: Blueprint not executing?
|
||||
|
||||
1. Ensure entity has `BlueprintComponent` attached
|
||||
2. Ensure scene has `BlueprintSystem` added
|
||||
3. Check if `autoStart` is `true`
|
||||
|
||||
### Q: How to debug?
|
||||
|
||||
Use **Print** nodes to output variable values to the console.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ECS Node Reference](/en/modules/blueprint/nodes) - Complete node list
|
||||
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Create custom nodes
|
||||
- [Runtime Integration](/en/modules/blueprint/vm) - Blueprint VM API
|
||||
- [Examples](/en/modules/blueprint/examples) - More game logic examples
|
||||
@@ -3,85 +3,127 @@ title: "Examples"
|
||||
description: "ECS integration and best practices"
|
||||
---
|
||||
|
||||
## Player Control Blueprint
|
||||
## Complete Game Integration Example
|
||||
|
||||
```typescript
|
||||
// Define input handling node
|
||||
const InputMoveTemplate: BlueprintNodeTemplate = {
|
||||
type: 'InputMove',
|
||||
title: 'Get Movement Input',
|
||||
category: 'input',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'direction', type: 'vector2', direction: 'output' }
|
||||
],
|
||||
isPure: true
|
||||
};
|
||||
import { Scene, Core, Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintSystem,
|
||||
BlueprintComponent,
|
||||
BlueprintExpose,
|
||||
BlueprintProperty,
|
||||
BlueprintMethod
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
@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 } };
|
||||
// 1. Define game components
|
||||
@ECSComponent('Player')
|
||||
@BlueprintExpose({ displayName: 'Player', category: 'gameplay' })
|
||||
export class PlayerComponent extends Component {
|
||||
@BlueprintProperty({ displayName: 'Move Speed', type: 'float' })
|
||||
moveSpeed: number = 5;
|
||||
|
||||
@BlueprintProperty({ displayName: 'Score', type: 'int' })
|
||||
score: number = 0;
|
||||
|
||||
@BlueprintMethod({ displayName: 'Add Score' })
|
||||
addScore(points: number): void {
|
||||
this.score += points;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: 'Health', category: 'gameplay' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: 'Current Health' })
|
||||
current: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: 'Max Health' })
|
||||
max: number = 100;
|
||||
|
||||
@BlueprintMethod({ displayName: 'Heal' })
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: 'Take Damage' })
|
||||
takeDamage(amount: number): boolean {
|
||||
this.current -= amount;
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Initialize game
|
||||
async function initGame() {
|
||||
const scene = new Scene();
|
||||
|
||||
// Add blueprint system
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
|
||||
Core.setScene(scene);
|
||||
|
||||
// 3. Create player
|
||||
const player = scene.createEntity('Player');
|
||||
player.addComponent(new PlayerComponent());
|
||||
player.addComponent(new HealthComponent());
|
||||
|
||||
// Add blueprint control
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
player.addComponent(blueprint);
|
||||
}
|
||||
```
|
||||
|
||||
## State Switching Logic
|
||||
## Custom Node Example
|
||||
|
||||
```typescript
|
||||
// Implement state machine logic in blueprint
|
||||
const stateBlueprint = createEmptyBlueprint('PlayerState');
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintNodeTemplate,
|
||||
BlueprintNode,
|
||||
ExecutionContext,
|
||||
ExecutionResult,
|
||||
INodeExecutor,
|
||||
RegisterNode
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
// Add state variable
|
||||
stateBlueprint.variables.push({
|
||||
name: 'currentState',
|
||||
type: 'string',
|
||||
defaultValue: 'idle',
|
||||
scope: 'instance'
|
||||
});
|
||||
|
||||
// Check state transitions in Tick event
|
||||
// ... implemented via node connections
|
||||
```
|
||||
|
||||
## Damage Handling System
|
||||
|
||||
```typescript
|
||||
// Custom damage node
|
||||
const ApplyDamageTemplate: BlueprintNodeTemplate = {
|
||||
type: 'ApplyDamage',
|
||||
title: 'Apply Damage',
|
||||
category: 'combat',
|
||||
color: '#aa2222',
|
||||
description: 'Apply damage to entity with Health component',
|
||||
keywords: ['damage', 'hurt', 'attack'],
|
||||
menuPath: ['Combat', 'Apply Damage'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
|
||||
{ name: 'target', type: 'entity', direction: 'input' },
|
||||
{ name: 'amount', type: 'number', direction: 'input', defaultValue: 10 }
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'target', type: 'entity', displayName: 'Target' },
|
||||
{ name: 'amount', type: 'float', displayName: 'Damage', defaultValue: 10 }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
|
||||
{ name: 'killed', type: 'boolean', direction: 'output' }
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'killed', type: 'bool', displayName: 'Killed' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(ApplyDamageTemplate)
|
||||
class ApplyDamageExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const target = context.getInput<Entity>(node.id, 'target');
|
||||
const amount = context.getInput<number>(node.id, 'amount');
|
||||
const target = context.evaluateInput(node.id, 'target', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!target || target.isDestroyed) {
|
||||
return { outputs: { killed: false }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
const health = target.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
const health = target.getComponent(HealthComponent);
|
||||
if (health) {
|
||||
health.current -= amount;
|
||||
const killed = health.current <= 0;
|
||||
return {
|
||||
outputs: { killed },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
return { outputs: { killed }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
return { outputs: { killed: false }, nextExec: 'exec' };
|
||||
@@ -132,7 +174,8 @@ vm.maxStepsPerFrame = 1000;
|
||||
|
||||
```typescript
|
||||
// Enable debug mode for execution logs
|
||||
vm.debug = true;
|
||||
const blueprint = entity.getComponent(BlueprintComponent);
|
||||
blueprint.debug = true;
|
||||
|
||||
// Use Print nodes for intermediate values
|
||||
// Set breakpoints in editor
|
||||
|
||||
@@ -1,414 +1,162 @@
|
||||
---
|
||||
title: "Blueprint Visual Scripting"
|
||||
description: "Visual scripting system deeply integrated with ECS framework"
|
||||
---
|
||||
|
||||
`@esengine/blueprint` provides a full-featured visual scripting system supporting node-based programming, event-driven execution, and blueprint composition.
|
||||
`@esengine/blueprint` provides a visual scripting system deeply integrated with the ECS framework, supporting node-based programming to control entity behavior.
|
||||
|
||||
## Installation
|
||||
## Editor Download
|
||||
|
||||
Blueprint Editor Plugin for Cocos Creator (Free):
|
||||
|
||||
**[Download Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
|
||||
> QQ Group: **481923584** | Website: [esengine.cn](https://esengine.cn/)
|
||||
|
||||
For detailed usage instructions, see [Editor User Guide](./editor-guide).
|
||||
|
||||
## Runtime Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/blueprint
|
||||
```
|
||||
|
||||
## Core Features
|
||||
|
||||
- **Deep ECS Integration** - Built-in Entity and Component operation nodes
|
||||
- **Auto-generated Component Nodes** - Use decorators to mark components, auto-generate Get/Set/Call nodes
|
||||
- **Runtime Blueprint Execution** - Efficient virtual machine executes blueprint logic
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add Blueprint System
|
||||
|
||||
```typescript
|
||||
import { Scene, Core } from '@esengine/ecs-framework';
|
||||
import { BlueprintSystem } from '@esengine/blueprint';
|
||||
|
||||
// Create scene and add blueprint system
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
|
||||
// Set scene
|
||||
Core.setScene(scene);
|
||||
```
|
||||
|
||||
### 2. Add Blueprint to Entity
|
||||
|
||||
```typescript
|
||||
import { BlueprintComponent } from '@esengine/blueprint';
|
||||
|
||||
// Create entity
|
||||
const player = scene.createEntity('Player');
|
||||
|
||||
// Add blueprint component
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
blueprint.autoStart = true;
|
||||
player.addComponent(blueprint);
|
||||
```
|
||||
|
||||
### 3. Mark Components (Auto-generate Blueprint Nodes)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createBlueprintSystem,
|
||||
createBlueprintComponentData,
|
||||
NodeRegistry,
|
||||
RegisterNode
|
||||
BlueprintExpose,
|
||||
BlueprintProperty,
|
||||
BlueprintMethod
|
||||
} from '@esengine/blueprint';
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
|
||||
// Create blueprint system
|
||||
const blueprintSystem = createBlueprintSystem(scene);
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: 'Health', category: 'gameplay' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: 'Current Health', type: 'float' })
|
||||
current: number = 100;
|
||||
|
||||
// Load blueprint asset
|
||||
const blueprint = await loadBlueprintAsset('player.bp');
|
||||
@BlueprintProperty({ displayName: 'Max Health', type: 'float' })
|
||||
max: number = 100;
|
||||
|
||||
// Create blueprint component data
|
||||
const componentData = createBlueprintComponentData();
|
||||
componentData.blueprintAsset = blueprint;
|
||||
@BlueprintMethod({
|
||||
displayName: 'Heal',
|
||||
params: [{ name: 'amount', type: 'float' }]
|
||||
})
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
// Update in game loop
|
||||
function gameLoop(dt: number) {
|
||||
blueprintSystem.process(entities, dt);
|
||||
@BlueprintMethod({ displayName: 'Take Damage' })
|
||||
takeDamage(amount: number): boolean {
|
||||
this.current -= amount;
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
After marking, the following nodes will appear in the blueprint editor:
|
||||
- **Get Health** - Get Health component
|
||||
- **Get Current Health** - Get current property
|
||||
- **Set Current Health** - Set current property
|
||||
- **Heal** - Call heal method
|
||||
- **Take Damage** - Call takeDamage method
|
||||
|
||||
### Blueprint Asset Structure
|
||||
## ECS Integration Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Core.update() │
|
||||
│ ↓ │
|
||||
│ Scene.updateSystems() │
|
||||
│ ↓ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ BlueprintSystem │ │
|
||||
│ │ │ │
|
||||
│ │ Matcher.all(BlueprintComponent) │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ process(entities) → blueprint.tick() for each entity │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ BlueprintVM.tick(dt) │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ Execute Event/ECS/Flow Nodes │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Node Types
|
||||
|
||||
| Category | Description | Color |
|
||||
|----------|-------------|-------|
|
||||
| `event` | Event nodes (BeginPlay, Tick, EndPlay) | Red |
|
||||
| `entity` | ECS entity operations | Blue |
|
||||
| `component` | ECS component access | Cyan |
|
||||
| `flow` | Flow control (Branch, Sequence, Loop) | Gray |
|
||||
| `math` | Math operations | Green |
|
||||
| `time` | Time utilities (Delay, GetDeltaTime) | Cyan |
|
||||
| `debug` | Debug utilities (Print) | Gray |
|
||||
|
||||
## 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
|
||||
version: number;
|
||||
type: 'blueprint';
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
variables: BlueprintVariable[];
|
||||
nodes: BlueprintNode[];
|
||||
connections: BlueprintConnection[];
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Virtual Machine API](./vm) - BlueprintVM execution and context
|
||||
- [Custom Nodes](./custom-nodes) - Creating custom nodes
|
||||
- [Built-in Nodes](./nodes) - Built-in node reference
|
||||
- [Blueprint Composition](./composition) - Fragments and composer
|
||||
- [Examples](./examples) - ECS integration and best practices
|
||||
## Documentation Navigation
|
||||
|
||||
- [Editor User Guide](./editor-guide) - Cocos Creator Blueprint Editor tutorial
|
||||
- [Virtual Machine API](./vm) - BlueprintVM and ECS integration
|
||||
- [ECS Node Reference](./nodes) - Built-in ECS operation nodes
|
||||
- [Custom Nodes](./custom-nodes) - Create custom ECS nodes
|
||||
- [Blueprint Composition](./composition) - Fragment reuse
|
||||
- [Examples](./examples) - ECS game logic examples
|
||||
|
||||
@@ -1,107 +1,616 @@
|
||||
---
|
||||
title: "Built-in Nodes"
|
||||
description: "Blueprint built-in node reference"
|
||||
title: "ECS Node Reference"
|
||||
description: "Blueprint built-in ECS operation nodes - complete reference with visual examples"
|
||||
---
|
||||
|
||||
This document provides a complete reference for all built-in blueprint nodes with visual examples.
|
||||
|
||||
<script src="/js/blueprint-graph.js"></script>
|
||||
|
||||
## Pin Type Legend
|
||||
|
||||
<div class="bp-legend">
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff" stroke="#fff" stroke-width="1"/></svg> Execution Flow</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#00a0e0" stroke-width="2"/></svg> Entity</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7030c0" stroke-width="2"/></svg> Component</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7ecd32" stroke-width="2"/></svg> Number</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#e060e0" stroke-width="2"/></svg> String</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#8c0000" stroke-width="2"/></svg> Boolean</div>
|
||||
</div>
|
||||
|
||||
## Event Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `EventBeginPlay` | Triggered when blueprint starts |
|
||||
| `EventTick` | Triggered each frame |
|
||||
| `EventEndPlay` | Triggered when blueprint stops |
|
||||
| `EventCollision` | Triggered on collision |
|
||||
| `EventInput` | Triggered on input event |
|
||||
| `EventTimer` | Triggered by timer |
|
||||
| `EventMessage` | Triggered by custom message |
|
||||
Lifecycle events as blueprint entry points:
|
||||
|
||||
## Flow Control Nodes
|
||||
| Node | Description | Outputs |
|
||||
|------|-------------|---------|
|
||||
| `EventBeginPlay` | Triggered when blueprint starts | Exec, Self (Entity) |
|
||||
| `EventTick` | Triggered each frame | Exec, Delta Time |
|
||||
| `EventEndPlay` | Triggered when blueprint stops | Exec |
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Branch` | Conditional branch (if/else) |
|
||||
| `Sequence` | Execute multiple outputs in sequence |
|
||||
| `ForLoop` | Loop execution |
|
||||
| `WhileLoop` | Conditional loop |
|
||||
| `DoOnce` | Execute only once |
|
||||
| `FlipFlop` | Alternate between two branches |
|
||||
| `Gate` | Toggleable execution gate |
|
||||
### Example: Game Initialization
|
||||
|
||||
## Time Nodes
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-beginplay-exec","to":"en-print-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="en-beginplay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Message</span>
|
||||
<span class="bp-pin-value">"Game Started!"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Delay` | Delay execution |
|
||||
| `GetDeltaTime` | Get frame delta time |
|
||||
| `GetTime` | Get runtime |
|
||||
| `SetTimer` | Set timer |
|
||||
| `ClearTimer` | Clear timer |
|
||||
### Example: Per-Frame Movement
|
||||
|
||||
## Math Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Add` | Addition |
|
||||
| `Subtract` | Subtraction |
|
||||
| `Multiply` | Multiplication |
|
||||
| `Divide` | Division |
|
||||
| `Abs` | Absolute value |
|
||||
| `Clamp` | Clamp to range |
|
||||
| `Lerp` | Linear interpolation |
|
||||
| `Min` / `Max` | Minimum/Maximum |
|
||||
| `Sin` / `Cos` | Trigonometric functions |
|
||||
| `Sqrt` | Square root |
|
||||
| `Power` | Power |
|
||||
|
||||
## Logic Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `And` | Logical AND |
|
||||
| `Or` | Logical OR |
|
||||
| `Not` | Logical NOT |
|
||||
| `Equal` | Equality comparison |
|
||||
| `NotEqual` | Inequality comparison |
|
||||
| `Greater` | Greater than comparison |
|
||||
| `Less` | Less than comparison |
|
||||
|
||||
## Vector Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `MakeVector2` | Create 2D vector |
|
||||
| `BreakVector2` | Break 2D vector |
|
||||
| `VectorAdd` | Vector addition |
|
||||
| `VectorSubtract` | Vector subtraction |
|
||||
| `VectorMultiply` | Vector multiplication |
|
||||
| `VectorLength` | Vector length |
|
||||
| `VectorNormalize` | Vector normalization |
|
||||
| `VectorDistance` | Vector distance |
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-tick-exec","to":"en-setprop-exec","type":"exec"},{"from":"en-tick-delta","to":"en-mul-a","type":"float"},{"from":"en-mul-result","to":"en-setprop-x","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="en-tick-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-tick-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta Time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
|
||||
<div class="bp-node-header math">Multiply</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-setprop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-setprop-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Entity Nodes
|
||||
|
||||
Manipulate ECS entities:
|
||||
|
||||
| Node | Description | Type |
|
||||
|------|-------------|------|
|
||||
| `Get Self` | Get the entity owning this blueprint | Pure |
|
||||
| `Create Entity` | Create a new entity in the scene | Exec |
|
||||
| `Destroy Entity` | Destroy specified entity | Exec |
|
||||
| `Destroy Self` | Destroy the owning entity | Exec |
|
||||
| `Is Valid` | Check if entity is valid | Pure |
|
||||
| `Get Entity Name` | Get entity name | Pure |
|
||||
| `Set Entity Name` | Set entity name | Exec |
|
||||
| `Find Entity By Name` | Find entity by name | Pure |
|
||||
| `Find Entities By Tag` | Find all entities with tag | Pure |
|
||||
|
||||
### Example: Create Bullet
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-bp-exec","to":"en-create-exec","type":"exec"},{"from":"en-create-exec-out","to":"en-add-exec","type":"exec"},{"from":"en-create-entity","to":"en-add-entity","type":"entity"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="en-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-create-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-create-exec-out"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-create-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 520px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Add Transform</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-add-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-add-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Component Nodes
|
||||
|
||||
Read and write component properties:
|
||||
|
||||
| Node | Description | Type |
|
||||
|------|-------------|------|
|
||||
| `Get Component` | Get component of specified type from entity | Pure |
|
||||
| `Has Component` | Check if entity has specified component | Pure |
|
||||
| `Add Component` | Add component to entity | Exec |
|
||||
| `Remove Component` | Remove component from entity | Exec |
|
||||
| `Get Property` | Get component property value | Pure |
|
||||
| `Set Property` | Set component property value | Exec |
|
||||
|
||||
### Example: Modify Position
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-self-entity","to":"en-getcomp-entity","type":"entity"},{"from":"en-getcomp-transform","to":"en-getprop-target","type":"component"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 100px;">
|
||||
<div class="bp-node-header pure">Get Self</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-self-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header pure">Get Component</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-getcomp-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-getcomp-transform"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Transform</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header pure">Get Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-getprop-target"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Flow Control Nodes
|
||||
|
||||
Control blueprint execution flow:
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `GetSelf` | Get current entity |
|
||||
| `GetComponent` | Get component |
|
||||
| `HasComponent` | Check component |
|
||||
| `AddComponent` | Add component |
|
||||
| `RemoveComponent` | Remove component |
|
||||
| `SpawnEntity` | Create entity |
|
||||
| `DestroyEntity` | Destroy entity |
|
||||
| `Branch` | Conditional branching (if/else) |
|
||||
| `Sequence` | Execute multiple branches in order |
|
||||
| `For Loop` | Loop specified number of times |
|
||||
| `For Each` | Iterate over array elements |
|
||||
| `While Loop` | Loop while condition is true |
|
||||
| `Do Once` | Execute only once |
|
||||
| `Flip Flop` | Alternate between A and B |
|
||||
| `Gate` | Gate switch control |
|
||||
|
||||
### Example: Conditional Branch
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-cond-exec","to":"en-branch-exec","type":"exec"},{"from":"en-cond-result","to":"en-branch-cond","type":"bool"},{"from":"en-branch-true","to":"en-print1-exec","type":"exec"},{"from":"en-branch-false","to":"en-print2-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 60px; width: 120px;">
|
||||
<div class="bp-node-header pure">Condition</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-cond-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-cond-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 60px; width: 110px;">
|
||||
<div class="bp-node-header flow">Branch</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-branch-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-branch-cond"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
|
||||
<span class="bp-pin-label">Cond</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-branch-true"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">True</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-branch-false"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">False</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 420px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-print1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"Yes"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 420px; top: 130px; width: 120px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-print2-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"No"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Example: For Loop
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-forloop-bp-exec","to":"en-forloop-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="en-forloop-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header flow">For Loop</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-forloop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">First</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Last</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Time Nodes
|
||||
|
||||
| Node | Description | Output |
|
||||
|------|-------------|--------|
|
||||
| `Delay` | Delay execution by specified seconds | Exec |
|
||||
| `Get Delta Time` | Get frame delta time | Float |
|
||||
| `Get Time` | Get total runtime | Float |
|
||||
|
||||
### Example: Delayed Execution
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-delay-bp-exec","to":"en-delay-exec","type":"exec"},{"from":"en-delay-done","to":"en-delay-print-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="en-delay-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-delay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 490px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-delay-print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"After 2s"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Math Nodes
|
||||
|
||||
### Basic Operations
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Add` | Addition | A, B | A + B |
|
||||
| `Subtract` | Subtraction | A, B | A - B |
|
||||
| `Multiply` | Multiplication | A, B | A × B |
|
||||
| `Divide` | Division | A, B | A / B |
|
||||
| `Modulo` | Modulo | A, B | A % B |
|
||||
|
||||
### Math Functions
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Abs` | Absolute value | Value | \|Value\| |
|
||||
| `Sqrt` | Square root | Value | √Value |
|
||||
| `Pow` | Power | Base, Exp | Base^Exp |
|
||||
| `Floor` | Floor | Value | ⌊Value⌋ |
|
||||
| `Ceil` | Ceiling | Value | ⌈Value⌉ |
|
||||
| `Round` | Round | Value | round(Value) |
|
||||
| `Clamp` | Clamp to range | Value, Min, Max | min(max(V, Min), Max) |
|
||||
| `Lerp` | Linear interpolation | A, B, Alpha | A + (B-A) × Alpha |
|
||||
| `Min` | Minimum | A, B | min(A, B) |
|
||||
| `Max` | Maximum | A, B | max(A, B) |
|
||||
|
||||
### Trigonometric Functions
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Sin` | Sine |
|
||||
| `Cos` | Cosine |
|
||||
| `Tan` | Tangent |
|
||||
| `Asin` | Arc sine |
|
||||
| `Acos` | Arc cosine |
|
||||
| `Atan` | Arc tangent |
|
||||
| `Atan2` | Two-argument arc tangent |
|
||||
|
||||
### Random Numbers
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Random` | Random float [0, 1) | - | Float |
|
||||
| `Random Range` | Random in range | Min, Max | Float |
|
||||
| `Random Int` | Random integer | Min, Max | Int |
|
||||
|
||||
### Comparison Nodes
|
||||
|
||||
| Node | Description | Output |
|
||||
|------|-------------|--------|
|
||||
| `Equal` | A == B | Boolean |
|
||||
| `Not Equal` | A != B | Boolean |
|
||||
| `Greater` | A > B | Boolean |
|
||||
| `Greater Or Equal` | A >= B | Boolean |
|
||||
| `Less` | A < B | Boolean |
|
||||
| `Less Or Equal` | A <= B | Boolean |
|
||||
|
||||
### Extended Math Nodes
|
||||
|
||||
> **Vector2, Fixed32, FixedVector2, Color** and other advanced math nodes are provided by the `@esengine/ecs-framework-math` module.
|
||||
>
|
||||
> See: [Math Blueprint Nodes](/en/modules/math/blueprint-nodes)
|
||||
|
||||
### Example: Clamp Value
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-rand-result","to":"en-clamp-value","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math">Random Range</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Min</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Max</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-rand-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math">Clamp</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-clamp-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Min</span>
|
||||
<span class="bp-pin-value">20</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Max</span>
|
||||
<span class="bp-pin-value">80</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Variable Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `GetVariable` | Get variable value |
|
||||
| `SetVariable` | Set variable value |
|
||||
Blueprint-defined variables automatically generate Get and Set nodes:
|
||||
|
||||
| Node | Description | Type |
|
||||
|------|-------------|------|
|
||||
| `Get <varname>` | Read variable value | Pure |
|
||||
| `Set <varname>` | Set variable value | Exec |
|
||||
|
||||
### Example: Counter
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-cnt-tick-exec","to":"en-cnt-add-exec","type":"exec"},{"from":"en-cnt-get-value","to":"en-cnt-add-a","type":"int"},{"from":"en-cnt-add-result","to":"en-cnt-set-value","type":"int"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="en-cnt-tick-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 20px; top: 120px; width: 110px;">
|
||||
<div class="bp-node-header variable">Get Count</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-cnt-get-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 110px;">
|
||||
<div class="bp-node-header math">Add</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-cnt-add-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-cnt-add-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
<span class="bp-pin-value">1</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-cnt-add-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 110px;">
|
||||
<div class="bp-node-header variable">Set Count</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-cnt-set-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Debug Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Print` | Print to console |
|
||||
| `DrawDebugLine` | Draw debug line |
|
||||
| `DrawDebugPoint` | Draw debug point |
|
||||
| `Breakpoint` | Debug breakpoint |
|
||||
| `Print` | Output message to console |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Math Blueprint Nodes](/en/modules/math/blueprint-nodes) - Vector2, Fixed32, Color and other math nodes
|
||||
- [Blueprint Editor Guide](/en/modules/blueprint/editor-guide) - Learn how to use the editor
|
||||
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Create custom nodes
|
||||
- [Blueprint VM](/en/modules/blueprint/vm) - Runtime API
|
||||
|
||||
@@ -45,7 +45,7 @@ interface ExecutionContext {
|
||||
time: number; // Total runtime
|
||||
|
||||
// Get input value
|
||||
getInput<T>(nodeId: string, pinName: string): T;
|
||||
evaluateInput(nodeId: string, pinName: string, defaultValue: unknown): unknown;
|
||||
|
||||
// Set output value
|
||||
setOutput(nodeId: string, pinName: string, value: unknown): void;
|
||||
@@ -70,35 +70,33 @@ interface ExecutionResult {
|
||||
|
||||
## ECS Integration
|
||||
|
||||
### Using Blueprint System
|
||||
### Using Built-in Blueprint System
|
||||
|
||||
```typescript
|
||||
import { createBlueprintSystem } from '@esengine/blueprint';
|
||||
import { Scene, Core } from '@esengine/ecs-framework';
|
||||
import { BlueprintSystem, BlueprintComponent } from '@esengine/blueprint';
|
||||
|
||||
class GameScene {
|
||||
private blueprintSystem: BlueprintSystem;
|
||||
// Add blueprint system to scene
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
Core.setScene(scene);
|
||||
|
||||
initialize() {
|
||||
this.blueprintSystem = createBlueprintSystem(this.scene);
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
// Process all entities with blueprint components
|
||||
this.blueprintSystem.process(this.entities, dt);
|
||||
}
|
||||
}
|
||||
// Add blueprint to entity
|
||||
const entity = scene.createEntity('Player');
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
entity.addComponent(blueprint);
|
||||
```
|
||||
|
||||
### Triggering Blueprint Events
|
||||
|
||||
```typescript
|
||||
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
|
||||
|
||||
// Trigger built-in event
|
||||
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
|
||||
|
||||
// Trigger custom event
|
||||
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
|
||||
// Get blueprint component from entity and trigger events
|
||||
const blueprint = entity.getComponent(BlueprintComponent);
|
||||
if (blueprint?.vm) {
|
||||
blueprint.vm.triggerEvent('EventCollision', { other: otherEntity });
|
||||
blueprint.vm.triggerCustomEvent('OnPickup', { item: itemEntity });
|
||||
}
|
||||
```
|
||||
|
||||
## Serialization
|
||||
|
||||
136
docs/src/content/docs/en/modules/database-drivers/index.md
Normal file
136
docs/src/content/docs/en/modules/database-drivers/index.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: "Database Drivers"
|
||||
description: "MongoDB, Redis connection management and driver abstraction"
|
||||
---
|
||||
|
||||
`@esengine/database-drivers` is ESEngine's database connection management layer, providing unified connection management for MongoDB, Redis, and more.
|
||||
|
||||
## Features
|
||||
|
||||
- **Connection Pool** - Automatic connection pool management
|
||||
- **Auto Reconnect** - Automatic reconnection on disconnect
|
||||
- **Event Notification** - Connection state change events
|
||||
- **Type Decoupling** - Simplified interfaces, no dependency on native driver types
|
||||
- **Shared Connections** - Single connection shared across modules
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/database-drivers
|
||||
```
|
||||
|
||||
**Peer Dependencies:**
|
||||
```bash
|
||||
npm install mongodb # For MongoDB support
|
||||
npm install ioredis # For Redis support
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ @esengine/database-drivers (Layer 1) │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ MongoConnection │ │ RedisConnection │ │
|
||||
│ │ - Pool management │ │ - Auto-reconnect │ │
|
||||
│ │ - Auto-reconnect │ │ - Key prefix │ │
|
||||
│ │ - Event emitter │ │ - Event emitter │ │
|
||||
│ └──────────┬──────────┘ └─────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IMongoCollection<T> │ ← Type-safe interface │
|
||||
│ │ (Adapter pattern) │ decoupled from mongodb types │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ @esengine/database │ │ @esengine/transaction │
|
||||
│ (Repository pattern) │ │ (Distributed tx) │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### MongoDB Connection
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
|
||||
// Create connection
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game',
|
||||
pool: {
|
||||
minSize: 5,
|
||||
maxSize: 20
|
||||
},
|
||||
autoReconnect: true
|
||||
})
|
||||
|
||||
// Listen to events
|
||||
mongo.on('connected', () => console.log('MongoDB connected'))
|
||||
mongo.on('disconnected', () => console.log('MongoDB disconnected'))
|
||||
mongo.on('error', (e) => console.error('Error:', e.error))
|
||||
|
||||
// Connect
|
||||
await mongo.connect()
|
||||
|
||||
// Use collections
|
||||
const users = mongo.collection<User>('users')
|
||||
await users.insertOne({ name: 'John', score: 100 })
|
||||
|
||||
const user = await users.findOne({ name: 'John' })
|
||||
|
||||
// Disconnect when done
|
||||
await mongo.disconnect()
|
||||
```
|
||||
|
||||
### Redis Connection
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection } from '@esengine/database-drivers'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
keyPrefix: 'game:',
|
||||
autoReconnect: true
|
||||
})
|
||||
|
||||
await redis.connect()
|
||||
|
||||
// Basic operations
|
||||
await redis.set('session:123', 'data', 3600) // With TTL
|
||||
const value = await redis.get('session:123')
|
||||
|
||||
await redis.disconnect()
|
||||
```
|
||||
|
||||
## Service Container Integration
|
||||
|
||||
```typescript
|
||||
import { ServiceContainer } from '@esengine/ecs-framework'
|
||||
import {
|
||||
createMongoConnection,
|
||||
MongoConnectionToken,
|
||||
RedisConnectionToken
|
||||
} from '@esengine/database-drivers'
|
||||
|
||||
const services = new ServiceContainer()
|
||||
|
||||
// Register connections
|
||||
const mongo = createMongoConnection({ uri: '...', database: 'game' })
|
||||
await mongo.connect()
|
||||
services.register(MongoConnectionToken, mongo)
|
||||
|
||||
// Retrieve in other modules
|
||||
const connection = services.get(MongoConnectionToken)
|
||||
const users = connection.collection('users')
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [MongoDB Connection](/en/modules/database-drivers/mongo/) - MongoDB configuration details
|
||||
- [Redis Connection](/en/modules/database-drivers/redis/) - Redis configuration details
|
||||
- [Service Tokens](/en/modules/database-drivers/tokens/) - Dependency injection integration
|
||||
265
docs/src/content/docs/en/modules/database-drivers/mongo.md
Normal file
265
docs/src/content/docs/en/modules/database-drivers/mongo.md
Normal file
@@ -0,0 +1,265 @@
|
||||
---
|
||||
title: "MongoDB Connection"
|
||||
description: "MongoDB connection management, connection pooling, auto-reconnect"
|
||||
---
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```typescript
|
||||
interface MongoConnectionConfig {
|
||||
/** MongoDB connection URI */
|
||||
uri: string
|
||||
|
||||
/** Database name */
|
||||
database: string
|
||||
|
||||
/** Connection pool configuration */
|
||||
pool?: {
|
||||
minSize?: number // Minimum connections
|
||||
maxSize?: number // Maximum connections
|
||||
acquireTimeout?: number // Connection acquire timeout (ms)
|
||||
maxLifetime?: number // Maximum connection lifetime (ms)
|
||||
}
|
||||
|
||||
/** Auto-reconnect (default true) */
|
||||
autoReconnect?: boolean
|
||||
|
||||
/** Reconnect interval (ms, default 5000) */
|
||||
reconnectInterval?: number
|
||||
|
||||
/** Maximum reconnect attempts (default 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection, MongoConnectionToken } from '@esengine/database-drivers'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game',
|
||||
pool: {
|
||||
minSize: 5,
|
||||
maxSize: 20,
|
||||
acquireTimeout: 5000,
|
||||
maxLifetime: 300000
|
||||
},
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10
|
||||
})
|
||||
|
||||
// Event listeners
|
||||
mongo.on('connected', () => {
|
||||
console.log('MongoDB connected')
|
||||
})
|
||||
|
||||
mongo.on('disconnected', () => {
|
||||
console.log('MongoDB disconnected')
|
||||
})
|
||||
|
||||
mongo.on('reconnecting', () => {
|
||||
console.log('MongoDB reconnecting...')
|
||||
})
|
||||
|
||||
mongo.on('reconnected', () => {
|
||||
console.log('MongoDB reconnected')
|
||||
})
|
||||
|
||||
mongo.on('error', (event) => {
|
||||
console.error('MongoDB error:', event.error)
|
||||
})
|
||||
|
||||
// Connect
|
||||
await mongo.connect()
|
||||
|
||||
// Check status
|
||||
console.log('Connected:', mongo.isConnected())
|
||||
console.log('Ping:', await mongo.ping())
|
||||
```
|
||||
|
||||
## IMongoConnection Interface
|
||||
|
||||
```typescript
|
||||
interface IMongoConnection {
|
||||
/** Connection ID */
|
||||
readonly id: string
|
||||
|
||||
/** Connection state */
|
||||
readonly state: ConnectionState
|
||||
|
||||
/** Establish connection */
|
||||
connect(): Promise<void>
|
||||
|
||||
/** Disconnect */
|
||||
disconnect(): Promise<void>
|
||||
|
||||
/** Check if connected */
|
||||
isConnected(): boolean
|
||||
|
||||
/** Test connection */
|
||||
ping(): Promise<boolean>
|
||||
|
||||
/** Get typed collection */
|
||||
collection<T extends object>(name: string): IMongoCollection<T>
|
||||
|
||||
/** Get database interface */
|
||||
getDatabase(): IMongoDatabase
|
||||
|
||||
/** Get native client (advanced usage) */
|
||||
getNativeClient(): MongoClientType
|
||||
|
||||
/** Get native database (advanced usage) */
|
||||
getNativeDatabase(): Db
|
||||
}
|
||||
```
|
||||
|
||||
## IMongoCollection Interface
|
||||
|
||||
Type-safe collection interface, decoupled from native MongoDB types:
|
||||
|
||||
```typescript
|
||||
interface IMongoCollection<T extends object> {
|
||||
readonly name: string
|
||||
|
||||
// Query
|
||||
findOne(filter: object, options?: FindOptions): Promise<T | null>
|
||||
find(filter: object, options?: FindOptions): Promise<T[]>
|
||||
countDocuments(filter?: object): Promise<number>
|
||||
|
||||
// Insert
|
||||
insertOne(doc: T): Promise<InsertOneResult>
|
||||
insertMany(docs: T[]): Promise<InsertManyResult>
|
||||
|
||||
// Update
|
||||
updateOne(filter: object, update: object): Promise<UpdateResult>
|
||||
updateMany(filter: object, update: object): Promise<UpdateResult>
|
||||
findOneAndUpdate(
|
||||
filter: object,
|
||||
update: object,
|
||||
options?: FindOneAndUpdateOptions
|
||||
): Promise<T | null>
|
||||
|
||||
// Delete
|
||||
deleteOne(filter: object): Promise<DeleteResult>
|
||||
deleteMany(filter: object): Promise<DeleteResult>
|
||||
|
||||
// Index
|
||||
createIndex(
|
||||
spec: Record<string, 1 | -1>,
|
||||
options?: IndexOptions
|
||||
): Promise<string>
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic CRUD
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
score: number
|
||||
}
|
||||
|
||||
const users = mongo.collection<User>('users')
|
||||
|
||||
// Insert
|
||||
await users.insertOne({
|
||||
id: '1',
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
score: 100
|
||||
})
|
||||
|
||||
// Query
|
||||
const user = await users.findOne({ name: 'John' })
|
||||
|
||||
const topUsers = await users.find(
|
||||
{ score: { $gte: 100 } },
|
||||
{ sort: { score: -1 }, limit: 10 }
|
||||
)
|
||||
|
||||
// Update
|
||||
await users.updateOne(
|
||||
{ id: '1' },
|
||||
{ $inc: { score: 10 } }
|
||||
)
|
||||
|
||||
// Delete
|
||||
await users.deleteOne({ id: '1' })
|
||||
```
|
||||
|
||||
### Batch Operations
|
||||
|
||||
```typescript
|
||||
// Batch insert
|
||||
await users.insertMany([
|
||||
{ id: '1', name: 'Alice', email: 'alice@example.com', score: 100 },
|
||||
{ id: '2', name: 'Bob', email: 'bob@example.com', score: 200 },
|
||||
{ id: '3', name: 'Carol', email: 'carol@example.com', score: 150 }
|
||||
])
|
||||
|
||||
// Batch update
|
||||
await users.updateMany(
|
||||
{ score: { $lt: 100 } },
|
||||
{ $set: { status: 'inactive' } }
|
||||
)
|
||||
|
||||
// Batch delete
|
||||
await users.deleteMany({ status: 'inactive' })
|
||||
```
|
||||
|
||||
### Index Management
|
||||
|
||||
```typescript
|
||||
// Create indexes
|
||||
await users.createIndex({ email: 1 }, { unique: true })
|
||||
await users.createIndex({ score: -1 })
|
||||
await users.createIndex({ name: 1, score: -1 })
|
||||
```
|
||||
|
||||
## Integration with Other Modules
|
||||
|
||||
### With @esengine/database
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { UserRepository, createRepository } from '@esengine/database'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// Use UserRepository
|
||||
const userRepo = new UserRepository(mongo)
|
||||
await userRepo.register({ username: 'john', password: '123456' })
|
||||
|
||||
// Use generic repository
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
```
|
||||
|
||||
### With @esengine/transaction
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { createMongoStorage, TransactionManager } from '@esengine/transaction'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// Create transaction storage (shared connection)
|
||||
const storage = createMongoStorage(mongo)
|
||||
await storage.ensureIndexes()
|
||||
|
||||
const txManager = new TransactionManager({ storage })
|
||||
```
|
||||
228
docs/src/content/docs/en/modules/database-drivers/redis.md
Normal file
228
docs/src/content/docs/en/modules/database-drivers/redis.md
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: "Redis Connection"
|
||||
description: "Redis connection management, auto-reconnect, key prefix"
|
||||
---
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```typescript
|
||||
interface RedisConnectionConfig {
|
||||
/** Redis host */
|
||||
host?: string
|
||||
|
||||
/** Redis port */
|
||||
port?: number
|
||||
|
||||
/** Authentication password */
|
||||
password?: string
|
||||
|
||||
/** Database number */
|
||||
db?: number
|
||||
|
||||
/** Key prefix */
|
||||
keyPrefix?: string
|
||||
|
||||
/** Auto-reconnect (default true) */
|
||||
autoReconnect?: boolean
|
||||
|
||||
/** Reconnect interval (ms, default 5000) */
|
||||
reconnectInterval?: number
|
||||
|
||||
/** Maximum reconnect attempts (default 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection, RedisConnectionToken } from '@esengine/database-drivers'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: 'your-password',
|
||||
db: 0,
|
||||
keyPrefix: 'game:',
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10
|
||||
})
|
||||
|
||||
// Event listeners
|
||||
redis.on('connected', () => {
|
||||
console.log('Redis connected')
|
||||
})
|
||||
|
||||
redis.on('disconnected', () => {
|
||||
console.log('Redis disconnected')
|
||||
})
|
||||
|
||||
redis.on('error', (event) => {
|
||||
console.error('Redis error:', event.error)
|
||||
})
|
||||
|
||||
// Connect
|
||||
await redis.connect()
|
||||
|
||||
// Check status
|
||||
console.log('Connected:', redis.isConnected())
|
||||
console.log('Ping:', await redis.ping())
|
||||
```
|
||||
|
||||
## IRedisConnection Interface
|
||||
|
||||
```typescript
|
||||
interface IRedisConnection {
|
||||
/** Connection ID */
|
||||
readonly id: string
|
||||
|
||||
/** Connection state */
|
||||
readonly state: ConnectionState
|
||||
|
||||
/** Establish connection */
|
||||
connect(): Promise<void>
|
||||
|
||||
/** Disconnect */
|
||||
disconnect(): Promise<void>
|
||||
|
||||
/** Check if connected */
|
||||
isConnected(): boolean
|
||||
|
||||
/** Test connection */
|
||||
ping(): Promise<boolean>
|
||||
|
||||
/** Get value */
|
||||
get(key: string): Promise<string | null>
|
||||
|
||||
/** Set value (optional TTL in seconds) */
|
||||
set(key: string, value: string, ttl?: number): Promise<void>
|
||||
|
||||
/** Delete key */
|
||||
del(key: string): Promise<boolean>
|
||||
|
||||
/** Check if key exists */
|
||||
exists(key: string): Promise<boolean>
|
||||
|
||||
/** Set expiration (seconds) */
|
||||
expire(key: string, seconds: number): Promise<boolean>
|
||||
|
||||
/** Get remaining TTL (seconds) */
|
||||
ttl(key: string): Promise<number>
|
||||
|
||||
/** Get native client (advanced usage) */
|
||||
getNativeClient(): Redis
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Operations
|
||||
|
||||
```typescript
|
||||
// Set value
|
||||
await redis.set('user:1:name', 'John')
|
||||
|
||||
// Set value with expiration (1 hour)
|
||||
await redis.set('session:abc123', 'user-data', 3600)
|
||||
|
||||
// Get value
|
||||
const name = await redis.get('user:1:name')
|
||||
|
||||
// Check if key exists
|
||||
const exists = await redis.exists('user:1:name')
|
||||
|
||||
// Delete key
|
||||
await redis.del('user:1:name')
|
||||
|
||||
// Get remaining TTL
|
||||
const ttl = await redis.ttl('session:abc123')
|
||||
```
|
||||
|
||||
### Key Prefix
|
||||
|
||||
When `keyPrefix` is configured, all operations automatically add the prefix:
|
||||
|
||||
```typescript
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
keyPrefix: 'game:'
|
||||
})
|
||||
|
||||
// Actual key is 'game:user:1'
|
||||
await redis.set('user:1', 'data')
|
||||
|
||||
// Actual key queried is 'game:user:1'
|
||||
const data = await redis.get('user:1')
|
||||
```
|
||||
|
||||
### Advanced Operations
|
||||
|
||||
Use native client for advanced operations:
|
||||
|
||||
```typescript
|
||||
const client = redis.getNativeClient()
|
||||
|
||||
// Using Pipeline
|
||||
const pipeline = client.pipeline()
|
||||
pipeline.set('key1', 'value1')
|
||||
pipeline.set('key2', 'value2')
|
||||
pipeline.set('key3', 'value3')
|
||||
await pipeline.exec()
|
||||
|
||||
// Using Transactions
|
||||
const multi = client.multi()
|
||||
multi.incr('counter')
|
||||
multi.get('counter')
|
||||
const results = await multi.exec()
|
||||
|
||||
// Using Lua Scripts
|
||||
const result = await client.eval(
|
||||
`return redis.call('get', KEYS[1])`,
|
||||
1,
|
||||
'mykey'
|
||||
)
|
||||
```
|
||||
|
||||
## Integration with Transaction System
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection } from '@esengine/database-drivers'
|
||||
import { RedisStorage, TransactionManager } from '@esengine/transaction'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
keyPrefix: 'tx:'
|
||||
})
|
||||
await redis.connect()
|
||||
|
||||
// Create transaction storage
|
||||
const storage = new RedisStorage({
|
||||
factory: () => redis.getNativeClient(),
|
||||
prefix: 'tx:'
|
||||
})
|
||||
|
||||
const txManager = new TransactionManager({ storage })
|
||||
```
|
||||
|
||||
## Connection State
|
||||
|
||||
```typescript
|
||||
type ConnectionState =
|
||||
| 'disconnected' // Not connected
|
||||
| 'connecting' // Connecting
|
||||
| 'connected' // Connected
|
||||
| 'disconnecting' // Disconnecting
|
||||
| 'error' // Error state
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `connected` | Connection established |
|
||||
| `disconnected` | Connection closed |
|
||||
| `reconnecting` | Reconnecting |
|
||||
| `reconnected` | Reconnection successful |
|
||||
| `error` | Error occurred |
|
||||
217
docs/src/content/docs/en/modules/database/index.md
Normal file
217
docs/src/content/docs/en/modules/database/index.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
title: "Database Repository"
|
||||
description: "Repository pattern database layer with CRUD, pagination, and soft delete"
|
||||
---
|
||||
|
||||
`@esengine/database` is ESEngine's database operation layer, providing type-safe CRUD operations based on the Repository pattern.
|
||||
|
||||
## Features
|
||||
|
||||
- **Repository Pattern** - Generic CRUD operations with type safety
|
||||
- **Pagination** - Built-in pagination support
|
||||
- **Soft Delete** - Optional soft delete with restore
|
||||
- **User Management** - Ready-to-use UserRepository
|
||||
- **Password Security** - Secure password hashing with scrypt
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/database @esengine/database-drivers
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Repository
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { Repository, createRepository } from '@esengine/database'
|
||||
|
||||
// Define entity
|
||||
interface Player {
|
||||
id: string
|
||||
name: string
|
||||
score: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// Create connection
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// Create repository
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
|
||||
// CRUD operations
|
||||
const player = await playerRepo.create({
|
||||
name: 'John',
|
||||
score: 0
|
||||
})
|
||||
|
||||
const found = await playerRepo.findById(player.id)
|
||||
|
||||
await playerRepo.update(player.id, { score: 100 })
|
||||
|
||||
await playerRepo.delete(player.id)
|
||||
```
|
||||
|
||||
### Custom Repository
|
||||
|
||||
```typescript
|
||||
import { Repository, BaseEntity } from '@esengine/database'
|
||||
import type { IMongoConnection } from '@esengine/database-drivers'
|
||||
|
||||
interface Player extends BaseEntity {
|
||||
name: string
|
||||
score: number
|
||||
rank?: string
|
||||
}
|
||||
|
||||
class PlayerRepository extends Repository<Player> {
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'players')
|
||||
}
|
||||
|
||||
async findTopPlayers(limit: number = 10): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit
|
||||
})
|
||||
}
|
||||
|
||||
async findByRank(rank: string): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
where: { rank }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const playerRepo = new PlayerRepository(mongo)
|
||||
const topPlayers = await playerRepo.findTopPlayers(5)
|
||||
```
|
||||
|
||||
### User Repository
|
||||
|
||||
```typescript
|
||||
import { UserRepository } from '@esengine/database'
|
||||
|
||||
const userRepo = new UserRepository(mongo)
|
||||
|
||||
// Register new user
|
||||
const user = await userRepo.register({
|
||||
username: 'john',
|
||||
password: 'securePassword123',
|
||||
email: 'john@example.com'
|
||||
})
|
||||
|
||||
// Authenticate
|
||||
const authenticated = await userRepo.authenticate('john', 'securePassword123')
|
||||
if (authenticated) {
|
||||
console.log('Login successful:', authenticated.username)
|
||||
}
|
||||
|
||||
// Change password
|
||||
await userRepo.changePassword(user.id, 'securePassword123', 'newPassword456')
|
||||
|
||||
// Role management
|
||||
await userRepo.addRole(user.id, 'admin')
|
||||
await userRepo.removeRole(user.id, 'admin')
|
||||
|
||||
// Find users
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
const john = await userRepo.findByUsername('john')
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```typescript
|
||||
const result = await playerRepo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { rank: 'gold' },
|
||||
sort: { score: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
console.log(result.data) // Player[]
|
||||
console.log(result.total) // Total count
|
||||
console.log(result.totalPages) // Total pages
|
||||
console.log(result.hasNext) // Has next page
|
||||
console.log(result.hasPrev) // Has previous page
|
||||
```
|
||||
|
||||
### Soft Delete
|
||||
|
||||
```typescript
|
||||
// Enable soft delete
|
||||
const playerRepo = createRepository<Player>(mongo, 'players', true)
|
||||
|
||||
// Delete (marks as deleted)
|
||||
await playerRepo.delete(playerId)
|
||||
|
||||
// Find excludes soft-deleted by default
|
||||
const players = await playerRepo.findMany()
|
||||
|
||||
// Include soft-deleted records
|
||||
const allPlayers = await playerRepo.findMany({
|
||||
includeSoftDeleted: true
|
||||
})
|
||||
|
||||
// Restore soft-deleted record
|
||||
await playerRepo.restore(playerId)
|
||||
```
|
||||
|
||||
### Query Options
|
||||
|
||||
```typescript
|
||||
// Complex queries
|
||||
const players = await playerRepo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] },
|
||||
name: { $like: 'John%' }
|
||||
},
|
||||
sort: {
|
||||
score: 'desc',
|
||||
name: 'asc'
|
||||
},
|
||||
limit: 10,
|
||||
offset: 0
|
||||
})
|
||||
|
||||
// OR conditions
|
||||
const players = await playerRepo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ score: { $gte: 1000 } },
|
||||
{ rank: 'legendary' }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Query Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `$eq` | Equal | `{ score: { $eq: 100 } }` |
|
||||
| `$ne` | Not equal | `{ status: { $ne: 'banned' } }` |
|
||||
| `$gt` | Greater than | `{ score: { $gt: 50 } }` |
|
||||
| `$gte` | Greater or equal | `{ level: { $gte: 10 } }` |
|
||||
| `$lt` | Less than | `{ age: { $lt: 18 } }` |
|
||||
| `$lte` | Less or equal | `{ price: { $lte: 100 } }` |
|
||||
| `$in` | In array | `{ rank: { $in: ['gold', 'platinum'] } }` |
|
||||
| `$nin` | Not in array | `{ status: { $nin: ['banned'] } }` |
|
||||
| `$like` | Pattern match | `{ name: { $like: '%john%' } }` |
|
||||
| `$regex` | Regex match | `{ email: { $regex: '@gmail.com$' } }` |
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Repository API](/en/modules/database/repository/) - Repository detailed API
|
||||
- [User Management](/en/modules/database/user/) - UserRepository usage
|
||||
- [Query Syntax](/en/modules/database/query/) - Query condition syntax
|
||||
185
docs/src/content/docs/en/modules/database/query.md
Normal file
185
docs/src/content/docs/en/modules/database/query.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: "Query Syntax"
|
||||
description: "Query condition operators and syntax"
|
||||
---
|
||||
|
||||
## Basic Queries
|
||||
|
||||
### Exact Match
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
name: 'John',
|
||||
status: 'active'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Using Operators
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Query Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `$eq` | Equal | `{ score: { $eq: 100 } }` |
|
||||
| `$ne` | Not equal | `{ status: { $ne: 'banned' } }` |
|
||||
| `$gt` | Greater than | `{ score: { $gt: 50 } }` |
|
||||
| `$gte` | Greater than or equal | `{ level: { $gte: 10 } }` |
|
||||
| `$lt` | Less than | `{ age: { $lt: 18 } }` |
|
||||
| `$lte` | Less than or equal | `{ price: { $lte: 100 } }` |
|
||||
| `$in` | In array | `{ rank: { $in: ['gold', 'platinum'] } }` |
|
||||
| `$nin` | Not in array | `{ status: { $nin: ['banned', 'suspended'] } }` |
|
||||
| `$like` | Pattern match | `{ name: { $like: '%john%' } }` |
|
||||
| `$regex` | Regex match | `{ email: { $regex: '@gmail.com$' } }` |
|
||||
|
||||
## Logical Operators
|
||||
|
||||
### $or
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ score: { $gte: 1000 } },
|
||||
{ rank: 'legendary' }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### $and
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
$and: [
|
||||
{ score: { $gte: 100 } },
|
||||
{ score: { $lte: 500 } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Combined Usage
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
$or: [
|
||||
{ rank: 'gold' },
|
||||
{ score: { $gte: 1000 } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
### $like Syntax
|
||||
|
||||
- `%` - Matches any sequence of characters
|
||||
- `_` - Matches single character
|
||||
|
||||
```typescript
|
||||
// Starts with 'John'
|
||||
{ name: { $like: 'John%' } }
|
||||
|
||||
// Ends with 'son'
|
||||
{ name: { $like: '%son' } }
|
||||
|
||||
// Contains 'oh'
|
||||
{ name: { $like: '%oh%' } }
|
||||
|
||||
// Second character is 'o'
|
||||
{ name: { $like: '_o%' } }
|
||||
```
|
||||
|
||||
### $regex Syntax
|
||||
|
||||
Uses standard regular expressions:
|
||||
|
||||
```typescript
|
||||
// Starts with 'John' (case insensitive)
|
||||
{ name: { $regex: '^john' } }
|
||||
|
||||
// Gmail email
|
||||
{ email: { $regex: '@gmail\\.com$' } }
|
||||
|
||||
// Contains numbers
|
||||
{ username: { $regex: '\\d+' } }
|
||||
```
|
||||
|
||||
## Sorting
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
sort: {
|
||||
score: 'desc', // Descending
|
||||
name: 'asc' // Ascending
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
### Using limit/offset
|
||||
|
||||
```typescript
|
||||
// First page
|
||||
await repo.findMany({
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
|
||||
// Second page
|
||||
await repo.findMany({
|
||||
limit: 20,
|
||||
offset: 20
|
||||
})
|
||||
```
|
||||
|
||||
### Using findPaginated
|
||||
|
||||
```typescript
|
||||
const result = await repo.findPaginated(
|
||||
{ page: 2, pageSize: 20 },
|
||||
{ sort: { createdAt: 'desc' } }
|
||||
)
|
||||
```
|
||||
|
||||
## Complete Examples
|
||||
|
||||
```typescript
|
||||
// Find active gold players with scores between 100-1000
|
||||
// Sort by score descending, get top 10
|
||||
const players = await repo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
rank: 'gold',
|
||||
score: { $gte: 100, $lte: 1000 }
|
||||
},
|
||||
sort: { score: 'desc' },
|
||||
limit: 10
|
||||
})
|
||||
|
||||
// Search for users with 'john' in username or gmail email
|
||||
const users = await repo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ username: { $like: '%john%' } },
|
||||
{ email: { $regex: '@gmail\\.com$' } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
244
docs/src/content/docs/en/modules/database/repository.md
Normal file
244
docs/src/content/docs/en/modules/database/repository.md
Normal file
@@ -0,0 +1,244 @@
|
||||
---
|
||||
title: "Repository API"
|
||||
description: "Generic repository interface, CRUD operations, pagination, soft delete"
|
||||
---
|
||||
|
||||
## Creating a Repository
|
||||
|
||||
### Using Factory Function
|
||||
|
||||
```typescript
|
||||
import { createRepository } from '@esengine/database'
|
||||
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
|
||||
// Enable soft delete
|
||||
const playerRepo = createRepository<Player>(mongo, 'players', true)
|
||||
```
|
||||
|
||||
### Extending Repository
|
||||
|
||||
```typescript
|
||||
import { Repository, BaseEntity } from '@esengine/database'
|
||||
|
||||
interface Player extends BaseEntity {
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
class PlayerRepository extends Repository<Player> {
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'players', false) // Third param: enable soft delete
|
||||
}
|
||||
|
||||
// Add custom methods
|
||||
async findTopPlayers(limit: number): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## BaseEntity Interface
|
||||
|
||||
All entities must extend `BaseEntity`:
|
||||
|
||||
```typescript
|
||||
interface BaseEntity {
|
||||
id: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
deletedAt?: Date // Used for soft delete
|
||||
}
|
||||
```
|
||||
|
||||
## Query Methods
|
||||
|
||||
### findById
|
||||
|
||||
```typescript
|
||||
const player = await repo.findById('player-123')
|
||||
```
|
||||
|
||||
### findOne
|
||||
|
||||
```typescript
|
||||
const player = await repo.findOne({
|
||||
where: { name: 'John' }
|
||||
})
|
||||
|
||||
const topPlayer = await repo.findOne({
|
||||
sort: { score: 'desc' }
|
||||
})
|
||||
```
|
||||
|
||||
### findMany
|
||||
|
||||
```typescript
|
||||
// Simple query
|
||||
const players = await repo.findMany({
|
||||
where: { rank: 'gold' }
|
||||
})
|
||||
|
||||
// Complex query
|
||||
const players = await repo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] }
|
||||
},
|
||||
sort: { score: 'desc', name: 'asc' },
|
||||
limit: 10,
|
||||
offset: 0
|
||||
})
|
||||
```
|
||||
|
||||
### findPaginated
|
||||
|
||||
```typescript
|
||||
const result = await repo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { rank: 'gold' },
|
||||
sort: { score: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
console.log(result.data) // Player[]
|
||||
console.log(result.total) // Total count
|
||||
console.log(result.totalPages) // Total pages
|
||||
console.log(result.hasNext) // Has next page
|
||||
console.log(result.hasPrev) // Has previous page
|
||||
```
|
||||
|
||||
### count
|
||||
|
||||
```typescript
|
||||
const count = await repo.count({
|
||||
where: { rank: 'gold' }
|
||||
})
|
||||
```
|
||||
|
||||
### exists
|
||||
|
||||
```typescript
|
||||
const exists = await repo.exists({
|
||||
where: { email: 'john@example.com' }
|
||||
})
|
||||
```
|
||||
|
||||
## Create Methods
|
||||
|
||||
### create
|
||||
|
||||
```typescript
|
||||
const player = await repo.create({
|
||||
name: 'John',
|
||||
score: 0
|
||||
})
|
||||
// Automatically generates id, createdAt, updatedAt
|
||||
```
|
||||
|
||||
### createMany
|
||||
|
||||
```typescript
|
||||
const players = await repo.createMany([
|
||||
{ name: 'Alice', score: 100 },
|
||||
{ name: 'Bob', score: 200 },
|
||||
{ name: 'Carol', score: 150 }
|
||||
])
|
||||
```
|
||||
|
||||
## Update Methods
|
||||
|
||||
### update
|
||||
|
||||
```typescript
|
||||
const updated = await repo.update('player-123', {
|
||||
score: 200,
|
||||
rank: 'gold'
|
||||
})
|
||||
// Automatically updates updatedAt
|
||||
```
|
||||
|
||||
## Delete Methods
|
||||
|
||||
### delete
|
||||
|
||||
```typescript
|
||||
// Hard delete
|
||||
await repo.delete('player-123')
|
||||
|
||||
// Soft delete (if enabled)
|
||||
// Actually sets the deletedAt field
|
||||
```
|
||||
|
||||
### deleteMany
|
||||
|
||||
```typescript
|
||||
const count = await repo.deleteMany({
|
||||
where: { score: { $lt: 10 } }
|
||||
})
|
||||
```
|
||||
|
||||
## Soft Delete
|
||||
|
||||
### Enabling Soft Delete
|
||||
|
||||
```typescript
|
||||
const repo = createRepository<Player>(mongo, 'players', true)
|
||||
```
|
||||
|
||||
### Query Behavior
|
||||
|
||||
```typescript
|
||||
// Excludes soft-deleted records by default
|
||||
const players = await repo.findMany()
|
||||
|
||||
// Include soft-deleted records
|
||||
const allPlayers = await repo.findMany({
|
||||
includeSoftDeleted: true
|
||||
})
|
||||
```
|
||||
|
||||
### Restore Records
|
||||
|
||||
```typescript
|
||||
await repo.restore('player-123')
|
||||
```
|
||||
|
||||
## QueryOptions
|
||||
|
||||
```typescript
|
||||
interface QueryOptions<T> {
|
||||
/** Query conditions */
|
||||
where?: WhereCondition<T>
|
||||
|
||||
/** Sorting */
|
||||
sort?: Partial<Record<keyof T, 'asc' | 'desc'>>
|
||||
|
||||
/** Limit count */
|
||||
limit?: number
|
||||
|
||||
/** Offset */
|
||||
offset?: number
|
||||
|
||||
/** Include soft-deleted records (only when soft delete is enabled) */
|
||||
includeSoftDeleted?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## PaginatedResult
|
||||
|
||||
```typescript
|
||||
interface PaginatedResult<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
```
|
||||
277
docs/src/content/docs/en/modules/database/user.md
Normal file
277
docs/src/content/docs/en/modules/database/user.md
Normal file
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: "User Management"
|
||||
description: "UserRepository for user registration, authentication, and role management"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`UserRepository` provides out-of-the-box user management features:
|
||||
|
||||
- User registration and authentication
|
||||
- Password hashing (using scrypt)
|
||||
- Role management
|
||||
- Account status management
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { UserRepository } from '@esengine/database'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
const userRepo = new UserRepository(mongo)
|
||||
```
|
||||
|
||||
## User Registration
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.register({
|
||||
username: 'john',
|
||||
password: 'securePassword123',
|
||||
email: 'john@example.com', // Optional
|
||||
displayName: 'John Doe', // Optional
|
||||
roles: ['player'] // Optional, defaults to []
|
||||
})
|
||||
|
||||
console.log(user)
|
||||
// {
|
||||
// id: 'uuid-...',
|
||||
// username: 'john',
|
||||
// email: 'john@example.com',
|
||||
// displayName: 'John Doe',
|
||||
// roles: ['player'],
|
||||
// status: 'active',
|
||||
// createdAt: Date,
|
||||
// updatedAt: Date
|
||||
// }
|
||||
```
|
||||
|
||||
**Note**: `register` returns a `SafeUser` which excludes the password hash.
|
||||
|
||||
## User Authentication
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.authenticate('john', 'securePassword123')
|
||||
|
||||
if (user) {
|
||||
console.log('Login successful:', user.username)
|
||||
} else {
|
||||
console.log('Invalid username or password')
|
||||
}
|
||||
```
|
||||
|
||||
## Password Management
|
||||
|
||||
### Change Password
|
||||
|
||||
```typescript
|
||||
const success = await userRepo.changePassword(
|
||||
userId,
|
||||
'oldPassword123',
|
||||
'newPassword456'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
console.log('Password changed successfully')
|
||||
} else {
|
||||
console.log('Invalid current password')
|
||||
}
|
||||
```
|
||||
|
||||
### Reset Password
|
||||
|
||||
```typescript
|
||||
// Admin directly resets password
|
||||
const success = await userRepo.resetPassword(userId, 'newPassword123')
|
||||
```
|
||||
|
||||
## Role Management
|
||||
|
||||
### Add Role
|
||||
|
||||
```typescript
|
||||
await userRepo.addRole(userId, 'admin')
|
||||
await userRepo.addRole(userId, 'moderator')
|
||||
```
|
||||
|
||||
### Remove Role
|
||||
|
||||
```typescript
|
||||
await userRepo.removeRole(userId, 'moderator')
|
||||
```
|
||||
|
||||
### Query Roles
|
||||
|
||||
```typescript
|
||||
// Find all admins
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
|
||||
// Check if user has a role
|
||||
const user = await userRepo.findById(userId)
|
||||
const isAdmin = user?.roles.includes('admin')
|
||||
```
|
||||
|
||||
## Querying Users
|
||||
|
||||
### Find by Username
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.findByUsername('john')
|
||||
```
|
||||
|
||||
### Find by Email
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.findByEmail('john@example.com')
|
||||
```
|
||||
|
||||
### Find by Role
|
||||
|
||||
```typescript
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
```
|
||||
|
||||
### Using Inherited Methods
|
||||
|
||||
```typescript
|
||||
// Paginated query
|
||||
const result = await userRepo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { status: 'active' },
|
||||
sort: { createdAt: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
// Complex query
|
||||
const users = await userRepo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
roles: { $in: ['admin', 'moderator'] }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Account Status
|
||||
|
||||
```typescript
|
||||
type UserStatus = 'active' | 'inactive' | 'banned' | 'suspended'
|
||||
```
|
||||
|
||||
### Update Status
|
||||
|
||||
```typescript
|
||||
await userRepo.update(userId, { status: 'banned' })
|
||||
```
|
||||
|
||||
### Query by Status
|
||||
|
||||
```typescript
|
||||
const activeUsers = await userRepo.findMany({
|
||||
where: { status: 'active' }
|
||||
})
|
||||
|
||||
const bannedUsers = await userRepo.findMany({
|
||||
where: { status: 'banned' }
|
||||
})
|
||||
```
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### UserEntity
|
||||
|
||||
```typescript
|
||||
interface UserEntity extends BaseEntity {
|
||||
username: string
|
||||
passwordHash: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
roles: string[]
|
||||
status: UserStatus
|
||||
lastLoginAt?: Date
|
||||
}
|
||||
```
|
||||
|
||||
### SafeUser
|
||||
|
||||
```typescript
|
||||
type SafeUser = Omit<UserEntity, 'passwordHash'>
|
||||
```
|
||||
|
||||
### CreateUserParams
|
||||
|
||||
```typescript
|
||||
interface CreateUserParams {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
roles?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
## Password Utilities
|
||||
|
||||
Standalone password utility functions:
|
||||
|
||||
```typescript
|
||||
import { hashPassword, verifyPassword } from '@esengine/database'
|
||||
|
||||
// Hash password
|
||||
const hash = await hashPassword('myPassword123')
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword('myPassword123', hash)
|
||||
```
|
||||
|
||||
### Security Notes
|
||||
|
||||
- Uses Node.js built-in `scrypt` algorithm
|
||||
- Automatically generates random salt
|
||||
- Uses secure iteration parameters by default
|
||||
- Hash format: `salt:hash` (both hex encoded)
|
||||
|
||||
## Extending UserRepository
|
||||
|
||||
```typescript
|
||||
import { UserRepository, UserEntity } from '@esengine/database'
|
||||
|
||||
interface GameUser extends UserEntity {
|
||||
level: number
|
||||
experience: number
|
||||
coins: number
|
||||
}
|
||||
|
||||
class GameUserRepository extends UserRepository {
|
||||
// Override collection name
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'game_users')
|
||||
}
|
||||
|
||||
// Add game-related methods
|
||||
async addExperience(userId: string, amount: number): Promise<GameUser | null> {
|
||||
const user = await this.findById(userId) as GameUser | null
|
||||
if (!user) return null
|
||||
|
||||
const newExp = user.experience + amount
|
||||
const newLevel = Math.floor(newExp / 1000) + 1
|
||||
|
||||
return this.update(userId, {
|
||||
experience: newExp,
|
||||
level: newLevel
|
||||
}) as Promise<GameUser | null>
|
||||
}
|
||||
|
||||
async findTopPlayers(limit: number = 10): Promise<GameUser[]> {
|
||||
return this.findMany({
|
||||
sort: { level: 'desc', experience: 'desc' },
|
||||
limit
|
||||
}) as Promise<GameUser[]>
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -20,6 +20,7 @@ ESEngine provides a rich set of modules that can be imported as needed.
|
||||
| [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 |
|
||||
| [World Streaming](/en/modules/world-streaming/) | `@esengine/world-streaming` | Chunk-based world streaming for open worlds |
|
||||
|
||||
### Tools
|
||||
|
||||
@@ -33,6 +34,14 @@ ESEngine provides a rich set of modules that can be imported as needed.
|
||||
| Module | Package | Description |
|
||||
|--------|---------|-------------|
|
||||
| [Network](/en/modules/network/) | `@esengine/network` | Multiplayer game networking |
|
||||
| [Transaction](/en/modules/transaction/) | `@esengine/transaction` | Game transactions with distributed support |
|
||||
|
||||
### Database
|
||||
|
||||
| Module | Package | Description |
|
||||
|--------|---------|-------------|
|
||||
| [Database Drivers](/en/modules/database-drivers/) | `@esengine/database-drivers` | MongoDB, Redis connection management |
|
||||
| [Database Repository](/en/modules/database/) | `@esengine/database` | Repository pattern data operations |
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
489
docs/src/content/docs/en/modules/math/blueprint-nodes.md
Normal file
489
docs/src/content/docs/en/modules/math/blueprint-nodes.md
Normal file
@@ -0,0 +1,489 @@
|
||||
---
|
||||
title: "Math Blueprint Nodes"
|
||||
description: "Blueprint nodes provided by the Math module - Vector2, Fixed32, FixedVector2, Color"
|
||||
---
|
||||
|
||||
This document describes the blueprint nodes provided by the `@esengine/ecs-framework-math` module.
|
||||
|
||||
> **Note**: These nodes require the math module to be installed.
|
||||
|
||||
<script src="/js/blueprint-graph.js"></script>
|
||||
|
||||
## Pin Type Legend
|
||||
|
||||
<div class="bp-legend">
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7ecd32" stroke-width="2"/></svg> Float</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#2196F3" stroke-width="2"/></svg> Vector2</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#9C27B0" stroke-width="2"/></svg> Fixed32</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#673AB7" stroke-width="2"/></svg> FixedVector2</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#FF9800" stroke-width="2"/></svg> Color</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Vector2 Nodes
|
||||
|
||||
2D vector operations for position, velocity, and direction calculations.
|
||||
|
||||
### Node List
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Make Vector2` | Create Vector2 from X, Y | X, Y | Vector2 |
|
||||
| `Break Vector2` | Decompose Vector2 to X, Y | Vector | X, Y |
|
||||
| `Vector2 +` | Vector addition | A, B | Vector2 |
|
||||
| `Vector2 -` | Vector subtraction | A, B | Vector2 |
|
||||
| `Vector2 *` | Vector scaling | Vector, Scalar | Vector2 |
|
||||
| `Vector2 Length` | Get vector length | Vector | Float |
|
||||
| `Vector2 Normalize` | Normalize to unit vector | Vector | Vector2 |
|
||||
| `Vector2 Dot` | Dot product | A, B | Float |
|
||||
| `Vector2 Cross` | 2D cross product | A, B | Float |
|
||||
| `Vector2 Distance` | Distance between two points | A, B | Float |
|
||||
| `Vector2 Lerp` | Linear interpolation | A, B, T | Vector2 |
|
||||
| `Vector2 Rotate` | Rotate by angle (radians) | Vector, Angle | Vector2 |
|
||||
| `Vector2 From Angle` | Create unit vector from angle | Angle | Vector2 |
|
||||
|
||||
### Example: Calculate Movement Direction
|
||||
|
||||
Direction vector from start to end point:
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"v2-start","to":"v2-sub-a","type":"vector2"},{"from":"v2-end","to":"v2-sub-b","type":"vector2"},{"from":"v2-sub-result","to":"v2-norm-in","type":"vector2"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 130px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Make Vector2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">X</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Y</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-start"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 180px; width: 130px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Make Vector2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">X</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Y</span>
|
||||
<span class="bp-pin-value">50</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-end"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 220px; top: 90px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 -</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-sub-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-sub-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-sub-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 400px; top: 55px; width: 140px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 Normalize</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-norm-in"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Example: Circular Motion
|
||||
|
||||
Calculate circular position using angle and radius:
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"v2-angle-out","to":"v2-scale-vec","type":"vector2"},{"from":"v2-scale-result","to":"v2-add-b","type":"vector2"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 40px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 From Angle</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Angle</span>
|
||||
<span class="bp-pin-value">1.57</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-angle-out"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 230px; top: 40px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 *</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-scale-vec"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Scalar</span>
|
||||
<span class="bp-pin-value">50</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-scale-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 420px; top: 40px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 +</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#2196F3" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">A (Center)</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-add-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Fixed32 Nodes (Fixed-Point Numbers)
|
||||
|
||||
Q16.16 fixed-point number operations for lockstep networking games, ensuring cross-platform calculation consistency.
|
||||
|
||||
### Node List
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Fixed32 From Float` | Create from float | Float | Fixed32 |
|
||||
| `Fixed32 From Int` | Create from integer | Int | Fixed32 |
|
||||
| `Fixed32 To Float` | Convert to float | Fixed32 | Float |
|
||||
| `Fixed32 To Int` | Convert to integer | Fixed32 | Int |
|
||||
| `Fixed32 +` | Addition | A, B | Fixed32 |
|
||||
| `Fixed32 -` | Subtraction | A, B | Fixed32 |
|
||||
| `Fixed32 *` | Multiplication | A, B | Fixed32 |
|
||||
| `Fixed32 /` | Division | A, B | Fixed32 |
|
||||
| `Fixed32 Abs` | Absolute value | Value | Fixed32 |
|
||||
| `Fixed32 Sqrt` | Square root | Value | Fixed32 |
|
||||
| `Fixed32 Floor` | Floor | Value | Fixed32 |
|
||||
| `Fixed32 Ceil` | Ceiling | Value | Fixed32 |
|
||||
| `Fixed32 Round` | Round | Value | Fixed32 |
|
||||
| `Fixed32 Sign` | Sign (-1, 0, 1) | Value | Fixed32 |
|
||||
| `Fixed32 Min` | Minimum | A, B | Fixed32 |
|
||||
| `Fixed32 Max` | Maximum | A, B | Fixed32 |
|
||||
| `Fixed32 Clamp` | Clamp to range | Value, Min, Max | Fixed32 |
|
||||
| `Fixed32 Lerp` | Linear interpolation | A, B, T | Fixed32 |
|
||||
|
||||
### Example: Lockstep Movement Speed Calculation
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"f32-speed","to":"f32-mul-a","type":"fixed32"},{"from":"f32-dt","to":"f32-mul-b","type":"fixed32"},{"from":"f32-mul-result","to":"f32-tofloat","type":"fixed32"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 From Float</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
<span class="bp-pin-value">5.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="f32-speed"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">Speed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 160px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 From Float</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
<span class="bp-pin-value">0.016</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="f32-dt"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">DeltaTime</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 240px; top: 75px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 *</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="f32-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="f32-mul-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="f32-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 430px; top: 75px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 To Float</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="f32-tofloat"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">Fixed</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Float</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## FixedVector2 Nodes (Fixed-Point Vectors)
|
||||
|
||||
Fixed-point vector operations for deterministic physics calculations, suitable for lockstep networking.
|
||||
|
||||
### Node List
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Make FixedVector2` | Create from X, Y floats | X, Y | FixedVector2 |
|
||||
| `Break FixedVector2` | Decompose to X, Y floats | Vector | X, Y |
|
||||
| `FixedVector2 +` | Vector addition | A, B | FixedVector2 |
|
||||
| `FixedVector2 -` | Vector subtraction | A, B | FixedVector2 |
|
||||
| `FixedVector2 *` | Scale by Fixed32 | Vector, Scalar | FixedVector2 |
|
||||
| `FixedVector2 Negate` | Negate vector | Vector | FixedVector2 |
|
||||
| `FixedVector2 Length` | Get length | Vector | Fixed32 |
|
||||
| `FixedVector2 Normalize` | Normalize | Vector | FixedVector2 |
|
||||
| `FixedVector2 Dot` | Dot product | A, B | Fixed32 |
|
||||
| `FixedVector2 Cross` | 2D cross product | A, B | Fixed32 |
|
||||
| `FixedVector2 Distance` | Distance between points | A, B | Fixed32 |
|
||||
| `FixedVector2 Lerp` | Linear interpolation | A, B, T | FixedVector2 |
|
||||
|
||||
### Example: Deterministic Position Update
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"fv2-pos","to":"fv2-add-a","type":"fixedvector2"},{"from":"fv2-vel","to":"fv2-add-b","type":"fixedvector2"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #673AB7;">Make FixedVector2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">X</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Y</span>
|
||||
<span class="bp-pin-value">20</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="fv2-pos"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">Position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 180px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #673AB7;">Make FixedVector2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">X</span>
|
||||
<span class="bp-pin-value">1</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Y</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="fv2-vel"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">Velocity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 250px; top: 90px; width: 140px;">
|
||||
<div class="bp-node-header math" style="background: #673AB7;">FixedVector2 +</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="fv2-add-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="fv2-add-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">New Position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Color Nodes
|
||||
|
||||
Color creation and manipulation nodes.
|
||||
|
||||
### Node List
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Make Color` | Create from RGBA | R, G, B, A | Color |
|
||||
| `Break Color` | Decompose to RGBA | Color | R, G, B, A |
|
||||
| `Color From Hex` | Create from hex string | Hex | Color |
|
||||
| `Color To Hex` | Convert to hex string | Color | String |
|
||||
| `Color From HSL` | Create from HSL | H, S, L | Color |
|
||||
| `Color To HSL` | Convert to HSL | Color | H, S, L |
|
||||
| `Color Lerp` | Color interpolation | A, B, T | Color |
|
||||
| `Color Lighten` | Lighten | Color, Amount | Color |
|
||||
| `Color Darken` | Darken | Color, Amount | Color |
|
||||
| `Color Saturate` | Increase saturation | Color, Amount | Color |
|
||||
| `Color Desaturate` | Decrease saturation | Color, Amount | Color |
|
||||
| `Color Invert` | Invert | Color | Color |
|
||||
| `Color Grayscale` | Convert to grayscale | Color | Color |
|
||||
| `Color Luminance` | Get luminance | Color | Float |
|
||||
|
||||
### Color Constants
|
||||
|
||||
| Node | Value |
|
||||
|------|-------|
|
||||
| `Color White` | (1, 1, 1, 1) |
|
||||
| `Color Black` | (0, 0, 0, 1) |
|
||||
| `Color Red` | (1, 0, 0, 1) |
|
||||
| `Color Green` | (0, 1, 0, 1) |
|
||||
| `Color Blue` | (0, 0, 1, 1) |
|
||||
| `Color Transparent` | (0, 0, 0, 0) |
|
||||
|
||||
### Example: Color Transition Animation
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"color-a","to":"color-lerp-a","type":"color"},{"from":"color-b","to":"color-lerp-b","type":"color"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Color Red</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="color-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Color</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 130px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Color Blue</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="color-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Color</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 220px; top: 50px; width: 130px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Color Lerp</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="color-lerp-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="color-lerp-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">T</span>
|
||||
<span class="bp-pin-value">0.5</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Example: Create Color from Hex
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"hex-color","to":"break-color","type":"color"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 30px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Color From Hex</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Hex</span>
|
||||
<span class="bp-pin-value">"#FF5722"</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="hex-color"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Color</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 250px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Break Color</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="break-color"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Color</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">R</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">G</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Blueprint Node Reference](/en/modules/blueprint/nodes) - Core blueprint nodes
|
||||
- [Blueprint Editor Guide](/en/modules/blueprint/editor-guide) - Editor usage
|
||||
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Create custom nodes
|
||||
79
docs/src/content/docs/en/modules/math/index.md
Normal file
79
docs/src/content/docs/en/modules/math/index.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "Math Library"
|
||||
description: "ESEngine Math Library - Vector2, Fixed32, FixedVector2, Color and other math types"
|
||||
---
|
||||
|
||||
The `@esengine/ecs-framework-math` module provides common math types and operations for game development.
|
||||
|
||||
## Core Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `Vector2` | 2D floating-point vector for position, velocity, direction |
|
||||
| `Fixed32` | Q16.16 fixed-point number for deterministic lockstep calculations |
|
||||
| `FixedVector2` | 2D fixed-point vector for deterministic physics |
|
||||
| `Color` | RGBA color |
|
||||
|
||||
## Features
|
||||
|
||||
### Vector2
|
||||
|
||||
- Addition, subtraction, scaling
|
||||
- Dot product, cross product
|
||||
- Length, normalization
|
||||
- Distance, interpolation
|
||||
- Rotation, angle conversion
|
||||
|
||||
### Fixed32 Fixed-Point Numbers
|
||||
|
||||
Designed for lockstep networking games, ensuring cross-platform calculation consistency:
|
||||
|
||||
- Basic operations: add, subtract, multiply, divide
|
||||
- Math functions: absolute value, square root, rounding
|
||||
- Comparison, clamping, interpolation
|
||||
- Constants: 0, 1, 0.5, PI, 2*PI
|
||||
|
||||
### Color
|
||||
|
||||
- RGB/RGBA creation and decomposition
|
||||
- Hex string conversion
|
||||
- HSL color space conversion
|
||||
- Color operations: lighten, darken, saturation adjustment
|
||||
- Color blending and interpolation
|
||||
|
||||
## Blueprint Support
|
||||
|
||||
The math library provides rich blueprint nodes, see:
|
||||
|
||||
- [Math Blueprint Nodes](/en/modules/math/blueprint-nodes)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @esengine/ecs-framework-math
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { Vector2, Fixed32, FixedVector2, Color } from '@esengine/ecs-framework-math';
|
||||
|
||||
// Vector2
|
||||
const pos = new Vector2(10, 20);
|
||||
const dir = pos.normalized();
|
||||
|
||||
// Fixed32 (lockstep)
|
||||
const speed = Fixed32.from(5.0);
|
||||
const dt = Fixed32.from(0.016);
|
||||
const distance = speed.mul(dt);
|
||||
|
||||
// FixedVector2
|
||||
const fixedPos = FixedVector2.from(10, 20);
|
||||
const fixedVel = FixedVector2.from(1, 0);
|
||||
const newPos = fixedPos.add(fixedVel);
|
||||
|
||||
// Color
|
||||
const red = Color.RED;
|
||||
const blue = Color.BLUE;
|
||||
const purple = Color.lerp(red, blue, 0.5);
|
||||
```
|
||||
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: "Area of Interest (AOI)"
|
||||
description: "View range based network entity filtering"
|
||||
---
|
||||
|
||||
AOI (Area of Interest) is a key technique in large-scale multiplayer games for optimizing network bandwidth. By only synchronizing entities within a player's view range, network traffic can be significantly reduced.
|
||||
|
||||
## NetworkAOISystem
|
||||
|
||||
`NetworkAOISystem` provides grid-based area of interest management.
|
||||
|
||||
### Enable AOI
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enableAOI: true,
|
||||
aoiConfig: {
|
||||
cellSize: 100, // Grid cell size
|
||||
defaultViewRange: 500, // Default view range
|
||||
enabled: true,
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### Adding Observers
|
||||
|
||||
Each player that needs to receive sync data must be added as an observer:
|
||||
|
||||
```typescript
|
||||
// Add observer when player joins
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
// ... setup components
|
||||
|
||||
// Add player as AOI observer
|
||||
networkPlugin.addAOIObserver(
|
||||
spawn.netId, // Network ID
|
||||
spawn.pos.x, // Initial X position
|
||||
spawn.pos.y, // Initial Y position
|
||||
600 // View range (optional)
|
||||
);
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// Remove observer when player leaves
|
||||
networkPlugin.removeAOIObserver(playerNetId);
|
||||
```
|
||||
|
||||
### Updating Observer Position
|
||||
|
||||
When a player moves, update their AOI position:
|
||||
|
||||
```typescript
|
||||
// Update in game loop or sync callback
|
||||
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||
```
|
||||
|
||||
## AOI Configuration
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `cellSize` | `number` | 100 | Grid cell size |
|
||||
| `defaultViewRange` | `number` | 500 | Default view range |
|
||||
| `enabled` | `boolean` | true | Whether AOI is enabled |
|
||||
|
||||
### Grid Size Recommendations
|
||||
|
||||
Grid size should be set based on game view range:
|
||||
|
||||
```typescript
|
||||
// Recommendation: cellSize = defaultViewRange / 3 to / 5
|
||||
aoiConfig: {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500, // Grid is about 1/5 of view range
|
||||
}
|
||||
```
|
||||
|
||||
## Query Interface
|
||||
|
||||
### Get Visible Entities
|
||||
|
||||
```typescript
|
||||
// Get all entities visible to player
|
||||
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||
console.log('Visible entities:', visibleEntities);
|
||||
```
|
||||
|
||||
### Check Visibility
|
||||
|
||||
```typescript
|
||||
// Check if player can see an entity
|
||||
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||
// Target is in view
|
||||
}
|
||||
```
|
||||
|
||||
## Event Listening
|
||||
|
||||
The AOI system triggers events when entities enter/exit view:
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||
// Can send entity's initial state here
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||
// Can cleanup resources here
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Filtering
|
||||
|
||||
AOI is most commonly used server-side to filter sync data for each client:
|
||||
|
||||
```typescript
|
||||
// Server-side example
|
||||
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private aoiSystem = createNetworkAOISystem({
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
});
|
||||
|
||||
// Player joins
|
||||
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.addObserver(playerId, x, y);
|
||||
}
|
||||
|
||||
// Player moves
|
||||
onPlayerMove(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||
}
|
||||
|
||||
// Send sync data
|
||||
broadcastSync(allEntities: EntitySyncState[]) {
|
||||
for (const playerId of this.players) {
|
||||
// Filter using AOI
|
||||
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||
playerId,
|
||||
allEntities
|
||||
);
|
||||
|
||||
// Send only visible entities
|
||||
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Game World │
|
||||
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||
│ │ │ │ E │ │ │ │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ E = Enemy entity │
|
||||
│ │ │ P │ ● │ │ │ P = Player │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ ● = Player view center │
|
||||
│ │ │ │ E │ E │ │ ○ = View range │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||
│ │ │ │ │ │ E │ Player only sees E in view│
|
||||
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||
│ │
|
||||
│ View range (circle): Contains 3 enemies │
|
||||
│ Grid optimization: Only check cells covered by view │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Grid Optimization
|
||||
|
||||
AOI uses spatial grid to accelerate queries:
|
||||
|
||||
1. **Add Entity**: Calculate grid cell based on position
|
||||
2. **View Detection**: Only check cells covered by view range
|
||||
3. **Move Update**: Update cell assignment when crossing cells
|
||||
4. **Event Trigger**: Detect enter/exit view
|
||||
|
||||
## Dynamic View Range
|
||||
|
||||
Different player types can have different view ranges:
|
||||
|
||||
```typescript
|
||||
// Regular player
|
||||
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||
|
||||
// VIP player (larger view)
|
||||
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||
|
||||
// Adjust view range at runtime
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
if (aoiSystem) {
|
||||
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Server-Side Usage
|
||||
|
||||
AOI filtering should be done server-side; clients should not trust their own AOI judgment:
|
||||
|
||||
```typescript
|
||||
// Filter on server before sending
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
sendToClient(playerId, filtered);
|
||||
```
|
||||
|
||||
### 2. Edge Handling
|
||||
|
||||
Add buffer zone at view edge to prevent flickering:
|
||||
|
||||
```typescript
|
||||
// Add immediately when entering view
|
||||
// Remove with delay when exiting (keep for 1-2 extra seconds)
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'exit') {
|
||||
setTimeout(() => {
|
||||
// Re-check if really exited
|
||||
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||
removeFromClient(event.observerNetId, event.targetNetId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Large Entities
|
||||
|
||||
Large entities (like bosses) may need special handling:
|
||||
|
||||
```typescript
|
||||
// Boss is always visible to everyone
|
||||
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
|
||||
// Add boss entity
|
||||
const bossState = entities.find(e => e.netId === bossNetId);
|
||||
if (bossState && !filtered.includes(bossState)) {
|
||||
filtered.push(bossState);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Performance Considerations
|
||||
|
||||
```typescript
|
||||
// Large-scale game recommended config
|
||||
aoiConfig: {
|
||||
cellSize: 200, // Larger grid reduces cell count
|
||||
defaultViewRange: 800, // Set based on actual view
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
console.log('AOI enabled:', aoiSystem.enabled);
|
||||
console.log('Observer count:', aoiSystem.observerCount);
|
||||
|
||||
// Get visible entities for specific player
|
||||
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||
console.log('Visible entities:', visible.length);
|
||||
}
|
||||
```
|
||||
855
docs/src/content/docs/en/modules/network/auth.md
Normal file
855
docs/src/content/docs/en/modules/network/auth.md
Normal file
@@ -0,0 +1,855 @@
|
||||
---
|
||||
title: "Authentication"
|
||||
description: "Add authentication to your game server with JWT and Session providers"
|
||||
---
|
||||
|
||||
The `@esengine/server` package includes a pluggable authentication system that supports JWT, session-based auth, and custom providers.
|
||||
|
||||
## Installation
|
||||
|
||||
Authentication is included in the server package:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server jsonwebtoken
|
||||
```
|
||||
|
||||
> Note: `jsonwebtoken` is an optional peer dependency, required only for JWT authentication.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### JWT Authentication
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||
|
||||
// Create JWT provider
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600, // 1 hour
|
||||
})
|
||||
|
||||
// Wrap server with authentication
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url ?? '', 'http://localhost')
|
||||
return url.searchParams.get('token')
|
||||
},
|
||||
})
|
||||
|
||||
// Define authenticated room
|
||||
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||
onJoin(player) {
|
||||
console.log(`${player.user?.name} joined!`)
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## Auth Providers
|
||||
|
||||
### JWT Provider
|
||||
|
||||
Use JSON Web Tokens for stateless authentication:
|
||||
|
||||
```typescript
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
// Required: secret key
|
||||
secret: 'your-secret-key',
|
||||
|
||||
// Optional: algorithm (default: HS256)
|
||||
algorithm: 'HS256',
|
||||
|
||||
// Optional: expiration in seconds (default: 3600)
|
||||
expiresIn: 3600,
|
||||
|
||||
// Optional: issuer for validation
|
||||
issuer: 'my-game-server',
|
||||
|
||||
// Optional: audience for validation
|
||||
audience: 'my-game-client',
|
||||
|
||||
// Optional: custom user extraction
|
||||
getUser: async (payload) => {
|
||||
// Fetch user from database
|
||||
return await db.users.findById(payload.sub)
|
||||
},
|
||||
})
|
||||
|
||||
// Sign a token (for login endpoints)
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.name,
|
||||
roles: ['player'],
|
||||
})
|
||||
|
||||
// Decode without verification (for debugging)
|
||||
const payload = jwtProvider.decode(token)
|
||||
```
|
||||
|
||||
### Custom Provider
|
||||
|
||||
You can create custom authentication providers by implementing the `IAuthProvider` interface to integrate with any authentication system (OAuth, LDAP, custom database auth, etc.).
|
||||
|
||||
#### IAuthProvider Interface
|
||||
|
||||
```typescript
|
||||
interface IAuthProvider<TUser = unknown, TCredentials = unknown> {
|
||||
/** Provider name */
|
||||
readonly name: string;
|
||||
|
||||
/** Verify credentials */
|
||||
verify(credentials: TCredentials): Promise<AuthResult<TUser>>;
|
||||
|
||||
/** Refresh token (optional) */
|
||||
refresh?(token: string): Promise<AuthResult<TUser>>;
|
||||
|
||||
/** Revoke token (optional) */
|
||||
revoke?(token: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
interface AuthResult<TUser> {
|
||||
success: boolean;
|
||||
user?: TUser;
|
||||
error?: string;
|
||||
errorCode?: AuthErrorCode;
|
||||
token?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'EXPIRED_TOKEN'
|
||||
| 'INVALID_TOKEN'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'ACCOUNT_DISABLED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INSUFFICIENT_PERMISSIONS';
|
||||
```
|
||||
|
||||
#### Custom Provider Examples
|
||||
|
||||
**Example 1: Database Password Authentication**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface PasswordCredentials {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
class DatabaseAuthProvider implements IAuthProvider<User, PasswordCredentials> {
|
||||
readonly name = 'database'
|
||||
|
||||
async verify(credentials: PasswordCredentials): Promise<AuthResult<User>> {
|
||||
const { username, password } = credentials
|
||||
|
||||
// Query user from database
|
||||
const user = await db.users.findByUsername(username)
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
errorCode: 'USER_NOT_FOUND'
|
||||
}
|
||||
}
|
||||
|
||||
// Verify password (using bcrypt or similar)
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid password',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
|
||||
// Check account status
|
||||
if (user.disabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Account is disabled',
|
||||
errorCode: 'ACCOUNT_DISABLED'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roles: user.roles
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example 2: OAuth/Third-party Authentication**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface OAuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
provider: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface OAuthCredentials {
|
||||
provider: 'google' | 'github' | 'discord'
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
class OAuthProvider implements IAuthProvider<OAuthUser, OAuthCredentials> {
|
||||
readonly name = 'oauth'
|
||||
|
||||
async verify(credentials: OAuthCredentials): Promise<AuthResult<OAuthUser>> {
|
||||
const { provider, accessToken } = credentials
|
||||
|
||||
try {
|
||||
// Verify token with provider
|
||||
const profile = await this.fetchUserProfile(provider, accessToken)
|
||||
|
||||
// Find or create local user
|
||||
let user = await db.users.findByOAuth(provider, profile.id)
|
||||
if (!user) {
|
||||
user = await db.users.create({
|
||||
oauthProvider: provider,
|
||||
oauthId: profile.id,
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
roles: ['player']
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
provider,
|
||||
roles: user.roles
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'OAuth verification failed',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUserProfile(provider: string, token: string) {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => r.json())
|
||||
case 'github':
|
||||
return fetch('https://api.github.com/user', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => r.json())
|
||||
// Other providers...
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${provider}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example 3: API Key Authentication**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface ApiUser {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
rateLimit: number
|
||||
}
|
||||
|
||||
class ApiKeyAuthProvider implements IAuthProvider<ApiUser, string> {
|
||||
readonly name = 'api-key'
|
||||
|
||||
private revokedKeys = new Set<string>()
|
||||
|
||||
async verify(apiKey: string): Promise<AuthResult<ApiUser>> {
|
||||
if (!apiKey || !apiKey.startsWith('sk_')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid API Key format',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.revokedKeys.has(apiKey)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key has been revoked',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
// Query API Key from database
|
||||
const keyData = await db.apiKeys.findByKey(apiKey)
|
||||
if (!keyData) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key not found',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (keyData.expiresAt && keyData.expiresAt < Date.now()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key has expired',
|
||||
errorCode: 'EXPIRED_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: keyData.userId,
|
||||
name: keyData.name,
|
||||
roles: keyData.roles,
|
||||
rateLimit: keyData.rateLimit
|
||||
},
|
||||
expiresAt: keyData.expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(apiKey: string): Promise<boolean> {
|
||||
this.revokedKeys.add(apiKey)
|
||||
await db.apiKeys.revoke(apiKey)
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Using Custom Providers
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
// Create custom provider
|
||||
const dbAuthProvider = new DatabaseAuthProvider()
|
||||
|
||||
// Or use OAuth provider
|
||||
const oauthProvider = new OAuthProvider()
|
||||
|
||||
// Use custom provider
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: dbAuthProvider, // or oauthProvider
|
||||
|
||||
// Extract credentials from WebSocket connection request
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
|
||||
// For database auth: get from query params
|
||||
const username = url.searchParams.get('username')
|
||||
const password = url.searchParams.get('password')
|
||||
if (username && password) {
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
// For OAuth: get from token param
|
||||
const provider = url.searchParams.get('provider')
|
||||
const accessToken = url.searchParams.get('access_token')
|
||||
if (provider && accessToken) {
|
||||
return { provider, accessToken }
|
||||
}
|
||||
|
||||
// For API Key: get from header
|
||||
const apiKey = req.headers['x-api-key']
|
||||
if (apiKey) {
|
||||
return apiKey as string
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
onAuthFailure: (conn, error) => {
|
||||
console.log(`Auth failed: ${error.errorCode} - ${error.error}`)
|
||||
}
|
||||
})
|
||||
|
||||
await server.start()
|
||||
```
|
||||
|
||||
#### Combining Multiple Providers
|
||||
|
||||
You can create a composite provider to support multiple authentication methods:
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface MultiAuthCredentials {
|
||||
type: 'jwt' | 'oauth' | 'apikey' | 'password'
|
||||
data: unknown
|
||||
}
|
||||
|
||||
class MultiAuthProvider implements IAuthProvider<User, MultiAuthCredentials> {
|
||||
readonly name = 'multi'
|
||||
|
||||
constructor(
|
||||
private jwtProvider: JwtAuthProvider<User>,
|
||||
private oauthProvider: OAuthProvider,
|
||||
private apiKeyProvider: ApiKeyAuthProvider,
|
||||
private dbProvider: DatabaseAuthProvider
|
||||
) {}
|
||||
|
||||
async verify(credentials: MultiAuthCredentials): Promise<AuthResult<User>> {
|
||||
switch (credentials.type) {
|
||||
case 'jwt':
|
||||
return this.jwtProvider.verify(credentials.data as string)
|
||||
case 'oauth':
|
||||
return this.oauthProvider.verify(credentials.data as OAuthCredentials)
|
||||
case 'apikey':
|
||||
return this.apiKeyProvider.verify(credentials.data as string)
|
||||
case 'password':
|
||||
return this.dbProvider.verify(credentials.data as PasswordCredentials)
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unsupported authentication type',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session Provider
|
||||
|
||||
Use server-side sessions for stateful authentication:
|
||||
|
||||
```typescript
|
||||
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||
|
||||
// Custom storage implementation
|
||||
const storage: ISessionStorage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return await redis.get(key)
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await redis.set(key, value)
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return await redis.del(key) > 0
|
||||
},
|
||||
}
|
||||
|
||||
const sessionProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 86400000, // 24 hours in ms
|
||||
|
||||
// Optional: validate user on each request
|
||||
validateUser: (user) => !user.banned,
|
||||
})
|
||||
|
||||
// Create session (for login endpoints)
|
||||
const sessionId = await sessionProvider.createSession(user, {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
})
|
||||
|
||||
// Revoke session (for logout)
|
||||
await sessionProvider.revoke(sessionId)
|
||||
```
|
||||
|
||||
## Server Auth Mixin
|
||||
|
||||
The `withAuth` function wraps your server to add authentication:
|
||||
|
||||
```typescript
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
const server = withAuth(baseServer, {
|
||||
// Required: auth provider
|
||||
provider: jwtProvider,
|
||||
|
||||
// Required: extract credentials from request
|
||||
extractCredentials: (req) => {
|
||||
// From query string
|
||||
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||
|
||||
// Or from headers
|
||||
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||
},
|
||||
|
||||
// Optional: handle auth failure
|
||||
onAuthFailed: (conn, error) => {
|
||||
console.log(`Auth failed: ${error}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Accessing Auth Context
|
||||
|
||||
After authentication, the auth context is available on connections:
|
||||
|
||||
```typescript
|
||||
import { getAuthContext } from '@esengine/server/auth'
|
||||
|
||||
server.onConnect = (conn) => {
|
||||
const auth = getAuthContext(conn)
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
console.log(`User ${auth.userId} connected`)
|
||||
console.log(`Roles: ${auth.roles}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Room Auth Mixin
|
||||
|
||||
The `withRoomAuth` function adds authentication checks to rooms:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
// Require authentication to join
|
||||
requireAuth: true,
|
||||
|
||||
// Optional: require specific roles
|
||||
allowedRoles: ['player', 'premium'],
|
||||
|
||||
// Optional: role check mode ('any' or 'all')
|
||||
roleCheckMode: 'any',
|
||||
}) {
|
||||
// player has .auth and .user properties
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} joined`)
|
||||
console.log(`Is premium: ${player.auth.hasRole('premium')}`)
|
||||
}
|
||||
|
||||
// Optional: custom auth validation
|
||||
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||
// Additional validation logic
|
||||
if (player.auth.hasRole('banned')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name ?? 'Guest',
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPlayer Interface
|
||||
|
||||
Players in auth rooms have additional properties:
|
||||
|
||||
```typescript
|
||||
interface AuthPlayer<TUser> extends Player {
|
||||
// Full auth context
|
||||
readonly auth: IAuthContext<TUser>
|
||||
|
||||
// User info (shortcut for auth.user)
|
||||
readonly user: TUser | null
|
||||
}
|
||||
```
|
||||
|
||||
### Room Auth Helpers
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRoomAuth<User>(Room) {
|
||||
someMethod() {
|
||||
// Get player by user ID
|
||||
const player = this.getPlayerByUserId('user-123')
|
||||
|
||||
// Get all players with a role
|
||||
const admins = this.getPlayersByRole('admin')
|
||||
|
||||
// Get player with auth info
|
||||
const authPlayer = this.getAuthPlayer(playerId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Decorators
|
||||
|
||||
### @requireAuth
|
||||
|
||||
Mark message handlers as requiring authentication:
|
||||
|
||||
```typescript
|
||||
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||
|
||||
class GameRoom extends withRoomAuth(Room) {
|
||||
@requireAuth()
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||
// Only authenticated players can trade
|
||||
}
|
||||
|
||||
@requireAuth({ allowGuest: true })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: ChatData, player: AuthPlayer) {
|
||||
// Guests can also chat
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @requireRole
|
||||
|
||||
Require specific roles for message handlers:
|
||||
|
||||
```typescript
|
||||
class AdminRoom extends withRoomAuth(Room) {
|
||||
@requireRole('admin')
|
||||
@onMessage('Ban')
|
||||
handleBan(data: BanData, player: AuthPlayer) {
|
||||
// Only admins can ban
|
||||
}
|
||||
|
||||
@requireRole(['moderator', 'admin'])
|
||||
@onMessage('Mute')
|
||||
handleMute(data: MuteData, player: AuthPlayer) {
|
||||
// Moderators OR admins can mute
|
||||
}
|
||||
|
||||
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
@onMessage('SpecialFeature')
|
||||
handleSpecial(data: any, player: AuthPlayer) {
|
||||
// Requires BOTH verified AND premium roles
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Context API
|
||||
|
||||
The auth context provides various methods for checking authentication state:
|
||||
|
||||
```typescript
|
||||
interface IAuthContext<TUser> {
|
||||
// Authentication state
|
||||
readonly isAuthenticated: boolean
|
||||
readonly user: TUser | null
|
||||
readonly userId: string | null
|
||||
readonly roles: ReadonlyArray<string>
|
||||
readonly authenticatedAt: number | null
|
||||
readonly expiresAt: number | null
|
||||
|
||||
// Role checking
|
||||
hasRole(role: string): boolean
|
||||
hasAnyRole(roles: string[]): boolean
|
||||
hasAllRoles(roles: string[]): boolean
|
||||
}
|
||||
```
|
||||
|
||||
The `AuthContext` class (implementation) also provides:
|
||||
|
||||
```typescript
|
||||
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||
// Set authentication from result
|
||||
setAuthenticated(result: AuthResult<TUser>): void
|
||||
|
||||
// Clear authentication state
|
||||
clear(): void
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use the mock auth provider for unit tests:
|
||||
|
||||
```typescript
|
||||
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||
|
||||
// Create mock provider with preset users
|
||||
const mockProvider = createMockAuthProvider({
|
||||
users: [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
],
|
||||
autoCreate: true, // Create users for unknown tokens
|
||||
})
|
||||
|
||||
// Use in tests
|
||||
const server = withAuth(testServer, {
|
||||
provider: mockProvider,
|
||||
extractCredentials: (req) => req.headers['x-token'],
|
||||
})
|
||||
|
||||
// Verify with user ID as token
|
||||
const result = await mockProvider.verify('1')
|
||||
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
|
||||
// Add/remove users dynamically
|
||||
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||
mockProvider.removeUser('3')
|
||||
|
||||
// Revoke tokens
|
||||
await mockProvider.revoke('1')
|
||||
|
||||
// Reset to initial state
|
||||
mockProvider.clear()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Auth errors include error codes for programmatic handling:
|
||||
|
||||
```typescript
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS' // Invalid username/password
|
||||
| 'INVALID_TOKEN' // Token is malformed or invalid
|
||||
| 'EXPIRED_TOKEN' // Token has expired
|
||||
| 'USER_NOT_FOUND' // User lookup failed
|
||||
| 'ACCOUNT_DISABLED' // User account is disabled
|
||||
| 'RATE_LIMITED' // Too many requests
|
||||
| 'INSUFFICIENT_PERMISSIONS' // Insufficient permissions
|
||||
|
||||
// In your auth failure handler
|
||||
const server = withAuth(baseServer, {
|
||||
provider: jwtProvider,
|
||||
extractCredentials,
|
||||
onAuthFailed: (conn, error) => {
|
||||
switch (error.errorCode) {
|
||||
case 'EXPIRED_TOKEN':
|
||||
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||
break
|
||||
case 'INVALID_TOKEN':
|
||||
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||
break
|
||||
default:
|
||||
conn.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example with JWT authentication:
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
import {
|
||||
withAuth,
|
||||
withRoomAuth,
|
||||
createJwtAuthProvider,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
type AuthPlayer,
|
||||
} from '@esengine/server/auth'
|
||||
|
||||
// Types
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// JWT Provider
|
||||
const jwtProvider = createJwtAuthProvider<User>({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) ?? [],
|
||||
}),
|
||||
})
|
||||
|
||||
// Create authenticated server
|
||||
const server = withAuth(
|
||||
await createServer({ port: 3000 }),
|
||||
{
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
return new URL(req.url ?? '', 'http://localhost')
|
||||
.searchParams.get('token')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Game Room with auth
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['player'],
|
||||
}) {
|
||||
onCreate() {
|
||||
console.log('Game room created')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} joined!`)
|
||||
this.broadcast('PlayerJoined', {
|
||||
id: player.id,
|
||||
name: player.user?.name,
|
||||
})
|
||||
}
|
||||
|
||||
@requireAuth()
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
// Handle movement
|
||||
}
|
||||
|
||||
@requireRole('admin')
|
||||
@onMessage('Kick')
|
||||
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
const target = this.getPlayer(data.playerId)
|
||||
if (target) {
|
||||
this.kick(target, 'Kicked by admin')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Secure your secrets**: Never hardcode JWT secrets. Use environment variables.
|
||||
|
||||
2. **Set reasonable expiration**: Balance security and user experience when setting token TTL.
|
||||
|
||||
3. **Validate on critical actions**: Use `@requireAuth` on sensitive message handlers.
|
||||
|
||||
4. **Use role-based access**: Implement proper role hierarchy for admin functions.
|
||||
|
||||
5. **Handle token refresh**: Implement token refresh logic for long sessions.
|
||||
|
||||
6. **Log auth events**: Track login attempts and failures for security monitoring.
|
||||
|
||||
7. **Test auth flows**: Use `MockAuthProvider` to test authentication scenarios.
|
||||
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: "State Delta Compression"
|
||||
description: "Reduce network bandwidth with incremental sync"
|
||||
---
|
||||
|
||||
State delta compression reduces network bandwidth by only sending fields that have changed. For frequently synchronized game state, this can significantly reduce data transmission.
|
||||
|
||||
## StateDeltaCompressor
|
||||
|
||||
The `StateDeltaCompressor` class is used to compress and decompress state deltas.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||
|
||||
// Create compressor
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.01, // Position change threshold
|
||||
rotationThreshold: 0.001, // Rotation change threshold (radians)
|
||||
velocityThreshold: 0.1, // Velocity change threshold
|
||||
fullSnapshotInterval: 60, // Full snapshot interval (frames)
|
||||
});
|
||||
|
||||
// Compress sync data
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||
],
|
||||
};
|
||||
|
||||
const deltaData = compressor.compress(syncData);
|
||||
// deltaData only contains changed fields
|
||||
|
||||
// Decompress delta data
|
||||
const fullData = compressor.decompress(deltaData);
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `positionThreshold` | `number` | 0.01 | Position change threshold |
|
||||
| `rotationThreshold` | `number` | 0.001 | Rotation change threshold (radians) |
|
||||
| `velocityThreshold` | `number` | 0.1 | Velocity change threshold |
|
||||
| `fullSnapshotInterval` | `number` | 60 | Full snapshot interval (frames) |
|
||||
|
||||
## Delta Flags
|
||||
|
||||
Bit flags indicate which fields have changed:
|
||||
|
||||
```typescript
|
||||
import { DeltaFlags } from '@esengine/network';
|
||||
|
||||
// Flag definitions
|
||||
DeltaFlags.NONE // 0 - No change
|
||||
DeltaFlags.POSITION // 1 - Position changed
|
||||
DeltaFlags.ROTATION // 2 - Rotation changed
|
||||
DeltaFlags.VELOCITY // 4 - Velocity changed
|
||||
DeltaFlags.ANGULAR_VELOCITY // 8 - Angular velocity changed
|
||||
DeltaFlags.CUSTOM // 16 - Custom data changed
|
||||
```
|
||||
|
||||
## Data Format
|
||||
|
||||
### Full State
|
||||
|
||||
```typescript
|
||||
interface EntitySyncState {
|
||||
netId: number;
|
||||
pos?: { x: number; y: number };
|
||||
rot?: number;
|
||||
vel?: { x: number; y: number };
|
||||
angVel?: number;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### Delta State
|
||||
|
||||
```typescript
|
||||
interface EntityDeltaState {
|
||||
netId: number;
|
||||
flags: number; // Change flags
|
||||
pos?: { x: number; y: number }; // Only present when POSITION flag set
|
||||
rot?: number; // Only present when ROTATION flag set
|
||||
vel?: { x: number; y: number }; // Only present when VELOCITY flag set
|
||||
angVel?: number; // Only present when ANGULAR_VELOCITY flag set
|
||||
custom?: Record<string, unknown>; // Only present when CUSTOM flag set
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Frame 1 (full snapshot):
|
||||
Entity 1: pos=(100, 200), rot=0
|
||||
|
||||
Frame 2 (delta):
|
||||
Entity 1: flags=POSITION, pos=(101, 200) // Only X changed
|
||||
|
||||
Frame 3 (delta):
|
||||
Entity 1: flags=0 // No change, not sent
|
||||
|
||||
Frame 4 (delta):
|
||||
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||
|
||||
Frame 60 (forced full snapshot):
|
||||
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||
```
|
||||
|
||||
## Server-Side Usage
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// Broadcast state updates
|
||||
broadcastState(entities: EntitySyncState[]) {
|
||||
const syncData: SyncData = {
|
||||
frame: this.currentFrame,
|
||||
timestamp: Date.now(),
|
||||
entities,
|
||||
};
|
||||
|
||||
// Compress data
|
||||
const deltaData = this.compressor.compress(syncData);
|
||||
|
||||
// Send delta data
|
||||
this.broadcast('sync', deltaData);
|
||||
}
|
||||
|
||||
// Cleanup when player leaves
|
||||
onPlayerLeave(netId: number) {
|
||||
this.compressor.removeEntity(netId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Usage
|
||||
|
||||
```typescript
|
||||
class GameClient {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// Receive delta data
|
||||
onSyncReceived(deltaData: DeltaSyncData) {
|
||||
// Decompress to full state
|
||||
const fullData = this.compressor.decompress(deltaData);
|
||||
|
||||
// Apply state
|
||||
for (const entity of fullData.entities) {
|
||||
this.applyEntityState(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Bandwidth Savings Example
|
||||
|
||||
Assume each entity has the following data:
|
||||
|
||||
| Field | Size (bytes) |
|
||||
|-------|-------------|
|
||||
| netId | 4 |
|
||||
| pos.x | 8 |
|
||||
| pos.y | 8 |
|
||||
| rot | 8 |
|
||||
| vel.x | 8 |
|
||||
| vel.y | 8 |
|
||||
| angVel | 8 |
|
||||
| **Total** | **52** |
|
||||
|
||||
With delta compression:
|
||||
|
||||
| Scenario | Original | Compressed | Savings |
|
||||
|----------|----------|------------|---------|
|
||||
| Only position changed | 52 | 4+1+16 = 21 | 60% |
|
||||
| Only rotation changed | 52 | 4+1+8 = 13 | 75% |
|
||||
| Stationary | 52 | 0 | 100% |
|
||||
| Position + rotation changed | 52 | 4+1+24 = 29 | 44% |
|
||||
|
||||
## Forcing Full Snapshot
|
||||
|
||||
Some situations require sending full snapshots:
|
||||
|
||||
```typescript
|
||||
// When new player joins
|
||||
compressor.forceFullSnapshot();
|
||||
const data = compressor.compress(syncData);
|
||||
// This will send full state
|
||||
|
||||
// On reconnection
|
||||
compressor.clear(); // Clear history
|
||||
compressor.forceFullSnapshot();
|
||||
```
|
||||
|
||||
## Custom Data
|
||||
|
||||
Support for syncing custom game data:
|
||||
|
||||
```typescript
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{
|
||||
netId: 1,
|
||||
pos: { x: 100, y: 200 },
|
||||
custom: {
|
||||
health: 80,
|
||||
mana: 50,
|
||||
buffs: ['speed', 'shield'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Custom data is also delta compressed
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Set Appropriate Thresholds
|
||||
|
||||
```typescript
|
||||
// High precision games (e.g., competitive)
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.001,
|
||||
rotationThreshold: 0.0001,
|
||||
});
|
||||
|
||||
// Casual games
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.1,
|
||||
rotationThreshold: 0.01,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Adjust Full Snapshot Interval
|
||||
|
||||
```typescript
|
||||
// High reliability (unstable network)
|
||||
fullSnapshotInterval: 30, // Full snapshot every 30 frames
|
||||
|
||||
// Low bandwidth priority
|
||||
fullSnapshotInterval: 120, // Full snapshot every 120 frames
|
||||
```
|
||||
|
||||
### 3. Combine with AOI
|
||||
|
||||
```typescript
|
||||
// Filter with AOI first, then delta compress
|
||||
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
### 4. Handle Entity Removal
|
||||
|
||||
```typescript
|
||||
// Clean up compressor state when entity despawns
|
||||
function onEntityDespawn(netId: number) {
|
||||
compressor.removeEntity(netId);
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Other Features
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Game State │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ AOI Filter │ ← Only process entities in view
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Delta Compress │ ← Only send changed fields
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Network Send │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
const compressor = createStateDeltaCompressor();
|
||||
|
||||
// Check compression efficiency
|
||||
const original = syncData;
|
||||
const compressed = compressor.compress(original);
|
||||
|
||||
console.log('Original entities:', original.entities.length);
|
||||
console.log('Compressed entities:', compressed.entities.length);
|
||||
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||
|
||||
// View each entity's changes
|
||||
for (const delta of compressed.entities) {
|
||||
console.log(`Entity ${delta.netId}:`, {
|
||||
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||
});
|
||||
}
|
||||
```
|
||||
441
docs/src/content/docs/en/modules/network/distributed.md
Normal file
441
docs/src/content/docs/en/modules/network/distributed.md
Normal file
@@ -0,0 +1,441 @@
|
||||
---
|
||||
title: "Distributed Rooms"
|
||||
description: "Multi-server room management with DistributedRoomManager"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Distributed room support allows multiple server instances to share a room registry, enabling cross-server player routing and failover.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Server A Server B Server C │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
|
||||
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IDistributedAdapter │ │
|
||||
│ │ (Redis / Memory) │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Single Server Mode (Testing)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DistributedRoomManager,
|
||||
MemoryAdapter,
|
||||
Room
|
||||
} from '@esengine/server';
|
||||
|
||||
// Define room type
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
// Create adapter and manager
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000
|
||||
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
|
||||
|
||||
// Register room type
|
||||
manager.define('game', GameRoom);
|
||||
|
||||
// Start manager
|
||||
await manager.start();
|
||||
|
||||
// Distributed join/create room
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
if ('redirect' in result) {
|
||||
// Player should connect to another server
|
||||
console.log(`Redirect to: ${result.redirect}`);
|
||||
} else {
|
||||
// Player joined local room
|
||||
const { room, player } = result;
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### Multi-Server Mode (Production)
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis({
|
||||
host: 'redis.example.com',
|
||||
port: 6379
|
||||
}),
|
||||
prefix: 'game:',
|
||||
serverTtl: 30,
|
||||
snapshotTtl: 86400
|
||||
});
|
||||
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: process.env.SERVER_ID,
|
||||
serverAddress: process.env.PUBLIC_IP,
|
||||
serverPort: 3000,
|
||||
heartbeatInterval: 5000,
|
||||
snapshotInterval: 30000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## DistributedRoomManager
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `serverId` | `string` | required | Unique server identifier |
|
||||
| `serverAddress` | `string` | required | Public address for client connections |
|
||||
| `serverPort` | `number` | required | Server port |
|
||||
| `heartbeatInterval` | `number` | `5000` | Heartbeat interval (ms) |
|
||||
| `snapshotInterval` | `number` | `30000` | State snapshot interval, 0 to disable |
|
||||
| `migrationTimeout` | `number` | `10000` | Room migration timeout |
|
||||
| `enableFailover` | `boolean` | `true` | Enable automatic failover |
|
||||
| `capacity` | `number` | `100` | Max rooms on this server |
|
||||
|
||||
### Lifecycle Methods
|
||||
|
||||
#### start()
|
||||
|
||||
Start the distributed room manager. Connects to adapter, registers server, starts heartbeat.
|
||||
|
||||
```typescript
|
||||
await manager.start();
|
||||
```
|
||||
|
||||
#### stop(graceful?)
|
||||
|
||||
Stop the manager. If `graceful=true`, marks server as draining and saves all room snapshots.
|
||||
|
||||
```typescript
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### Routing Methods
|
||||
|
||||
#### joinOrCreateDistributed()
|
||||
|
||||
Join or create a room with distributed awareness. Returns `{ room, player }` for local rooms or `{ redirect: string }` for remote rooms.
|
||||
|
||||
```typescript
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
|
||||
if ('redirect' in result) {
|
||||
// Client should redirect to another server
|
||||
res.json({ redirect: result.redirect });
|
||||
} else {
|
||||
// Player joined local room
|
||||
const { room, player } = result;
|
||||
}
|
||||
```
|
||||
|
||||
#### route()
|
||||
|
||||
Route a player to the appropriate room/server.
|
||||
|
||||
```typescript
|
||||
const result = await manager.route({
|
||||
roomType: 'game',
|
||||
playerId: 'p1'
|
||||
});
|
||||
|
||||
switch (result.type) {
|
||||
case 'local': // Room is on this server
|
||||
break;
|
||||
case 'redirect': // Room is on another server
|
||||
// result.serverAddress contains target server
|
||||
break;
|
||||
case 'create': // No room exists, need to create
|
||||
break;
|
||||
case 'unavailable': // Cannot find or create room
|
||||
// result.reason contains error message
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
#### saveSnapshot()
|
||||
|
||||
Manually save a room's state snapshot.
|
||||
|
||||
```typescript
|
||||
await manager.saveSnapshot(roomId);
|
||||
```
|
||||
|
||||
#### restoreFromSnapshot()
|
||||
|
||||
Restore a room from its saved snapshot.
|
||||
|
||||
```typescript
|
||||
const success = await manager.restoreFromSnapshot(roomId);
|
||||
```
|
||||
|
||||
### Query Methods
|
||||
|
||||
#### getServers()
|
||||
|
||||
Get all online servers.
|
||||
|
||||
```typescript
|
||||
const servers = await manager.getServers();
|
||||
```
|
||||
|
||||
#### queryDistributedRooms()
|
||||
|
||||
Query rooms across all servers.
|
||||
|
||||
```typescript
|
||||
const rooms = await manager.queryDistributedRooms({
|
||||
roomType: 'game',
|
||||
hasSpace: true,
|
||||
notLocked: true
|
||||
});
|
||||
```
|
||||
|
||||
## IDistributedAdapter
|
||||
|
||||
Interface for distributed backends. Implement this to add support for Redis, message queues, etc.
|
||||
|
||||
### Built-in Adapters
|
||||
|
||||
#### MemoryAdapter
|
||||
|
||||
In-memory implementation for testing and single-server mode.
|
||||
|
||||
```typescript
|
||||
const adapter = new MemoryAdapter({
|
||||
serverTtl: 15000, // Server offline after no heartbeat (ms)
|
||||
enableTtlCheck: true, // Enable automatic TTL checking
|
||||
ttlCheckInterval: 5000 // TTL check interval (ms)
|
||||
});
|
||||
```
|
||||
|
||||
#### RedisAdapter
|
||||
|
||||
Redis-based implementation for production multi-server deployments.
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis('redis://localhost:6379'),
|
||||
prefix: 'game:', // Key prefix (default: 'dist:')
|
||||
serverTtl: 30, // Server TTL in seconds (default: 30)
|
||||
roomTtl: 0, // Room TTL, 0 = never expire (default: 0)
|
||||
snapshotTtl: 86400, // Snapshot TTL in seconds (default: 24h)
|
||||
channel: 'game:events' // Pub/Sub channel (default: 'distributed:events')
|
||||
});
|
||||
```
|
||||
|
||||
**RedisAdapter Configuration:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `factory` | `() => RedisClient` | required | Redis client factory (lazy connection) |
|
||||
| `prefix` | `string` | `'dist:'` | Key prefix for all Redis keys |
|
||||
| `serverTtl` | `number` | `30` | Server TTL in seconds |
|
||||
| `roomTtl` | `number` | `0` | Room TTL in seconds, 0 = no expiry |
|
||||
| `snapshotTtl` | `number` | `86400` | Snapshot TTL in seconds |
|
||||
| `channel` | `string` | `'distributed:events'` | Pub/Sub channel name |
|
||||
|
||||
**Features:**
|
||||
- Server registry with automatic heartbeat TTL
|
||||
- Room registry with cross-server lookup
|
||||
- State snapshots with configurable TTL
|
||||
- Pub/Sub for cross-server events
|
||||
- Distributed locks using Redis SET NX
|
||||
|
||||
### Custom Adapters
|
||||
|
||||
```typescript
|
||||
import type { IDistributedAdapter } from '@esengine/server';
|
||||
|
||||
class MyAdapter implements IDistributedAdapter {
|
||||
// Lifecycle
|
||||
async connect(): Promise<void> { }
|
||||
async disconnect(): Promise<void> { }
|
||||
isConnected(): boolean { return true; }
|
||||
|
||||
// Server Registry
|
||||
async registerServer(server: ServerRegistration): Promise<void> { }
|
||||
async unregisterServer(serverId: string): Promise<void> { }
|
||||
async heartbeat(serverId: string): Promise<void> { }
|
||||
async getServers(): Promise<ServerRegistration[]> { return []; }
|
||||
|
||||
// Room Registry
|
||||
async registerRoom(room: RoomRegistration): Promise<void> { }
|
||||
async unregisterRoom(roomId: string): Promise<void> { }
|
||||
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
|
||||
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
|
||||
|
||||
// State Snapshots
|
||||
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
|
||||
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
|
||||
|
||||
// Pub/Sub
|
||||
async publish(event: DistributedEvent): Promise<void> { }
|
||||
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
|
||||
|
||||
// Distributed Locks
|
||||
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
|
||||
async releaseLock(key: string): Promise<void> { }
|
||||
}
|
||||
```
|
||||
|
||||
## Player Routing Flow
|
||||
|
||||
```
|
||||
Client Server A Server B
|
||||
│ │ │
|
||||
│─── joinOrCreate ────────►│ │
|
||||
│ │ │
|
||||
│ │── findAvailableRoom() ───►│
|
||||
│ │◄──── room on Server B ────│
|
||||
│ │ │
|
||||
│◄─── redirect: B:3001 ────│ │
|
||||
│ │ │
|
||||
│───────────────── connect to Server B ───────────────►│
|
||||
│ │ │
|
||||
│◄─────────────────────────────── joined ─────────────│
|
||||
```
|
||||
|
||||
## Event Types
|
||||
|
||||
The distributed system publishes these events:
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `server:online` | Server came online |
|
||||
| `server:offline` | Server went offline |
|
||||
| `server:draining` | Server is draining |
|
||||
| `room:created` | Room was created |
|
||||
| `room:disposed` | Room was disposed |
|
||||
| `room:updated` | Room info updated |
|
||||
| `room:message` | Cross-server room message |
|
||||
| `room:migrated` | Room migrated to another server |
|
||||
| `player:joined` | Player joined room |
|
||||
| `player:left` | Player left room |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Unique Server IDs** - Use hostname, container ID, or UUID
|
||||
|
||||
2. **Configure Proper Heartbeat** - Balance between freshness and network overhead
|
||||
|
||||
3. **Enable Snapshots for Stateful Rooms** - Ensure room state survives server restarts
|
||||
|
||||
4. **Handle Redirects Gracefully** - Client should reconnect to target server
|
||||
```typescript
|
||||
// Client handling redirect
|
||||
if (response.redirect) {
|
||||
await client.disconnect();
|
||||
await client.connect(response.redirect);
|
||||
await client.joinRoom(roomId);
|
||||
}
|
||||
```
|
||||
|
||||
5. **Use Distributed Locks** - Prevent race conditions in joinOrCreate
|
||||
|
||||
## Using createServer Integration
|
||||
|
||||
The simplest way to use distributed rooms is through `createServer`'s `distributed` config:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server';
|
||||
import { RedisAdapter, Room } from '@esengine/server';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
distributed: {
|
||||
enabled: true,
|
||||
adapter: new RedisAdapter({ factory: () => new Redis() }),
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'ws://192.168.1.100',
|
||||
serverPort: 3000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}
|
||||
});
|
||||
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
When clients call the `JoinRoom` API, the server will automatically:
|
||||
1. Find available rooms (local or remote)
|
||||
2. If room is on another server, send `$redirect` message to client
|
||||
3. Client receives redirect and connects to target server
|
||||
|
||||
## Load Balancing
|
||||
|
||||
Use `LoadBalancedRouter` for server selection:
|
||||
|
||||
```typescript
|
||||
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
|
||||
|
||||
// Using factory function
|
||||
const router = createLoadBalancedRouter('least-players');
|
||||
|
||||
// Or create directly
|
||||
const router = new LoadBalancedRouter({
|
||||
strategy: 'least-rooms', // Select server with fewest rooms
|
||||
preferLocal: true // Prefer local server
|
||||
});
|
||||
|
||||
// Available strategies
|
||||
// - 'round-robin': Round robin selection
|
||||
// - 'least-rooms': Fewest rooms
|
||||
// - 'least-players': Fewest players
|
||||
// - 'random': Random selection
|
||||
// - 'weighted': Weighted by capacity usage
|
||||
```
|
||||
|
||||
## Failover
|
||||
|
||||
When a server goes offline with `enableFailover` enabled, the system will automatically:
|
||||
|
||||
1. Detect server offline (via heartbeat timeout)
|
||||
2. Query all rooms on that server
|
||||
3. Use distributed lock to prevent multiple servers recovering same room
|
||||
4. Restore room state from snapshot
|
||||
5. Publish `room:migrated` event to notify other servers
|
||||
|
||||
```typescript
|
||||
// Ensure periodic snapshots
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000,
|
||||
snapshotInterval: 30000, // Save snapshot every 30 seconds
|
||||
enableFailover: true // Enable failover
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## Future Releases
|
||||
|
||||
- Redis Cluster support
|
||||
- More load balancing strategies (geo-location, latency-aware)
|
||||
326
docs/src/content/docs/en/modules/network/fixed-point.md
Normal file
326
docs/src/content/docs/en/modules/network/fixed-point.md
Normal file
@@ -0,0 +1,326 @@
|
||||
---
|
||||
title: "Fixed-Point Numbers"
|
||||
description: "Deterministic fixed-point math library for lockstep games"
|
||||
---
|
||||
|
||||
`@esengine/ecs-framework-math` provides deterministic fixed-point calculations designed for **Lockstep** architecture. Fixed-point numbers guarantee identical results across all platforms.
|
||||
|
||||
## Why Fixed-Point?
|
||||
|
||||
Floating-point numbers may produce different rounding results on different platforms:
|
||||
|
||||
```typescript
|
||||
// Floating-point: may differ across platforms
|
||||
const a = 0.1 + 0.2; // 0.30000000000000004 (some platforms)
|
||||
// 0.3 (other platforms)
|
||||
|
||||
// Fixed-point: consistent everywhere
|
||||
const x = Fixed32.from(0.1);
|
||||
const y = Fixed32.from(0.2);
|
||||
const z = x.add(y); // raw = 19661 (all platforms)
|
||||
```
|
||||
|
||||
| Feature | Floating-Point | Fixed-Point |
|
||||
|---------|----------------|-------------|
|
||||
| Cross-platform consistency | ❌ May differ | ✅ Identical |
|
||||
| Network sync mode | State sync | Lockstep |
|
||||
| Game types | FPS, RPG | RTS, MOBA, Fighting |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/ecs-framework-math
|
||||
```
|
||||
|
||||
## Fixed32 Fixed-Point Number
|
||||
|
||||
Q16.16 format: 16-bit integer + 16-bit fraction, range ±32767.99998.
|
||||
|
||||
### Creating Fixed-Point Numbers
|
||||
|
||||
```typescript
|
||||
import { Fixed32 } from '@esengine/ecs-framework-math';
|
||||
|
||||
// From floating-point
|
||||
const speed = Fixed32.from(5.5);
|
||||
|
||||
// From integer (no precision loss)
|
||||
const count = Fixed32.fromInt(10);
|
||||
|
||||
// From raw value (after network receive)
|
||||
const received = Fixed32.fromRaw(360448); // equals 5.5
|
||||
|
||||
// Predefined constants
|
||||
Fixed32.ZERO // 0
|
||||
Fixed32.ONE // 1
|
||||
Fixed32.HALF // 0.5
|
||||
Fixed32.PI // π
|
||||
Fixed32.TWO_PI // 2π
|
||||
Fixed32.HALF_PI // π/2
|
||||
```
|
||||
|
||||
### Basic Operations
|
||||
|
||||
```typescript
|
||||
const a = Fixed32.from(10);
|
||||
const b = Fixed32.from(3);
|
||||
|
||||
const sum = a.add(b); // 13
|
||||
const diff = a.sub(b); // 7
|
||||
const prod = a.mul(b); // 30
|
||||
const quot = a.div(b); // 3.333...
|
||||
const mod = a.mod(b); // 1
|
||||
const neg = a.neg(); // -10
|
||||
const abs = neg.abs(); // 10
|
||||
```
|
||||
|
||||
### Comparison Operations
|
||||
|
||||
```typescript
|
||||
const x = Fixed32.from(5);
|
||||
const y = Fixed32.from(3);
|
||||
|
||||
x.eq(y) // false - equal
|
||||
x.ne(y) // true - not equal
|
||||
x.lt(y) // false - less than
|
||||
x.le(y) // false - less or equal
|
||||
x.gt(y) // true - greater than
|
||||
x.ge(y) // true - greater or equal
|
||||
|
||||
x.isZero() // false
|
||||
x.isPositive() // true
|
||||
x.isNegative() // false
|
||||
```
|
||||
|
||||
### Math Functions
|
||||
|
||||
```typescript
|
||||
// Square root (Newton's method, deterministic)
|
||||
const sqrt = Fixed32.sqrt(Fixed32.from(16)); // 4
|
||||
|
||||
// Rounding
|
||||
Fixed32.floor(Fixed32.from(3.7)) // 3
|
||||
Fixed32.ceil(Fixed32.from(3.2)) // 4
|
||||
Fixed32.round(Fixed32.from(3.5)) // 4
|
||||
|
||||
// Clamping
|
||||
Fixed32.clamp(value, min, max)
|
||||
|
||||
// Linear interpolation
|
||||
Fixed32.lerp(from, to, t)
|
||||
|
||||
// Min/Max
|
||||
Fixed32.min(a, b)
|
||||
Fixed32.max(a, b)
|
||||
```
|
||||
|
||||
### Type Conversion
|
||||
|
||||
```typescript
|
||||
const value = Fixed32.from(3.14159);
|
||||
|
||||
// To float (for rendering)
|
||||
const float = value.toNumber(); // 3.14159
|
||||
|
||||
// Get raw value (for network)
|
||||
const raw = value.toRaw(); // 205887
|
||||
|
||||
// To integer (floor)
|
||||
const int = value.toInt(); // 3
|
||||
```
|
||||
|
||||
## FixedVector2 Fixed-Point Vector
|
||||
|
||||
Immutable 2D vector, all operations return new instances.
|
||||
|
||||
### Creating Vectors
|
||||
|
||||
```typescript
|
||||
import { FixedVector2, Fixed32 } from '@esengine/ecs-framework-math';
|
||||
|
||||
// From floating-point
|
||||
const pos = FixedVector2.from(100, 200);
|
||||
|
||||
// From raw values (after network receive)
|
||||
const received = FixedVector2.fromRaw(6553600, 13107200);
|
||||
|
||||
// From Fixed32
|
||||
const vec = new FixedVector2(Fixed32.from(10), Fixed32.from(20));
|
||||
|
||||
// Predefined constants
|
||||
FixedVector2.ZERO // (0, 0)
|
||||
FixedVector2.ONE // (1, 1)
|
||||
FixedVector2.RIGHT // (1, 0)
|
||||
FixedVector2.LEFT // (-1, 0)
|
||||
FixedVector2.UP // (0, 1)
|
||||
FixedVector2.DOWN // (0, -1)
|
||||
```
|
||||
|
||||
### Vector Operations
|
||||
|
||||
```typescript
|
||||
const a = FixedVector2.from(3, 4);
|
||||
const b = FixedVector2.from(1, 2);
|
||||
|
||||
// Basic operations
|
||||
const sum = a.add(b); // (4, 6)
|
||||
const diff = a.sub(b); // (2, 2)
|
||||
const scaled = a.mul(Fixed32.from(2)); // (6, 8)
|
||||
const divided = a.div(Fixed32.from(2)); // (1.5, 2)
|
||||
|
||||
// Vector products
|
||||
const dot = a.dot(b); // 3*1 + 4*2 = 11
|
||||
const cross = a.cross(b); // 3*2 - 4*1 = 2
|
||||
|
||||
// Length
|
||||
const lenSq = a.lengthSquared(); // 25
|
||||
const len = a.length(); // 5
|
||||
|
||||
// Normalize
|
||||
const norm = a.normalize(); // (0.6, 0.8)
|
||||
|
||||
// Distance
|
||||
const dist = a.distanceTo(b); // sqrt((3-1)² + (4-2)²)
|
||||
```
|
||||
|
||||
### Rotation and Angles
|
||||
|
||||
```typescript
|
||||
import { FixedMath } from '@esengine/ecs-framework-math';
|
||||
|
||||
const vec = FixedVector2.from(1, 0);
|
||||
const angle = Fixed32.from(Math.PI / 2); // 90 degrees
|
||||
|
||||
// Rotate vector
|
||||
const rotated = vec.rotate(angle); // (0, 1)
|
||||
|
||||
// Rotate around point
|
||||
const center = FixedVector2.from(5, 5);
|
||||
const around = vec.rotateAround(center, angle);
|
||||
|
||||
// Get vector angle
|
||||
const vecAngle = vec.angle();
|
||||
|
||||
// Angle between vectors
|
||||
const between = vec.angleTo(other);
|
||||
|
||||
// Create unit vector from angle
|
||||
const dir = FixedVector2.fromAngle(angle);
|
||||
|
||||
// From polar coordinates
|
||||
const polar = FixedVector2.fromPolar(length, angle);
|
||||
```
|
||||
|
||||
### Type Conversion
|
||||
|
||||
```typescript
|
||||
const pos = FixedVector2.from(100.5, 200.5);
|
||||
|
||||
// To float object (for rendering)
|
||||
const obj = pos.toObject(); // { x: 100.5, y: 200.5 }
|
||||
|
||||
// To array
|
||||
const arr = pos.toArray(); // [100.5, 200.5]
|
||||
|
||||
// Get raw values (for network)
|
||||
const raw = pos.toRawObject(); // { x: 6586368, y: 13140992 }
|
||||
```
|
||||
|
||||
## FixedMath Trigonometric Functions
|
||||
|
||||
Deterministic trigonometric functions using lookup tables.
|
||||
|
||||
```typescript
|
||||
import { FixedMath, Fixed32 } from '@esengine/ecs-framework-math';
|
||||
|
||||
const angle = Fixed32.from(Math.PI / 6); // 30 degrees
|
||||
|
||||
// Trigonometric functions
|
||||
const sin = FixedMath.sin(angle); // 0.5
|
||||
const cos = FixedMath.cos(angle); // 0.866
|
||||
const tan = FixedMath.tan(angle); // 0.577
|
||||
|
||||
// Inverse trigonometric
|
||||
const atan = FixedMath.atan2(y, x);
|
||||
const asin = FixedMath.asin(value);
|
||||
const acos = FixedMath.acos(value);
|
||||
|
||||
// Normalize angle to [-π, π]
|
||||
const normalized = FixedMath.normalizeAngle(angle);
|
||||
|
||||
// Angle difference (shortest path)
|
||||
const delta = FixedMath.angleDelta(from, to);
|
||||
|
||||
// Angle interpolation (handles 360° wrap)
|
||||
const lerped = FixedMath.lerpAngle(from, to, t);
|
||||
|
||||
// Radian/degree conversion
|
||||
const deg = FixedMath.radToDeg(rad);
|
||||
const rad = FixedMath.degToRad(deg);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Fixed-Point Throughout
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: all game logic uses fixed-point
|
||||
function calculateDamage(baseDamage: Fixed32, multiplier: Fixed32): Fixed32 {
|
||||
return baseDamage.mul(multiplier);
|
||||
}
|
||||
|
||||
// ❌ Wrong: mixing floating-point
|
||||
function calculateDamage(baseDamage: number, multiplier: number): number {
|
||||
return baseDamage * multiplier; // may be inconsistent
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Only Convert to Float for Rendering
|
||||
|
||||
```typescript
|
||||
// Game logic
|
||||
const position: FixedVector2 = calculatePosition(input);
|
||||
|
||||
// Rendering
|
||||
const { x, y } = position.toObject();
|
||||
sprite.position.set(x, y);
|
||||
```
|
||||
|
||||
### 3. Use Raw Values for Network
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: transmit raw integers
|
||||
const raw = position.toRawObject();
|
||||
send(JSON.stringify(raw));
|
||||
|
||||
// ❌ Wrong: transmit floats
|
||||
const float = position.toObject();
|
||||
send(JSON.stringify(float)); // may lose precision
|
||||
```
|
||||
|
||||
### 4. Use FixedMath for Trigonometry
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: use lookup tables
|
||||
const direction = FixedVector2.fromAngle(FixedMath.atan2(dy, dx));
|
||||
|
||||
// ❌ Wrong: use Math library
|
||||
const angle = Math.atan2(dy.toNumber(), dx.toNumber()); // non-deterministic
|
||||
```
|
||||
|
||||
## API Exports
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Fixed32,
|
||||
FixedVector2,
|
||||
FixedMath,
|
||||
type IFixed32,
|
||||
type IFixedVector2
|
||||
} from '@esengine/ecs-framework-math';
|
||||
```
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [State Sync](/en/modules/network/sync) - Fixed-point snapshot buffer
|
||||
- [Client Prediction](/en/modules/network/prediction) - Fixed-point client prediction
|
||||
679
docs/src/content/docs/en/modules/network/http.md
Normal file
679
docs/src/content/docs/en/modules/network/http.md
Normal file
@@ -0,0 +1,679 @@
|
||||
---
|
||||
title: "HTTP Routing"
|
||||
description: "HTTP REST API routing with WebSocket port sharing support"
|
||||
---
|
||||
|
||||
`@esengine/server` includes a lightweight HTTP routing feature that can share the same port with WebSocket services, making it easy to implement REST APIs.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Inline Route Definition
|
||||
|
||||
The simplest way is to define HTTP routes directly when creating the server:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
http: {
|
||||
'/api/health': (req, res) => {
|
||||
res.json({ status: 'ok', time: Date.now() })
|
||||
},
|
||||
'/api/users': {
|
||||
GET: (req, res) => {
|
||||
res.json({ users: [] })
|
||||
},
|
||||
POST: async (req, res) => {
|
||||
const body = req.body as { name: string }
|
||||
res.status(201).json({ id: '1', name: body.name })
|
||||
}
|
||||
}
|
||||
},
|
||||
cors: true // Enable CORS
|
||||
})
|
||||
|
||||
await server.start()
|
||||
```
|
||||
|
||||
### File-based Routing
|
||||
|
||||
For larger projects, file-based routing is recommended. Create a `src/http` directory where each file corresponds to a route:
|
||||
|
||||
```typescript
|
||||
// src/http/login.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
interface LoginBody {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default defineHttp<LoginBody>({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
const { username, password } = req.body as LoginBody
|
||||
|
||||
// Validate user...
|
||||
if (username === 'admin' && password === '123456') {
|
||||
res.json({ token: 'jwt-token-here', userId: 'user-1' })
|
||||
} else {
|
||||
res.error(401, 'Invalid username or password')
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http', // HTTP routes directory
|
||||
httpPrefix: '/api', // Route prefix
|
||||
cors: true
|
||||
})
|
||||
|
||||
await server.start()
|
||||
// Route: POST /api/login
|
||||
```
|
||||
|
||||
## defineHttp Definition
|
||||
|
||||
`defineHttp` is used to define type-safe HTTP handlers:
|
||||
|
||||
```typescript
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
interface CreateUserBody {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default defineHttp<CreateUserBody>({
|
||||
// HTTP method (default POST)
|
||||
method: 'POST',
|
||||
|
||||
// Handler function
|
||||
handler(req, res) {
|
||||
const body = req.body as CreateUserBody
|
||||
// Handle request...
|
||||
res.status(201).json({ id: 'new-user-id' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Supported HTTP Methods
|
||||
|
||||
```typescript
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
|
||||
```
|
||||
|
||||
## HttpRequest Object
|
||||
|
||||
The HTTP request object contains the following properties:
|
||||
|
||||
```typescript
|
||||
interface HttpRequest {
|
||||
/** Raw Node.js IncomingMessage */
|
||||
raw: IncomingMessage
|
||||
|
||||
/** HTTP method */
|
||||
method: string
|
||||
|
||||
/** Request path */
|
||||
path: string
|
||||
|
||||
/** Route parameters (extracted from URL path, e.g., /users/:id) */
|
||||
params: Record<string, string>
|
||||
|
||||
/** Query parameters */
|
||||
query: Record<string, string>
|
||||
|
||||
/** Request headers */
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
|
||||
/** Parsed request body */
|
||||
body: unknown
|
||||
|
||||
/** Client IP */
|
||||
ip: string
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// Get query parameters
|
||||
const page = parseInt(req.query.page ?? '1')
|
||||
const limit = parseInt(req.query.limit ?? '10')
|
||||
|
||||
// Get request headers
|
||||
const authHeader = req.headers.authorization
|
||||
|
||||
// Get client IP
|
||||
console.log('Request from:', req.ip)
|
||||
|
||||
res.json({ page, limit })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Body Parsing
|
||||
|
||||
The request body is automatically parsed based on `Content-Type`:
|
||||
|
||||
- `application/json` - Parsed as JSON object
|
||||
- `application/x-www-form-urlencoded` - Parsed as key-value object
|
||||
- Others - Kept as raw string
|
||||
|
||||
```typescript
|
||||
export default defineHttp<{ name: string; age: number }>({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
// body is already parsed
|
||||
const { name, age } = req.body as { name: string; age: number }
|
||||
res.json({ received: { name, age } })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## HttpResponse Object
|
||||
|
||||
The HTTP response object provides a chainable API:
|
||||
|
||||
```typescript
|
||||
interface HttpResponse {
|
||||
/** Raw Node.js ServerResponse */
|
||||
raw: ServerResponse
|
||||
|
||||
/** Set status code */
|
||||
status(code: number): HttpResponse
|
||||
|
||||
/** Set response header */
|
||||
header(name: string, value: string): HttpResponse
|
||||
|
||||
/** Send JSON response */
|
||||
json(data: unknown): void
|
||||
|
||||
/** Send text response */
|
||||
text(data: string): void
|
||||
|
||||
/** Send error response */
|
||||
error(code: number, message: string): void
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
// Set status code and custom headers
|
||||
res
|
||||
.status(201)
|
||||
.header('X-Custom-Header', 'value')
|
||||
.json({ created: true })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// Send error response
|
||||
res.error(404, 'Resource not found')
|
||||
// Equivalent to: res.status(404).json({ error: 'Resource not found' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// Send plain text
|
||||
res.text('Hello, World!')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## File Routing Conventions
|
||||
|
||||
### Name Conversion
|
||||
|
||||
File names are automatically converted to route paths:
|
||||
|
||||
| File Path | Route Path (prefix=/api) |
|
||||
|-----------|-------------------------|
|
||||
| `login.ts` | `/api/login` |
|
||||
| `users/profile.ts` | `/api/users/profile` |
|
||||
| `users/[id].ts` | `/api/users/:id` |
|
||||
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
|
||||
|
||||
### Dynamic Route Parameters
|
||||
|
||||
Use `[param]` syntax to define dynamic parameters:
|
||||
|
||||
```typescript
|
||||
// src/http/users/[id].ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// Get route parameter directly from params
|
||||
const { id } = req.params
|
||||
res.json({ userId: id })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Multiple parameters:
|
||||
|
||||
```typescript
|
||||
// src/http/users/[userId]/posts/[postId].ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
const { userId, postId } = req.params
|
||||
res.json({ userId, postId })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Skip Rules
|
||||
|
||||
The following files are automatically skipped:
|
||||
|
||||
- Files starting with `_` (e.g., `_helper.ts`)
|
||||
- `index.ts` / `index.js` files
|
||||
- Non `.ts` / `.js` / `.mts` / `.mjs` files
|
||||
|
||||
### Directory Structure Example
|
||||
|
||||
```
|
||||
src/
|
||||
└── http/
|
||||
├── _utils.ts # Skipped (underscore prefix)
|
||||
├── index.ts # Skipped (index file)
|
||||
├── health.ts # GET /api/health
|
||||
├── login.ts # POST /api/login
|
||||
├── register.ts # POST /api/register
|
||||
└── users/
|
||||
├── index.ts # Skipped
|
||||
├── list.ts # GET /api/users/list
|
||||
└── [id].ts # GET /api/users/:id
|
||||
```
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
### Quick Enable
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
cors: true // Use default configuration
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
cors: {
|
||||
// Allowed origins
|
||||
origin: ['http://localhost:5173', 'https://myapp.com'],
|
||||
// Or use wildcard
|
||||
// origin: '*',
|
||||
// origin: true, // Reflect request origin
|
||||
|
||||
// Allowed HTTP methods
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
|
||||
// Allowed request headers
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
|
||||
// Allow credentials (cookies)
|
||||
credentials: true,
|
||||
|
||||
// Preflight cache max age (seconds)
|
||||
maxAge: 86400
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### CorsOptions Type
|
||||
|
||||
```typescript
|
||||
interface CorsOptions {
|
||||
/** Allowed origins: string, string array, true (reflect) or '*' */
|
||||
origin?: string | string[] | boolean
|
||||
|
||||
/** Allowed HTTP methods */
|
||||
methods?: string[]
|
||||
|
||||
/** Allowed request headers */
|
||||
allowedHeaders?: string[]
|
||||
|
||||
/** Allow credentials */
|
||||
credentials?: boolean
|
||||
|
||||
/** Preflight cache max age (seconds) */
|
||||
maxAge?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Route Merging
|
||||
|
||||
File routes and inline routes can be used together, with inline routes having higher priority:
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http',
|
||||
httpPrefix: '/api',
|
||||
|
||||
// Inline routes merge with file routes
|
||||
http: {
|
||||
'/health': (req, res) => res.json({ status: 'ok' }),
|
||||
'/api/special': (req, res) => res.json({ special: true })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Sharing Port with WebSocket
|
||||
|
||||
HTTP routes automatically share the same port with WebSocket services:
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
// WebSocket related config
|
||||
apiDir: './src/api',
|
||||
msgDir: './src/msg',
|
||||
|
||||
// HTTP related config
|
||||
httpDir: './src/http',
|
||||
httpPrefix: '/api',
|
||||
cors: true
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
// Same port 3000:
|
||||
// - WebSocket: ws://localhost:3000
|
||||
// - HTTP API: http://localhost:3000/api/*
|
||||
```
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Game Server Login API
|
||||
|
||||
```typescript
|
||||
// src/http/auth/login.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string
|
||||
userId: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600
|
||||
})
|
||||
|
||||
export default defineHttp<LoginRequest>({
|
||||
method: 'POST',
|
||||
async handler(req, res) {
|
||||
const { username, password } = req.body as LoginRequest
|
||||
|
||||
// Validate user
|
||||
const user = await db.users.findByUsername(username)
|
||||
if (!user || !await verifyPassword(password, user.passwordHash)) {
|
||||
res.error(401, 'Invalid username or password')
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.username,
|
||||
roles: user.roles
|
||||
})
|
||||
|
||||
const response: LoginResponse = {
|
||||
token,
|
||||
userId: user.id,
|
||||
expiresAt: Date.now() + 3600 * 1000
|
||||
}
|
||||
|
||||
res.json(response)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Game Data Query API
|
||||
|
||||
```typescript
|
||||
// src/http/game/leaderboard.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
async handler(req, res) {
|
||||
const limit = parseInt(req.query.limit ?? '10')
|
||||
const offset = parseInt(req.query.offset ?? '0')
|
||||
|
||||
const players = await db.players.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
|
||||
res.json({
|
||||
data: players,
|
||||
pagination: { limit, offset }
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
### Middleware Type
|
||||
|
||||
Middleware are functions that execute before and after route handlers:
|
||||
|
||||
```typescript
|
||||
type HttpMiddleware = (
|
||||
req: HttpRequest,
|
||||
res: HttpResponse,
|
||||
next: () => Promise<void>
|
||||
) => void | Promise<void>
|
||||
```
|
||||
|
||||
### Built-in Middleware
|
||||
|
||||
```typescript
|
||||
import {
|
||||
requestLogger,
|
||||
bodyLimit,
|
||||
responseTime,
|
||||
requestId,
|
||||
securityHeaders
|
||||
} from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
http: { /* ... */ },
|
||||
// Global middleware configured via createHttpRouter
|
||||
})
|
||||
```
|
||||
|
||||
#### requestLogger - Request Logging
|
||||
|
||||
```typescript
|
||||
import { requestLogger } from '@esengine/server'
|
||||
|
||||
// Log request and response time
|
||||
requestLogger()
|
||||
|
||||
// Also log request body
|
||||
requestLogger({ logBody: true })
|
||||
```
|
||||
|
||||
#### bodyLimit - Request Body Size Limit
|
||||
|
||||
```typescript
|
||||
import { bodyLimit } from '@esengine/server'
|
||||
|
||||
// Limit request body to 1MB
|
||||
bodyLimit(1024 * 1024)
|
||||
```
|
||||
|
||||
#### responseTime - Response Time Header
|
||||
|
||||
```typescript
|
||||
import { responseTime } from '@esengine/server'
|
||||
|
||||
// Automatically add X-Response-Time header
|
||||
responseTime()
|
||||
```
|
||||
|
||||
#### requestId - Request ID
|
||||
|
||||
```typescript
|
||||
import { requestId } from '@esengine/server'
|
||||
|
||||
// Auto-generate and add X-Request-ID header
|
||||
requestId()
|
||||
|
||||
// Custom header name
|
||||
requestId('X-Trace-ID')
|
||||
```
|
||||
|
||||
#### securityHeaders - Security Headers
|
||||
|
||||
```typescript
|
||||
import { securityHeaders } from '@esengine/server'
|
||||
|
||||
// Add common security response headers
|
||||
securityHeaders()
|
||||
|
||||
// Custom configuration
|
||||
securityHeaders({
|
||||
hidePoweredBy: true,
|
||||
frameOptions: 'DENY',
|
||||
noSniff: true
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Middleware
|
||||
|
||||
```typescript
|
||||
import type { HttpMiddleware } from '@esengine/server'
|
||||
|
||||
// Authentication middleware
|
||||
const authMiddleware: HttpMiddleware = async (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
res.error(401, 'Unauthorized')
|
||||
return // Don't call next(), terminate request
|
||||
}
|
||||
|
||||
// Validate token...
|
||||
(req as any).userId = 'decoded-user-id'
|
||||
|
||||
await next() // Continue to next middleware and handler
|
||||
}
|
||||
```
|
||||
|
||||
### Using Middleware
|
||||
|
||||
#### With createHttpRouter
|
||||
|
||||
```typescript
|
||||
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
|
||||
|
||||
const router = createHttpRouter({
|
||||
'/api/users': (req, res) => res.json([]),
|
||||
'/api/admin': {
|
||||
GET: {
|
||||
handler: (req, res) => res.json({ admin: true }),
|
||||
middlewares: [adminAuthMiddleware] // Route-level middleware
|
||||
}
|
||||
}
|
||||
}, {
|
||||
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // Global middleware
|
||||
timeout: 30000 // Global timeout 30 seconds
|
||||
})
|
||||
```
|
||||
|
||||
## Request Timeout
|
||||
|
||||
### Global Timeout
|
||||
|
||||
```typescript
|
||||
import { createHttpRouter } from '@esengine/server'
|
||||
|
||||
const router = createHttpRouter({
|
||||
'/api/data': async (req, res) => {
|
||||
// If processing exceeds 30 seconds, auto-return 408 Request Timeout
|
||||
await someSlowOperation()
|
||||
res.json({ data: 'result' })
|
||||
}
|
||||
}, {
|
||||
timeout: 30000 // 30 seconds
|
||||
})
|
||||
```
|
||||
|
||||
### Route-level Timeout
|
||||
|
||||
```typescript
|
||||
const router = createHttpRouter({
|
||||
'/api/quick': (req, res) => res.json({ fast: true }),
|
||||
|
||||
'/api/slow': {
|
||||
POST: {
|
||||
handler: async (req, res) => {
|
||||
await verySlowOperation()
|
||||
res.json({ done: true })
|
||||
},
|
||||
timeout: 120000 // This route allows 2 minutes
|
||||
}
|
||||
}
|
||||
}, {
|
||||
timeout: 10000 // Global 10 seconds (overridden by route-level)
|
||||
})
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use defineHttp** - Get better type hints and code organization
|
||||
2. **Unified Error Handling** - Use `res.error()` to return consistent error format
|
||||
3. **Enable CORS** - Required for frontend-backend separation
|
||||
4. **Directory Organization** - Organize HTTP route files by functional modules
|
||||
5. **Validate Input** - Always validate `req.body` and `req.query` content
|
||||
6. **Status Code Standards** - Follow HTTP status code conventions (200, 201, 400, 401, 404, 500, etc.)
|
||||
7. **Use Middleware** - Implement cross-cutting concerns like auth, logging, rate limiting via middleware
|
||||
8. **Set Timeouts** - Prevent slow requests from blocking the server
|
||||
@@ -147,7 +147,11 @@ service.on('chat', (data) => {
|
||||
|
||||
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
|
||||
- [Server Side](/en/modules/network/server/) - GameServer and Room management
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation, prediction and snapshots
|
||||
- [Distributed Rooms](/en/modules/network/distributed/) - Multi-server room management and player routing
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
|
||||
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
|
||||
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization
|
||||
- [Delta Compression](/en/modules/network/delta/) - State delta synchronization
|
||||
- [API Reference](/en/modules/network/api/) - Complete API documentation
|
||||
|
||||
## Service Tokens
|
||||
@@ -159,10 +163,14 @@ import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from '@esengine/network';
|
||||
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
396
docs/src/content/docs/en/modules/network/prediction.md
Normal file
396
docs/src/content/docs/en/modules/network/prediction.md
Normal file
@@ -0,0 +1,396 @@
|
||||
---
|
||||
title: "Client Prediction"
|
||||
description: "Local input prediction and server reconciliation"
|
||||
---
|
||||
|
||||
Client prediction is a key technique in networked games to reduce input latency. By immediately applying player inputs locally while waiting for server confirmation, games feel more responsive.
|
||||
|
||||
## NetworkPredictionSystem
|
||||
|
||||
`NetworkPredictionSystem` is an ECS system dedicated to handling local player prediction.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enablePrediction: true,
|
||||
predictionConfig: {
|
||||
moveSpeed: 200, // Movement speed (units/second)
|
||||
maxUnacknowledgedInputs: 60, // Max unacknowledged inputs
|
||||
reconciliationThreshold: 0.5, // Reconciliation threshold
|
||||
reconciliationSpeed: 10, // Reconciliation speed
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### Setting Up Local Player
|
||||
|
||||
After the local player entity spawns, set its network ID:
|
||||
|
||||
```typescript
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
|
||||
identity.bIsLocalPlayer = identity.bHasAuthority;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
|
||||
// Set local player for prediction
|
||||
if (identity.bIsLocalPlayer) {
|
||||
networkPlugin.setLocalPlayerNetId(spawn.netId);
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
```
|
||||
|
||||
### Sending Input
|
||||
|
||||
```typescript
|
||||
// Send movement input in game loop
|
||||
function onUpdate() {
|
||||
const moveX = Input.getAxis('horizontal');
|
||||
const moveY = Input.getAxis('vertical');
|
||||
|
||||
if (moveX !== 0 || moveY !== 0) {
|
||||
networkPlugin.sendMoveInput(moveX, moveY);
|
||||
}
|
||||
|
||||
// Send action input
|
||||
if (Input.isPressed('attack')) {
|
||||
networkPlugin.sendActionInput('attack');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prediction Configuration
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `moveSpeed` | `number` | 200 | Movement speed (units/second) |
|
||||
| `enabled` | `boolean` | true | Whether prediction is enabled |
|
||||
| `maxUnacknowledgedInputs` | `number` | 60 | Max unacknowledged inputs |
|
||||
| `reconciliationThreshold` | `number` | 0.5 | Position difference threshold for reconciliation |
|
||||
| `reconciliationSpeed` | `number` | 10 | Reconciliation smoothing speed |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
├─ 1. Capture input (seq=1) │
|
||||
├─ 2. Predict movement locally │
|
||||
├─ 3. Send input to server ─────────►
|
||||
│ │
|
||||
├─ 4. Continue capturing (seq=2,3...) │
|
||||
├─ 5. Continue predicting │
|
||||
│ │
|
||||
│ ├─ 6. Process input (seq=1)
|
||||
│ │
|
||||
◄──────── 7. Return state (ackSeq=1) ─
|
||||
│ │
|
||||
├─ 8. Compare prediction with server │
|
||||
├─ 9. Replay inputs seq=2,3... │
|
||||
├─ 10. Smooth correction │
|
||||
│ │
|
||||
```
|
||||
|
||||
### Step by Step
|
||||
|
||||
1. **Input Capture**: Capture player input and assign sequence number
|
||||
2. **Local Prediction**: Immediately apply input to local state
|
||||
3. **Send Input**: Send input to server
|
||||
4. **Cache Input**: Save input for later reconciliation
|
||||
5. **Receive Acknowledgment**: Server returns authoritative state with ack sequence
|
||||
6. **State Comparison**: Compare predicted state with server state
|
||||
7. **Input Replay**: Recalculate state using cached unacknowledged inputs
|
||||
8. **Smooth Correction**: Interpolate smoothly to correct position
|
||||
|
||||
## Low-Level API
|
||||
|
||||
For fine-grained control, use the `ClientPrediction` class directly:
|
||||
|
||||
```typescript
|
||||
import { createClientPrediction, type IPredictor } from '@esengine/network';
|
||||
|
||||
// Define state type
|
||||
interface PlayerState {
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
// Define input type
|
||||
interface PlayerInput {
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
// Define predictor
|
||||
const predictor: IPredictor<PlayerState, PlayerInput> = {
|
||||
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * dt,
|
||||
y: state.y + input.dy * MOVE_SPEED * dt,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Create client prediction
|
||||
const prediction = createClientPrediction(predictor, {
|
||||
maxUnacknowledgedInputs: 60,
|
||||
reconciliationThreshold: 0.5,
|
||||
reconciliationSpeed: 10,
|
||||
});
|
||||
|
||||
// Record input and get predicted state
|
||||
const input = { dx: 1, dy: 0 };
|
||||
const predictedState = prediction.recordInput(input, currentState, deltaTime);
|
||||
|
||||
// Get input to send
|
||||
const inputToSend = prediction.getInputToSend();
|
||||
|
||||
// Reconcile with server state
|
||||
prediction.reconcile(
|
||||
serverState,
|
||||
serverAckSeq,
|
||||
(state) => ({ x: state.x, y: state.y }),
|
||||
deltaTime
|
||||
);
|
||||
|
||||
// Get correction offset
|
||||
const offset = prediction.correctionOffset;
|
||||
```
|
||||
|
||||
## Enable/Disable Prediction
|
||||
|
||||
```typescript
|
||||
// Toggle prediction at runtime
|
||||
networkPlugin.setPredictionEnabled(false);
|
||||
|
||||
// Check prediction status
|
||||
if (networkPlugin.isPredictionEnabled) {
|
||||
console.log('Prediction is active');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Set Appropriate Reconciliation Threshold
|
||||
|
||||
```typescript
|
||||
// Action games: lower threshold, more precise
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 0.1,
|
||||
}
|
||||
|
||||
// Casual games: higher threshold, smoother
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 1.0,
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Prediction Only for Local Player
|
||||
|
||||
Remote players should use interpolation, not prediction:
|
||||
|
||||
```typescript
|
||||
const identity = entity.getComponent(NetworkIdentity);
|
||||
|
||||
if (identity.bIsLocalPlayer) {
|
||||
// Use prediction system
|
||||
} else {
|
||||
// Use NetworkSyncSystem interpolation
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Handle High Latency
|
||||
|
||||
```typescript
|
||||
// High latency network: increase buffer
|
||||
predictionConfig: {
|
||||
maxUnacknowledgedInputs: 120, // Increase buffer
|
||||
reconciliationSpeed: 5, // Slower correction
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Deterministic Prediction
|
||||
|
||||
Ensure client and server use the same physics calculations:
|
||||
|
||||
```typescript
|
||||
// Use fixed timestep
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
|
||||
// Use fixed timestep instead of actual deltaTime
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
|
||||
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
// Get prediction system instance
|
||||
const predictionSystem = networkPlugin.predictionSystem;
|
||||
|
||||
if (predictionSystem) {
|
||||
console.log('Pending inputs:', predictionSystem.pendingInputCount);
|
||||
console.log('Current sequence:', predictionSystem.inputSequence);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fixed-Point Client Prediction (Lockstep)
|
||||
|
||||
Deterministic client prediction for **Lockstep** architecture.
|
||||
|
||||
> See [Fixed-Point Numbers](/en/modules/network/fixed-point) for math basics
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
FixedClientPrediction,
|
||||
createFixedClientPrediction,
|
||||
type IFixedPredictor,
|
||||
type IFixedStatePositionExtractor
|
||||
} from '@esengine/network';
|
||||
import { Fixed32, FixedVector2 } from '@esengine/ecs-framework-math';
|
||||
|
||||
// Define game state
|
||||
interface GameState {
|
||||
position: FixedVector2;
|
||||
velocity: FixedVector2;
|
||||
}
|
||||
|
||||
// Implement predictor (must use fixed-point arithmetic)
|
||||
const predictor: IFixedPredictor<GameState, PlayerInput> = {
|
||||
predict(state: GameState, input: PlayerInput, deltaTime: Fixed32): GameState {
|
||||
const speed = Fixed32.from(100);
|
||||
const inputVec = FixedVector2.from(input.dx, input.dy);
|
||||
const velocity = inputVec.normalize().mul(speed);
|
||||
const displacement = velocity.mul(deltaTime);
|
||||
|
||||
return {
|
||||
position: state.position.add(displacement),
|
||||
velocity
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Create prediction
|
||||
const prediction = createFixedClientPrediction(predictor, {
|
||||
maxUnacknowledgedInputs: 60,
|
||||
fixedDeltaTime: Fixed32.from(1 / 60),
|
||||
reconciliationThreshold: Fixed32.from(0.001),
|
||||
enableSmoothReconciliation: false // Usually disabled for lockstep
|
||||
});
|
||||
```
|
||||
|
||||
### Record Input
|
||||
|
||||
```typescript
|
||||
function onUpdate(input: PlayerInput, currentState: GameState) {
|
||||
// Record input and get predicted state
|
||||
const predicted = prediction.recordInput(input, currentState);
|
||||
|
||||
// Render predicted state
|
||||
const pos = predicted.position.toObject();
|
||||
sprite.position.set(pos.x, pos.y);
|
||||
|
||||
// Send input
|
||||
socket.send(JSON.stringify({
|
||||
frame: prediction.currentFrame,
|
||||
input
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Server Reconciliation
|
||||
|
||||
```typescript
|
||||
// Position extractor
|
||||
const posExtractor: IFixedStatePositionExtractor<GameState> = {
|
||||
getPosition(state: GameState): FixedVector2 {
|
||||
return state.position;
|
||||
}
|
||||
};
|
||||
|
||||
// When receiving server state
|
||||
function onServerState(serverState: GameState, serverFrame: number) {
|
||||
const reconciled = prediction.reconcile(
|
||||
serverState,
|
||||
serverFrame,
|
||||
posExtractor
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Rollback and Replay
|
||||
|
||||
```typescript
|
||||
// Rollback when desync detected
|
||||
const correctedState = prediction.rollbackAndResimulate(
|
||||
serverFrame,
|
||||
authoritativeState
|
||||
);
|
||||
|
||||
// View historical state
|
||||
const historicalState = prediction.getStateAtFrame(100);
|
||||
```
|
||||
|
||||
### Preset Movement Predictor
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createFixedMovementPredictor,
|
||||
createFixedMovementPositionExtractor,
|
||||
type IFixedMovementInput,
|
||||
type IFixedMovementState
|
||||
} from '@esengine/network';
|
||||
|
||||
// Create movement predictor (speed 100 units/sec)
|
||||
const movePredictor = createFixedMovementPredictor(Fixed32.from(100));
|
||||
const posExtractor = createFixedMovementPositionExtractor();
|
||||
|
||||
const prediction = createFixedClientPrediction<IFixedMovementState, IFixedMovementInput>(
|
||||
movePredictor,
|
||||
{ fixedDeltaTime: Fixed32.from(1 / 60) }
|
||||
);
|
||||
|
||||
// Input format
|
||||
const input: IFixedMovementInput = { dx: 1, dy: 0 };
|
||||
```
|
||||
|
||||
### API Exports
|
||||
|
||||
```typescript
|
||||
import {
|
||||
FixedClientPrediction,
|
||||
createFixedClientPrediction,
|
||||
createFixedMovementPredictor,
|
||||
createFixedMovementPositionExtractor,
|
||||
type IFixedInputSnapshot,
|
||||
type IFixedPredictedState,
|
||||
type IFixedPredictor,
|
||||
type IFixedStatePositionExtractor,
|
||||
type FixedClientPredictionConfig,
|
||||
type IFixedMovementInput,
|
||||
type IFixedMovementState
|
||||
} from '@esengine/network';
|
||||
```
|
||||
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: "Rate Limiting"
|
||||
description: "Protect your game server from abuse with configurable rate limiting"
|
||||
---
|
||||
|
||||
The `@esengine/server` package includes a pluggable rate limiting system to protect against DDoS attacks, message flooding, and other abuse.
|
||||
|
||||
## Installation
|
||||
|
||||
Rate limiting is included in the server package:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage } from '@esengine/server'
|
||||
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20,
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
}) {
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: Player) {
|
||||
// Protected by rate limit (10 msg/s default)
|
||||
}
|
||||
|
||||
@rateLimit({ messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) {
|
||||
// Stricter limit for trading
|
||||
}
|
||||
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
// No rate limit for heartbeat
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit Strategies
|
||||
|
||||
### Token Bucket (Default)
|
||||
|
||||
The token bucket algorithm allows burst traffic while maintaining long-term rate limits. Tokens are added at a fixed rate, and each request consumes tokens.
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'token-bucket',
|
||||
messagesPerSecond: 10, // Refill rate
|
||||
burstSize: 20, // Bucket capacity
|
||||
}) { }
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
```
|
||||
Config: rate=10/s, burstSize=20
|
||||
|
||||
[0s] Bucket full: 20 tokens
|
||||
[0s] 15 messages → allowed, 5 remaining
|
||||
[0.5s] Refill 5 tokens → 10 tokens
|
||||
[0.5s] 8 messages → allowed, 2 remaining
|
||||
[0.6s] Refill 1 token → 3 tokens
|
||||
[0.6s] 5 messages → 3 allowed, 2 rejected
|
||||
```
|
||||
|
||||
**Best for:** Most general use cases, balances burst tolerance with protection.
|
||||
|
||||
### Sliding Window
|
||||
|
||||
The sliding window algorithm precisely tracks requests within a time window. More accurate than fixed window but uses slightly more memory.
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'sliding-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**Best for:** When you need precise rate limiting without burst tolerance.
|
||||
|
||||
### Fixed Window
|
||||
|
||||
The fixed window algorithm divides time into fixed intervals and counts requests per interval. Simple and memory-efficient but allows 2x burst at window boundaries.
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'fixed-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**Best for:** Simple scenarios where boundary burst is acceptable.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Room Configuration
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
// Messages allowed per second (default: 10)
|
||||
messagesPerSecond: 10,
|
||||
|
||||
// Burst capacity / bucket size (default: 20)
|
||||
burstSize: 20,
|
||||
|
||||
// Strategy: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// Callback when rate limited
|
||||
onLimited: (player, messageType, result) => {
|
||||
player.send('RateLimited', {
|
||||
type: messageType,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// Disconnect on rate limit (default: false)
|
||||
disconnectOnLimit: false,
|
||||
|
||||
// Disconnect after N consecutive limits (0 = never)
|
||||
maxConsecutiveLimits: 10,
|
||||
|
||||
// Custom key function (default: player.id)
|
||||
getKey: (player) => player.id,
|
||||
|
||||
// Cleanup interval in ms (default: 60000)
|
||||
cleanupInterval: 60000,
|
||||
}) { }
|
||||
```
|
||||
|
||||
### Per-Message Configuration
|
||||
|
||||
Use decorators to configure rate limits for specific messages:
|
||||
|
||||
```typescript
|
||||
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
// Custom rate limit for this message
|
||||
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) { }
|
||||
|
||||
// This message costs 5 tokens
|
||||
@rateLimit({ cost: 5 })
|
||||
@onMessage('ExpensiveAction')
|
||||
handleExpensive(data: any, player: Player) { }
|
||||
|
||||
// Exempt from rate limiting
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) { }
|
||||
|
||||
// Alternative: specify message type explicitly
|
||||
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||
@onMessage('SpecialAction')
|
||||
handleSpecial(data: any, player: Player) { }
|
||||
}
|
||||
```
|
||||
|
||||
## Combining with Authentication
|
||||
|
||||
Rate limiting works seamlessly with the authentication system:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth } from '@esengine/server/auth'
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
// Apply both mixins
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth(Room, { requireAuth: true }),
|
||||
{ messagesPerSecond: 10 }
|
||||
) {
|
||||
onJoin(player: AuthPlayer) {
|
||||
console.log(`${player.user?.name} joined with rate limit protection`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit Result
|
||||
|
||||
When a message is rate limited, the callback receives a result object:
|
||||
|
||||
```typescript
|
||||
interface RateLimitResult {
|
||||
// Whether the request was allowed
|
||||
allowed: boolean
|
||||
|
||||
// Remaining quota
|
||||
remaining: number
|
||||
|
||||
// When the quota resets (timestamp)
|
||||
resetAt: number
|
||||
|
||||
// How long to wait before retrying (ms)
|
||||
retryAfter?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Accessing Rate Limit Context
|
||||
|
||||
You can access the rate limit context for any player:
|
||||
|
||||
```typescript
|
||||
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
someMethod(player: Player) {
|
||||
const context = this.getRateLimitContext(player)
|
||||
|
||||
// Check without consuming
|
||||
const status = context?.check()
|
||||
console.log(`Remaining: ${status?.remaining}`)
|
||||
|
||||
// Get consecutive limit count
|
||||
console.log(`Consecutive limits: ${context?.consecutiveLimitCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Or use the standalone function
|
||||
const context = getPlayerRateLimitContext(player)
|
||||
```
|
||||
|
||||
## Custom Strategies
|
||||
|
||||
You can use the strategies directly for custom implementations:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createTokenBucketStrategy,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
// Create strategy directly
|
||||
const strategy = createTokenBucketStrategy({
|
||||
rate: 10, // tokens per second
|
||||
capacity: 20, // max tokens
|
||||
})
|
||||
|
||||
// Check and consume
|
||||
const result = strategy.consume('player-123')
|
||||
if (result.allowed) {
|
||||
// Process message
|
||||
} else {
|
||||
// Rate limited, wait result.retryAfter ms
|
||||
}
|
||||
|
||||
// Check without consuming
|
||||
const status = strategy.getStatus('player-123')
|
||||
|
||||
// Reset a key
|
||||
strategy.reset('player-123')
|
||||
|
||||
// Cleanup expired records
|
||||
strategy.cleanup()
|
||||
```
|
||||
|
||||
## Rate Limit Context
|
||||
|
||||
The `RateLimitContext` class manages rate limiting for a single player:
|
||||
|
||||
```typescript
|
||||
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||
|
||||
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||
const context = new RateLimitContext('player-123', strategy)
|
||||
|
||||
// Check without consuming
|
||||
context.check()
|
||||
|
||||
// Consume quota
|
||||
context.consume()
|
||||
|
||||
// Consume with cost
|
||||
context.consume(undefined, 5)
|
||||
|
||||
// Consume for specific message type
|
||||
context.consume('Trade')
|
||||
|
||||
// Set per-message strategy
|
||||
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||
|
||||
// Reset
|
||||
context.reset()
|
||||
|
||||
// Get consecutive limit count
|
||||
console.log(context.consecutiveLimitCount)
|
||||
```
|
||||
|
||||
## Room Lifecycle Hook
|
||||
|
||||
You can override the `onRateLimited` hook for custom handling:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
// Log the event
|
||||
console.log(`Player ${player.id} rate limited on ${messageType}`)
|
||||
|
||||
// Send custom error
|
||||
player.send('SystemMessage', {
|
||||
type: 'warning',
|
||||
message: `Slow down! Try again in ${result.retryAfter}ms`,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with token bucket**: It's the most flexible algorithm for games.
|
||||
|
||||
2. **Set appropriate limits**: Consider your game's mechanics:
|
||||
- Movement messages: Higher limits (20-60/s)
|
||||
- Chat messages: Lower limits (1-5/s)
|
||||
- Trade/purchase: Very low limits (0.5-1/s)
|
||||
|
||||
3. **Use burst capacity**: Allow short bursts for responsive gameplay:
|
||||
```typescript
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30, // Allow 3s worth of burst
|
||||
```
|
||||
|
||||
4. **Exempt critical messages**: Don't rate limit heartbeats or system messages:
|
||||
```typescript
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() { }
|
||||
```
|
||||
|
||||
5. **Combine with auth**: Rate limit by user ID for authenticated users:
|
||||
```typescript
|
||||
getKey: (player) => player.auth?.userId ?? player.id
|
||||
```
|
||||
|
||||
6. **Monitor and adjust**: Log rate limit events to tune your limits:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
metrics.increment('rate_limit', { messageType: type })
|
||||
}
|
||||
```
|
||||
|
||||
7. **Graceful degradation**: Send informative errors instead of just disconnecting:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'Too many requests',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage, type Player } from '@esengine/server'
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
import {
|
||||
withRateLimit,
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
type RateLimitResult,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
premium: boolean
|
||||
}
|
||||
|
||||
// Combine auth and rate limit
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||
{
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30,
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// Use user ID for rate limiting
|
||||
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||
|
||||
// Handle rate limits
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
messageType: type,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// Disconnect after 20 consecutive rate limits
|
||||
maxConsecutiveLimits: 20,
|
||||
}
|
||||
) {
|
||||
onCreate() {
|
||||
console.log('Room created with auth + rate limit protection')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||
}
|
||||
|
||||
// High-frequency movement (default rate limit)
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||
}
|
||||
|
||||
// Low-frequency trading (strict limit)
|
||||
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||
// Process trade...
|
||||
}
|
||||
|
||||
// Chat with moderate limit
|
||||
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
|
||||
// System messages - no limit
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
player.send('Pong', { time: Date.now() })
|
||||
}
|
||||
|
||||
// Custom rate limit handling
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
console.warn(`[RateLimit] Player ${player.id} limited on ${messageType}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -79,10 +79,33 @@ await server.start()
|
||||
| `tickRate` | `number` | `20` | Global tick rate (Hz) |
|
||||
| `apiDir` | `string` | `'src/api'` | API handlers directory |
|
||||
| `msgDir` | `string` | `'src/msg'` | Message handlers directory |
|
||||
| `httpDir` | `string` | `'src/http'` | HTTP routes directory |
|
||||
| `httpPrefix` | `string` | `'/api'` | HTTP routes prefix |
|
||||
| `cors` | `boolean \| CorsOptions` | - | CORS configuration |
|
||||
| `onStart` | `(port) => void` | - | Start callback |
|
||||
| `onConnect` | `(conn) => void` | - | Connection callback |
|
||||
| `onDisconnect` | `(conn) => void` | - | Disconnect callback |
|
||||
|
||||
## HTTP Routing
|
||||
|
||||
Supports HTTP API sharing the same port with WebSocket, ideal for login, registration, and similar scenarios.
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http', // HTTP routes directory
|
||||
httpPrefix: '/api', // Route prefix
|
||||
cors: true,
|
||||
|
||||
// Or inline definition
|
||||
http: {
|
||||
'/health': (req, res) => res.json({ status: 'ok' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
> For detailed documentation, see [HTTP Routing](/en/modules/network/http)
|
||||
|
||||
## Room System
|
||||
|
||||
Room is the base class for game rooms, managing players and game state.
|
||||
@@ -243,6 +266,122 @@ class GameRoom extends Room {
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Validation
|
||||
|
||||
Use the built-in Schema validation system for runtime type validation:
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { s, defineApiWithSchema } from '@esengine/server'
|
||||
|
||||
// Define schema
|
||||
const MoveSchema = s.object({
|
||||
x: s.number(),
|
||||
y: s.number(),
|
||||
speed: s.number().optional()
|
||||
})
|
||||
|
||||
// Auto type inference
|
||||
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
|
||||
|
||||
// Use schema to define API (auto validation)
|
||||
export default defineApiWithSchema(MoveSchema, {
|
||||
handler(req, ctx) {
|
||||
// req is validated, type-safe
|
||||
console.log(req.x, req.y)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Validator Types
|
||||
|
||||
| Type | Example | Description |
|
||||
|------|---------|-------------|
|
||||
| `s.string()` | `s.string().min(1).max(50)` | String with length constraints |
|
||||
| `s.number()` | `s.number().min(0).int()` | Number with range and integer constraints |
|
||||
| `s.boolean()` | `s.boolean()` | Boolean |
|
||||
| `s.literal()` | `s.literal('admin')` | Literal type |
|
||||
| `s.object()` | `s.object({ name: s.string() })` | Object |
|
||||
| `s.array()` | `s.array(s.number())` | Array |
|
||||
| `s.enum()` | `s.enum(['a', 'b'] as const)` | Enum |
|
||||
| `s.union()` | `s.union([s.string(), s.number()])` | Union type |
|
||||
| `s.record()` | `s.record(s.any())` | Record type |
|
||||
|
||||
### Modifiers
|
||||
|
||||
```typescript
|
||||
// Optional field
|
||||
s.string().optional()
|
||||
|
||||
// Default value
|
||||
s.number().default(0)
|
||||
|
||||
// Nullable
|
||||
s.string().nullable()
|
||||
|
||||
// String validation
|
||||
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
|
||||
|
||||
// Number validation
|
||||
s.number().min(0).max(100).int().positive()
|
||||
|
||||
// Array validation
|
||||
s.array(s.string()).min(1).max(10).nonempty()
|
||||
|
||||
// Object validation
|
||||
s.object({ ... }).strict() // No extra fields allowed
|
||||
s.object({ ... }).partial() // All fields optional
|
||||
s.object({ ... }).pick('name', 'age') // Pick fields
|
||||
s.object({ ... }).omit('password') // Omit fields
|
||||
```
|
||||
|
||||
### Message Validation
|
||||
|
||||
```typescript
|
||||
import { s, defineMsgWithSchema } from '@esengine/server'
|
||||
|
||||
const InputSchema = s.object({
|
||||
keys: s.array(s.string()),
|
||||
timestamp: s.number()
|
||||
})
|
||||
|
||||
export default defineMsgWithSchema(InputSchema, {
|
||||
handler(msg, ctx) {
|
||||
// msg is validated
|
||||
console.log(msg.keys, msg.timestamp)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Manual Validation
|
||||
|
||||
```typescript
|
||||
import { s, parse, safeParse, createGuard } from '@esengine/server'
|
||||
|
||||
const UserSchema = s.object({
|
||||
name: s.string(),
|
||||
age: s.number().int().min(0)
|
||||
})
|
||||
|
||||
// Throws on error
|
||||
const user = parse(UserSchema, data)
|
||||
|
||||
// Returns result object
|
||||
const result = safeParse(UserSchema, data)
|
||||
if (result.success) {
|
||||
console.log(result.data)
|
||||
} else {
|
||||
console.error(result.error)
|
||||
}
|
||||
|
||||
// Type guard
|
||||
const isUser = createGuard(UserSchema)
|
||||
if (isUser(data)) {
|
||||
// data is User type
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Definition
|
||||
|
||||
Define shared types in `src/shared/protocol.ts`:
|
||||
@@ -311,6 +450,93 @@ client.send('RoomMessage', {
|
||||
})
|
||||
```
|
||||
|
||||
## ECSRoom
|
||||
|
||||
`ECSRoom` is a room base class with ECS World support, suitable for games that need ECS architecture.
|
||||
|
||||
### Server Startup
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { createServer } from '@esengine/server';
|
||||
import { GameRoom } from './rooms/GameRoom.js';
|
||||
|
||||
// Initialize Core
|
||||
Core.create();
|
||||
|
||||
// Global game loop
|
||||
setInterval(() => Core.update(1/60), 16);
|
||||
|
||||
// Create server
|
||||
const server = await createServer({ port: 3000 });
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### Define ECSRoom
|
||||
|
||||
```typescript
|
||||
import { ECSRoom, Player } from '@esengine/server/ecs';
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
// Define sync component
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
}
|
||||
|
||||
// Define room
|
||||
class GameRoom extends ECSRoom {
|
||||
onCreate() {
|
||||
this.addSystem(new MovementSystem());
|
||||
}
|
||||
|
||||
onJoin(player: Player) {
|
||||
const entity = this.createPlayerEntity(player.id);
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = player.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ECSRoom API
|
||||
|
||||
```typescript
|
||||
abstract class ECSRoom<TState, TPlayerData> extends Room<TState, TPlayerData> {
|
||||
protected readonly world: World; // ECS World
|
||||
protected readonly scene: Scene; // Main scene
|
||||
|
||||
// Scene management
|
||||
protected addSystem(system: EntitySystem): void;
|
||||
protected createEntity(name?: string): Entity;
|
||||
protected createPlayerEntity(playerId: string, name?: string): Entity;
|
||||
protected getPlayerEntity(playerId: string): Entity | undefined;
|
||||
protected destroyPlayerEntity(playerId: string): void;
|
||||
|
||||
// State sync
|
||||
protected sendFullState(player: Player): void;
|
||||
protected broadcastSpawn(entity: Entity, prefabType?: string): void;
|
||||
protected broadcastDelta(): void;
|
||||
}
|
||||
```
|
||||
|
||||
### @sync Decorator
|
||||
|
||||
Mark component fields that need network synchronization:
|
||||
|
||||
| Type | Description | Bytes |
|
||||
|------|-------------|-------|
|
||||
| `"boolean"` | Boolean | 1 |
|
||||
| `"int8"` / `"uint8"` | 8-bit integer | 1 |
|
||||
| `"int16"` / `"uint16"` | 16-bit integer | 2 |
|
||||
| `"int32"` / `"uint32"` | 32-bit integer | 4 |
|
||||
| `"float32"` | 32-bit float | 4 |
|
||||
| `"float64"` | 64-bit float | 8 |
|
||||
| `"string"` | String | Variable |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Set Appropriate Tick Rate**
|
||||
|
||||
@@ -1,8 +1,176 @@
|
||||
---
|
||||
title: "State Sync"
|
||||
description: "Interpolation, prediction and snapshot buffers"
|
||||
description: "Component sync, interpolation, prediction and snapshot buffers"
|
||||
---
|
||||
|
||||
## @NetworkEntity Decorator
|
||||
|
||||
The `@NetworkEntity` decorator marks components for automatic spawn/despawn broadcasting. When an entity containing this component is created or destroyed, ECSRoom automatically broadcasts the corresponding message to all clients.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync, NetworkEntity } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Enemy')
|
||||
@NetworkEntity('Enemy')
|
||||
class EnemyComponent extends Component {
|
||||
@sync('float32') x: number = 0;
|
||||
@sync('float32') y: number = 0;
|
||||
@sync('uint16') health: number = 100;
|
||||
}
|
||||
```
|
||||
|
||||
When adding this component to an entity, ECSRoom automatically broadcasts the spawn message:
|
||||
|
||||
```typescript
|
||||
// Server-side
|
||||
const entity = scene.createEntity('Enemy');
|
||||
entity.addComponent(new EnemyComponent()); // Auto-broadcasts spawn
|
||||
|
||||
// Destroying auto-broadcasts despawn
|
||||
entity.destroy(); // Auto-broadcasts despawn
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```typescript
|
||||
@NetworkEntity('Bullet', {
|
||||
autoSpawn: true, // Auto-broadcast spawn (default true)
|
||||
autoDespawn: false // Disable auto-broadcast despawn
|
||||
})
|
||||
class BulletComponent extends Component { }
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `autoSpawn` | `boolean` | `true` | Auto-broadcast spawn when component is added |
|
||||
| `autoDespawn` | `boolean` | `true` | Auto-broadcast despawn when entity is destroyed |
|
||||
|
||||
### Initialization Order
|
||||
|
||||
When using `@NetworkEntity`, initialize data **before** adding the component:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: Initialize first, then add
|
||||
const comp = new PlayerComponent();
|
||||
comp.playerId = player.id;
|
||||
comp.x = 100;
|
||||
comp.y = 200;
|
||||
entity.addComponent(comp); // Data is correct at spawn
|
||||
|
||||
// ❌ Wrong: Add first, then initialize
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.playerId = player.id; // Data has default values at spawn
|
||||
```
|
||||
|
||||
### Simplified GameRoom
|
||||
|
||||
With `@NetworkEntity`, GameRoom becomes much cleaner:
|
||||
|
||||
```typescript
|
||||
// No manual callbacks needed
|
||||
class GameRoom extends ECSRoom {
|
||||
private setupSystems(): void {
|
||||
// Enemy spawn system (auto-broadcasts spawn)
|
||||
this.addSystem(new EnemySpawnSystem());
|
||||
|
||||
// Enemy AI system
|
||||
const enemyAI = new EnemyAISystem();
|
||||
enemyAI.onDeath((enemy) => {
|
||||
enemy.destroy(); // Auto-broadcasts despawn
|
||||
});
|
||||
this.addSystem(enemyAI);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ECSRoom Configuration
|
||||
|
||||
You can disable the auto network entity feature in ECSRoom:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends ECSRoom {
|
||||
constructor() {
|
||||
super({
|
||||
enableAutoNetworkEntity: false // Disable auto-broadcasting
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component Sync System
|
||||
|
||||
ECS component state synchronization based on `@sync` decorator.
|
||||
|
||||
### Define Sync Component
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
|
||||
// Fields without @sync won't be synced
|
||||
localData: any;
|
||||
}
|
||||
```
|
||||
|
||||
### Server-side Encoding
|
||||
|
||||
```typescript
|
||||
import { ComponentSyncSystem } from '@esengine/network';
|
||||
|
||||
const syncSystem = new ComponentSyncSystem({}, true);
|
||||
scene.addSystem(syncSystem);
|
||||
|
||||
// Encode all entities (initial connection)
|
||||
const fullData = syncSystem.encodeAllEntities(true);
|
||||
sendToClient(fullData);
|
||||
|
||||
// Encode delta (only send changes)
|
||||
const deltaData = syncSystem.encodeDelta();
|
||||
if (deltaData) {
|
||||
broadcast(deltaData);
|
||||
}
|
||||
```
|
||||
|
||||
### Client-side Decoding
|
||||
|
||||
```typescript
|
||||
const syncSystem = new ComponentSyncSystem();
|
||||
scene.addSystem(syncSystem);
|
||||
|
||||
// Register component types
|
||||
syncSystem.registerComponent(PlayerComponent);
|
||||
|
||||
// Listen for sync events
|
||||
syncSystem.addSyncListener((event) => {
|
||||
if (event.type === 'entitySpawned') {
|
||||
console.log('New entity:', event.entityId);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply state
|
||||
syncSystem.applySnapshot(data);
|
||||
```
|
||||
|
||||
### Sync Types
|
||||
|
||||
| Type | Description | Bytes |
|
||||
|------|-------------|-------|
|
||||
| `"boolean"` | Boolean | 1 |
|
||||
| `"int8"` / `"uint8"` | 8-bit integer | 1 |
|
||||
| `"int16"` / `"uint16"` | 16-bit integer | 2 |
|
||||
| `"int32"` / `"uint32"` | 32-bit integer | 4 |
|
||||
| `"float32"` | 32-bit float | 4 |
|
||||
| `"float64"` | 64-bit float | 8 |
|
||||
| `"string"` | String | Variable |
|
||||
|
||||
## Snapshot Buffer
|
||||
|
||||
Stores server state snapshots for interpolation:
|
||||
@@ -67,3 +235,139 @@ const corrected = prediction.reconcile(serverState, serverSeq, applyInput);
|
||||
1. **Interpolation delay**: 100-150ms for typical networks
|
||||
2. **Prediction**: Use only for local player, interpolate remote players
|
||||
3. **Snapshot count**: Keep enough snapshots to handle network jitter
|
||||
|
||||
---
|
||||
|
||||
## Fixed-Point Sync (Lockstep)
|
||||
|
||||
For **Lockstep** architecture, use fixed-point numbers to ensure cross-platform determinism.
|
||||
|
||||
> See [Fixed-Point Numbers](/en/modules/network/fixed-point) for math basics
|
||||
|
||||
### FixedTransformState
|
||||
|
||||
Fixed-point transform state for network transmission:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
FixedTransformState,
|
||||
FixedTransformStateWithVelocity,
|
||||
type IFixedTransformStateRaw
|
||||
} from '@esengine/network';
|
||||
|
||||
// Create state
|
||||
const state = FixedTransformState.from(100, 200, Math.PI / 4);
|
||||
|
||||
// Serialize (sender)
|
||||
const raw: IFixedTransformStateRaw = state.toRaw();
|
||||
socket.send(JSON.stringify({ type: 'sync', state: raw }));
|
||||
|
||||
// Deserialize (receiver)
|
||||
const received = FixedTransformState.fromRaw(message.state);
|
||||
|
||||
// Use for rendering
|
||||
const { x, y, rotation } = received.toFloat();
|
||||
sprite.position.set(x, y);
|
||||
```
|
||||
|
||||
State with velocity (for extrapolation):
|
||||
|
||||
```typescript
|
||||
const state = FixedTransformStateWithVelocity.from(
|
||||
100, 200, // position
|
||||
0, // rotation
|
||||
5, 3, // velocity
|
||||
0.1 // angular velocity
|
||||
);
|
||||
```
|
||||
|
||||
### Fixed-Point Interpolators
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createFixedTransformInterpolator,
|
||||
createFixedHermiteTransformInterpolator
|
||||
} from '@esengine/network';
|
||||
import { Fixed32 } from '@esengine/ecs-framework-math';
|
||||
|
||||
// Linear interpolator
|
||||
const interpolator = createFixedTransformInterpolator();
|
||||
|
||||
const from = FixedTransformState.from(0, 0, 0);
|
||||
const to = FixedTransformState.from(100, 50, Math.PI);
|
||||
const t = Fixed32.from(0.5);
|
||||
|
||||
const result = interpolator.interpolate(from, to, t);
|
||||
|
||||
// Hermite interpolator (smoother)
|
||||
const hermite = createFixedHermiteTransformInterpolator(100);
|
||||
```
|
||||
|
||||
### Fixed-Point Snapshot Buffer
|
||||
|
||||
Manages fixed-point state history for lockstep replay:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
FixedSnapshotBuffer,
|
||||
createFixedSnapshotBuffer
|
||||
} from '@esengine/network';
|
||||
|
||||
// Create buffer (max 30 snapshots, 2 frame delay)
|
||||
const buffer = createFixedSnapshotBuffer<FixedTransformState>(30, 2);
|
||||
|
||||
// Add snapshots
|
||||
buffer.push({
|
||||
frame: 100,
|
||||
state: FixedTransformState.from(100, 200, 0)
|
||||
});
|
||||
|
||||
// Get interpolation snapshots
|
||||
const result = buffer.getInterpolationSnapshots(103);
|
||||
if (result) {
|
||||
const { from, to, t } = result;
|
||||
const interpolated = interpolator.interpolate(from.state, to.state, t);
|
||||
}
|
||||
|
||||
// Get latest/specific frame
|
||||
const latest = buffer.getLatest();
|
||||
const atFrame = buffer.getAtFrame(100);
|
||||
|
||||
// Rollback replay
|
||||
const snapshotsToReplay = buffer.getSnapshotsAfter(98);
|
||||
|
||||
// Clean up old snapshots
|
||||
buffer.removeSnapshotsBefore(95);
|
||||
```
|
||||
|
||||
Sub-frame interpolation:
|
||||
|
||||
```typescript
|
||||
// Use Fixed32 frame time (supports fractional frames)
|
||||
const frameTime = Fixed32.from(102.5);
|
||||
const result = buffer.getInterpolationSnapshotsFixed(frameTime);
|
||||
```
|
||||
|
||||
### API Exports
|
||||
|
||||
```typescript
|
||||
import {
|
||||
// State classes
|
||||
FixedTransformState,
|
||||
FixedTransformStateWithVelocity,
|
||||
type IFixedTransformStateRaw,
|
||||
type IFixedTransformStateWithVelocityRaw,
|
||||
|
||||
// Interpolators
|
||||
FixedTransformInterpolator,
|
||||
FixedHermiteTransformInterpolator,
|
||||
createFixedTransformInterpolator,
|
||||
createFixedHermiteTransformInterpolator,
|
||||
|
||||
// Snapshot buffer
|
||||
FixedSnapshotBuffer,
|
||||
createFixedSnapshotBuffer,
|
||||
type IFixedStateSnapshot,
|
||||
type IFixedInterpolationResult
|
||||
} from '@esengine/network';
|
||||
```
|
||||
|
||||
251
docs/src/content/docs/en/modules/rpc/client.md
Normal file
251
docs/src/content/docs/en/modules/rpc/client.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: "RPC Client API"
|
||||
description: "RpcClient for connecting to RPC servers"
|
||||
---
|
||||
|
||||
The `RpcClient` class provides a type-safe WebSocket client for RPC communication.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('Connected'),
|
||||
onDisconnect: (reason) => console.log('Disconnected:', reason),
|
||||
onError: (error) => console.error('Error:', error),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
```
|
||||
|
||||
## Constructor Options
|
||||
|
||||
```typescript
|
||||
interface RpcClientOptions {
|
||||
// Codec for serialization (default: json())
|
||||
codec?: Codec;
|
||||
|
||||
// API call timeout in ms (default: 30000)
|
||||
timeout?: number;
|
||||
|
||||
// Auto reconnect on disconnect (default: true)
|
||||
autoReconnect?: boolean;
|
||||
|
||||
// Reconnect interval in ms (default: 3000)
|
||||
reconnectInterval?: number;
|
||||
|
||||
// Custom WebSocket factory (for WeChat Mini Games, etc.)
|
||||
webSocketFactory?: (url: string) => WebSocketAdapter;
|
||||
|
||||
// Callbacks
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: (reason?: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## Connection
|
||||
|
||||
### Connect
|
||||
|
||||
```typescript
|
||||
// Connect returns a promise
|
||||
await client.connect();
|
||||
|
||||
// Or chain
|
||||
client.connect().then(() => {
|
||||
console.log('Ready');
|
||||
});
|
||||
```
|
||||
|
||||
### Check Status
|
||||
|
||||
```typescript
|
||||
// Connection status: 'connecting' | 'open' | 'closing' | 'closed'
|
||||
console.log(client.status);
|
||||
|
||||
// Convenience boolean
|
||||
if (client.isConnected) {
|
||||
// Safe to call APIs
|
||||
}
|
||||
```
|
||||
|
||||
### Disconnect
|
||||
|
||||
```typescript
|
||||
// Manually disconnect (disables auto-reconnect)
|
||||
client.disconnect();
|
||||
```
|
||||
|
||||
## Calling APIs
|
||||
|
||||
APIs use request-response pattern with full type safety:
|
||||
|
||||
```typescript
|
||||
// Define protocol
|
||||
const protocol = rpc.define({
|
||||
api: {
|
||||
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
|
||||
getProfile: rpc.api<{ userId: string }, { name: string; level: number }>(),
|
||||
},
|
||||
msg: {}
|
||||
});
|
||||
|
||||
// Call with type inference
|
||||
const { userId, token } = await client.call('login', { username: 'player1' });
|
||||
const profile = await client.call('getProfile', { userId });
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/client';
|
||||
|
||||
try {
|
||||
await client.call('login', { username: 'player1' });
|
||||
} catch (error) {
|
||||
if (error instanceof RpcError) {
|
||||
switch (error.code) {
|
||||
case ErrorCode.TIMEOUT:
|
||||
console.log('Request timed out');
|
||||
break;
|
||||
case ErrorCode.CONNECTION_CLOSED:
|
||||
console.log('Not connected');
|
||||
break;
|
||||
case ErrorCode.NOT_FOUND:
|
||||
console.log('API not found');
|
||||
break;
|
||||
default:
|
||||
console.log('Server error:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sending Messages
|
||||
|
||||
Messages are fire-and-forget (no response):
|
||||
|
||||
```typescript
|
||||
// Send message to server
|
||||
client.send('playerMove', { x: 100, y: 200 });
|
||||
client.send('chat', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
## Receiving Messages
|
||||
|
||||
Listen for server-pushed messages:
|
||||
|
||||
```typescript
|
||||
// Subscribe to message
|
||||
client.on('newMessage', (data) => {
|
||||
console.log(`${data.from}: ${data.text}`);
|
||||
});
|
||||
|
||||
client.on('playerJoined', (data) => {
|
||||
console.log(`${data.name} joined the game`);
|
||||
});
|
||||
|
||||
// Unsubscribe specific handler
|
||||
const handler = (data) => console.log(data);
|
||||
client.on('event', handler);
|
||||
client.off('event', handler);
|
||||
|
||||
// Unsubscribe all handlers for a message
|
||||
client.off('event');
|
||||
|
||||
// One-time listener
|
||||
client.once('gameStart', (data) => {
|
||||
console.log('Game started!');
|
||||
});
|
||||
```
|
||||
|
||||
## Custom WebSocket (Platform Adapters)
|
||||
|
||||
For platforms like WeChat Mini Games:
|
||||
|
||||
```typescript
|
||||
// WeChat Mini Games adapter
|
||||
const wxWebSocketFactory = (url: string) => {
|
||||
const ws = wx.connectSocket({ url });
|
||||
|
||||
return {
|
||||
get readyState() { return ws.readyState; },
|
||||
send: (data) => ws.send({ data }),
|
||||
close: (code, reason) => ws.close({ code, reason }),
|
||||
set onopen(fn) { ws.onOpen(fn); },
|
||||
set onclose(fn) { ws.onClose((e) => fn({ code: e.code, reason: e.reason })); },
|
||||
set onerror(fn) { ws.onError(fn); },
|
||||
set onmessage(fn) { ws.onMessage((e) => fn({ data: e.data })); },
|
||||
};
|
||||
};
|
||||
|
||||
const client = new RpcClient(protocol, 'wss://game.example.com', {
|
||||
webSocketFactory: wxWebSocketFactory,
|
||||
});
|
||||
```
|
||||
|
||||
## Convenience Function
|
||||
|
||||
```typescript
|
||||
import { connect } from '@esengine/rpc/client';
|
||||
|
||||
// Connect and return client in one call
|
||||
const client = await connect(protocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('Connected'),
|
||||
});
|
||||
|
||||
const result = await client.call('join', { name: 'Alice' });
|
||||
```
|
||||
|
||||
## Full Example
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
class GameClient {
|
||||
private client: RpcClient<typeof gameProtocol>;
|
||||
private userId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => this.onConnected(),
|
||||
onDisconnect: () => this.onDisconnected(),
|
||||
onError: (e) => console.error('RPC Error:', e),
|
||||
});
|
||||
|
||||
// Setup message handlers
|
||||
this.client.on('gameState', (state) => this.updateState(state));
|
||||
this.client.on('playerJoined', (p) => this.addPlayer(p));
|
||||
this.client.on('playerLeft', (p) => this.removePlayer(p));
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
private async onConnected() {
|
||||
const { userId, token } = await this.client.call('login', {
|
||||
username: localStorage.getItem('username') || 'Guest',
|
||||
});
|
||||
this.userId = userId;
|
||||
console.log('Logged in as', userId);
|
||||
}
|
||||
|
||||
private onDisconnected() {
|
||||
console.log('Disconnected, will auto-reconnect...');
|
||||
}
|
||||
|
||||
async move(x: number, y: number) {
|
||||
if (!this.client.isConnected) return;
|
||||
this.client.send('move', { x, y });
|
||||
}
|
||||
|
||||
async chat(text: string) {
|
||||
await this.client.call('sendChat', { text });
|
||||
}
|
||||
}
|
||||
```
|
||||
160
docs/src/content/docs/en/modules/rpc/codec.md
Normal file
160
docs/src/content/docs/en/modules/rpc/codec.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "RPC Codecs"
|
||||
description: "Serialization codecs for RPC communication"
|
||||
---
|
||||
|
||||
Codecs handle serialization and deserialization of RPC messages. Two built-in codecs are available.
|
||||
|
||||
## Built-in Codecs
|
||||
|
||||
### JSON Codec (Default)
|
||||
|
||||
Human-readable, widely compatible:
|
||||
|
||||
```typescript
|
||||
import { json } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: json(),
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Human-readable (easy debugging)
|
||||
- No additional dependencies
|
||||
- Universal browser support
|
||||
|
||||
**Cons:**
|
||||
- Larger message size
|
||||
- Slower serialization
|
||||
|
||||
### MessagePack Codec
|
||||
|
||||
Binary format, more efficient:
|
||||
|
||||
```typescript
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: msgpack(),
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Smaller message size (~30-50% smaller)
|
||||
- Faster serialization
|
||||
- Supports binary data natively
|
||||
|
||||
**Cons:**
|
||||
- Not human-readable
|
||||
- Requires msgpack library
|
||||
|
||||
## Codec Interface
|
||||
|
||||
```typescript
|
||||
interface Codec {
|
||||
/**
|
||||
* Encode packet to wire format
|
||||
*/
|
||||
encode(packet: unknown): string | Uint8Array;
|
||||
|
||||
/**
|
||||
* Decode wire format to packet
|
||||
*/
|
||||
decode(data: string | Uint8Array): unknown;
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Codec
|
||||
|
||||
Create your own codec for special needs:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
// Example: Compressed JSON codec
|
||||
const compressedJson: () => Codec = () => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
const json = JSON.stringify(packet);
|
||||
return compress(new TextEncoder().encode(json));
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
const decompressed = decompress(bytes);
|
||||
return JSON.parse(new TextDecoder().decode(decompressed));
|
||||
},
|
||||
});
|
||||
|
||||
// Use custom codec
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: compressedJson(),
|
||||
});
|
||||
```
|
||||
|
||||
## Protocol Buffers Codec
|
||||
|
||||
For production games, consider Protocol Buffers:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
const protobuf = (schema: ProtobufSchema): Codec => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
return schema.Packet.encode(packet).finish();
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
return schema.Packet.decode(bytes);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Matching Client and Server
|
||||
|
||||
Both client and server must use the same codec:
|
||||
|
||||
```typescript
|
||||
// shared/codec.ts
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
export const gameCodec = msgpack();
|
||||
|
||||
// client.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const client = new RpcClient(protocol, url, { codec: gameCodec });
|
||||
|
||||
// server.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
codec: gameCodec,
|
||||
api: { /* ... */ },
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Codec | Encode Speed | Decode Speed | Size |
|
||||
|-------|-------------|--------------|------|
|
||||
| JSON | Medium | Medium | Large |
|
||||
| MessagePack | Fast | Fast | Small |
|
||||
| Protobuf | Fastest | Fastest | Smallest |
|
||||
|
||||
For most games, MessagePack provides a good balance. Use Protobuf for high-performance requirements.
|
||||
|
||||
## Text Encoding Utilities
|
||||
|
||||
For custom codecs, utilities are provided:
|
||||
|
||||
```typescript
|
||||
import { textEncode, textDecode } from '@esengine/rpc/codec';
|
||||
|
||||
// Works on all platforms (browser, Node.js, WeChat)
|
||||
const bytes = textEncode('Hello'); // Uint8Array
|
||||
const text = textDecode(bytes); // 'Hello'
|
||||
```
|
||||
350
docs/src/content/docs/en/modules/rpc/server.md
Normal file
350
docs/src/content/docs/en/modules/rpc/server.md
Normal file
@@ -0,0 +1,350 @@
|
||||
---
|
||||
title: "RPC Server API"
|
||||
description: "RpcServer for handling client connections"
|
||||
---
|
||||
|
||||
The `serve` function creates a type-safe RPC server that handles client connections and API calls.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { serve } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
console.log(`${input.username} connected from ${conn.ip}`);
|
||||
return { userId: conn.id, token: generateToken() };
|
||||
},
|
||||
sendChat: async (input, conn) => {
|
||||
server.broadcast('newMessage', {
|
||||
from: conn.id,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onStart: (port) => console.log(`Server started on port ${port}`),
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
## Server Options
|
||||
|
||||
```typescript
|
||||
interface ServeOptions<P, TConnData> {
|
||||
// Required
|
||||
port: number;
|
||||
api: ApiHandlers<P, TConnData>;
|
||||
|
||||
// Optional
|
||||
msg?: MsgHandlers<P, TConnData>;
|
||||
codec?: Codec;
|
||||
createConnData?: () => TConnData;
|
||||
|
||||
// Callbacks
|
||||
onConnect?: (conn: Connection<TConnData>) => void | Promise<void>;
|
||||
onDisconnect?: (conn: Connection<TConnData>, reason?: string) => void | Promise<void>;
|
||||
onError?: (error: Error, conn?: Connection<TConnData>) => void;
|
||||
onStart?: (port: number) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## API Handlers
|
||||
|
||||
Each API handler receives the input and connection context:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
// Sync handler
|
||||
ping: (input, conn) => {
|
||||
return { pong: true, time: Date.now() };
|
||||
},
|
||||
|
||||
// Async handler
|
||||
getProfile: async (input, conn) => {
|
||||
const user = await database.findUser(input.userId);
|
||||
return { name: user.name, level: user.level };
|
||||
},
|
||||
|
||||
// Access connection context
|
||||
getMyInfo: (input, conn) => {
|
||||
return {
|
||||
connectionId: conn.id,
|
||||
ip: conn.ip,
|
||||
data: conn.data,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Throwing Errors
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/server';
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
const user = await database.findUser(input.username);
|
||||
|
||||
if (!user) {
|
||||
throw new RpcError(ErrorCode.NOT_FOUND, 'User not found');
|
||||
}
|
||||
|
||||
if (!await verifyPassword(input.password, user.hash)) {
|
||||
throw new RpcError('AUTH_FAILED', 'Invalid password');
|
||||
}
|
||||
|
||||
return { userId: user.id, token: generateToken() };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Message Handlers
|
||||
|
||||
Handle messages sent by clients:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: { /* ... */ },
|
||||
msg: {
|
||||
playerMove: (data, conn) => {
|
||||
// Update player position
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
}
|
||||
|
||||
// Broadcast to others
|
||||
server.broadcast('playerMoved', {
|
||||
playerId: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
},
|
||||
|
||||
chat: async (data, conn) => {
|
||||
// Async handlers work too
|
||||
await logChat(conn.id, data.text);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Connection Context
|
||||
|
||||
The `Connection` object provides access to client info:
|
||||
|
||||
```typescript
|
||||
interface Connection<TData> {
|
||||
// Unique connection ID
|
||||
readonly id: string;
|
||||
|
||||
// Client IP address
|
||||
readonly ip: string;
|
||||
|
||||
// Connection status
|
||||
readonly isOpen: boolean;
|
||||
|
||||
// Custom data attached to this connection
|
||||
data: TData;
|
||||
|
||||
// Close the connection
|
||||
close(reason?: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Connection Data
|
||||
|
||||
Store per-connection state:
|
||||
|
||||
```typescript
|
||||
interface PlayerData {
|
||||
playerId: string;
|
||||
username: string;
|
||||
room: string | null;
|
||||
}
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({
|
||||
playerId: '',
|
||||
username: '',
|
||||
room: null,
|
||||
} as PlayerData),
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
// Store data on connection
|
||||
conn.data.playerId = generateId();
|
||||
conn.data.username = input.username;
|
||||
return { playerId: conn.data.playerId };
|
||||
},
|
||||
joinRoom: async (input, conn) => {
|
||||
conn.data.room = input.roomId;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onDisconnect: (conn) => {
|
||||
console.log(`${conn.data.username} left room ${conn.data.room}`);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Sending Messages
|
||||
|
||||
### To Single Connection
|
||||
|
||||
```typescript
|
||||
server.send(conn, 'notification', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
### Broadcast to All
|
||||
|
||||
```typescript
|
||||
// To everyone
|
||||
server.broadcast('announcement', { text: 'Server restart in 5 minutes' });
|
||||
|
||||
// Exclude sender
|
||||
server.broadcast('playerMoved', { id: conn.id, x, y }, { exclude: conn });
|
||||
|
||||
// Exclude multiple
|
||||
server.broadcast('gameEvent', data, { exclude: [conn1, conn2] });
|
||||
```
|
||||
|
||||
### To Specific Group
|
||||
|
||||
```typescript
|
||||
// Custom broadcasting
|
||||
function broadcastToRoom(roomId: string, name: string, data: any) {
|
||||
for (const conn of server.connections) {
|
||||
if (conn.data.room === roomId) {
|
||||
server.send(conn, name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToRoom('room1', 'roomMessage', { text: 'Hello room!' });
|
||||
```
|
||||
|
||||
## Server Lifecycle
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, { /* ... */ });
|
||||
|
||||
// Start
|
||||
await server.start();
|
||||
console.log('Server running');
|
||||
|
||||
// Access connections
|
||||
console.log(`${server.connections.length} clients connected`);
|
||||
|
||||
// Stop (closes all connections)
|
||||
await server.stop();
|
||||
console.log('Server stopped');
|
||||
```
|
||||
|
||||
## Full Example
|
||||
|
||||
```typescript
|
||||
import { serve, RpcError } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
interface PlayerData {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const players = new Map<string, PlayerData>();
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({ id: '', name: '', x: 0, y: 0 }),
|
||||
|
||||
api: {
|
||||
join: async (input, conn) => {
|
||||
const player: PlayerData = {
|
||||
id: conn.id,
|
||||
name: input.name,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
players.set(conn.id, player);
|
||||
conn.data = player;
|
||||
|
||||
// Notify others
|
||||
server.broadcast('playerJoined', {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
}, { exclude: conn });
|
||||
|
||||
// Send current state to new player
|
||||
return {
|
||||
playerId: player.id,
|
||||
players: Array.from(players.values()),
|
||||
};
|
||||
},
|
||||
|
||||
chat: async (input, conn) => {
|
||||
server.broadcast('chatMessage', {
|
||||
from: conn.data.name,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { sent: true };
|
||||
},
|
||||
},
|
||||
|
||||
msg: {
|
||||
move: (data, conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
|
||||
server.broadcast('playerMoved', {
|
||||
id: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
onConnect: (conn) => {
|
||||
console.log(`Client connected: ${conn.id} from ${conn.ip}`);
|
||||
},
|
||||
|
||||
onDisconnect: (conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
players.delete(conn.id);
|
||||
server.broadcast('playerLeft', { id: conn.id });
|
||||
console.log(`${player.name} disconnected`);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (error, conn) => {
|
||||
console.error(`Error from ${conn?.id}:`, error);
|
||||
},
|
||||
|
||||
onStart: (port) => {
|
||||
console.log(`Game server running on ws://localhost:${port}`);
|
||||
},
|
||||
});
|
||||
|
||||
server.start();
|
||||
```
|
||||
261
docs/src/content/docs/en/modules/transaction/core.md
Normal file
261
docs/src/content/docs/en/modules/transaction/core.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
title: "Core Concepts"
|
||||
description: "Transaction system core concepts: context, manager, and Saga pattern"
|
||||
---
|
||||
|
||||
## Transaction State
|
||||
|
||||
A transaction can be in the following states:
|
||||
|
||||
```typescript
|
||||
type TransactionState =
|
||||
| 'pending' // Waiting to execute
|
||||
| 'executing' // Executing
|
||||
| 'committed' // Committed
|
||||
| 'rolledback' // Rolled back
|
||||
| 'failed' // Failed
|
||||
```
|
||||
|
||||
## TransactionContext
|
||||
|
||||
The transaction context encapsulates transaction state, operations, and execution logic.
|
||||
|
||||
### Creating Transactions
|
||||
|
||||
```typescript
|
||||
import { TransactionManager } from '@esengine/transaction';
|
||||
|
||||
const manager = new TransactionManager();
|
||||
|
||||
// Method 1: Manual management with begin()
|
||||
const tx = manager.begin({ timeout: 5000 });
|
||||
tx.addOperation(op1);
|
||||
tx.addOperation(op2);
|
||||
const result = await tx.execute();
|
||||
|
||||
// Method 2: Automatic management with run()
|
||||
const result = await manager.run((tx) => {
|
||||
tx.addOperation(op1);
|
||||
tx.addOperation(op2);
|
||||
});
|
||||
```
|
||||
|
||||
### Chaining Operations
|
||||
|
||||
```typescript
|
||||
const result = await manager.run((tx) => {
|
||||
tx.addOperation(new CurrencyOperation({ ... }))
|
||||
.addOperation(new InventoryOperation({ ... }))
|
||||
.addOperation(new InventoryOperation({ ... }));
|
||||
});
|
||||
```
|
||||
|
||||
### Context Data
|
||||
|
||||
Operations can share data through the context:
|
||||
|
||||
```typescript
|
||||
class CustomOperation extends BaseOperation<MyData, MyResult> {
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
|
||||
// Read data set by previous operations
|
||||
const previousResult = ctx.get<number>('previousValue');
|
||||
|
||||
// Set data for subsequent operations
|
||||
ctx.set('myResult', { value: 123 });
|
||||
|
||||
return this.success({ ... });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TransactionManager
|
||||
|
||||
The transaction manager is responsible for creating, executing, and recovering transactions.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```typescript
|
||||
interface TransactionManagerConfig {
|
||||
storage?: ITransactionStorage; // Storage instance
|
||||
defaultTimeout?: number; // Default timeout (ms)
|
||||
serverId?: string; // Server ID (for distributed)
|
||||
autoRecover?: boolean; // Auto-recover pending transactions
|
||||
}
|
||||
|
||||
const manager = new TransactionManager({
|
||||
storage: new RedisStorage({ client: redis }),
|
||||
defaultTimeout: 10000,
|
||||
serverId: 'server-1',
|
||||
autoRecover: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Distributed Locking
|
||||
|
||||
```typescript
|
||||
// Acquire lock
|
||||
const token = await manager.acquireLock('player:123:inventory', 10000);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Perform operations
|
||||
await doSomething();
|
||||
} finally {
|
||||
// Release lock
|
||||
await manager.releaseLock('player:123:inventory', token);
|
||||
}
|
||||
}
|
||||
|
||||
// Or use withLock for convenience
|
||||
await manager.withLock('player:123:inventory', async () => {
|
||||
await doSomething();
|
||||
}, 10000);
|
||||
```
|
||||
|
||||
### Transaction Recovery
|
||||
|
||||
Recover pending transactions after server restart:
|
||||
|
||||
```typescript
|
||||
const manager = new TransactionManager({
|
||||
storage: new RedisStorage({ client: redis }),
|
||||
serverId: 'server-1',
|
||||
});
|
||||
|
||||
// Recover pending transactions
|
||||
const recoveredCount = await manager.recover();
|
||||
console.log(`Recovered ${recoveredCount} transactions`);
|
||||
```
|
||||
|
||||
## Saga Pattern
|
||||
|
||||
The transaction system uses the Saga pattern. Each operation must implement `execute` and `compensate` methods:
|
||||
|
||||
```typescript
|
||||
interface ITransactionOperation<TData, TResult> {
|
||||
readonly name: string;
|
||||
readonly data: TData;
|
||||
|
||||
// Validate preconditions
|
||||
validate(ctx: ITransactionContext): Promise<boolean>;
|
||||
|
||||
// Forward execution
|
||||
execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>;
|
||||
|
||||
// Compensate (rollback)
|
||||
compensate(ctx: ITransactionContext): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Execution Flow
|
||||
|
||||
```
|
||||
Begin Transaction
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ validate(op1) │──fail──► Return failure
|
||||
└─────────────────────┘
|
||||
│success
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ execute(op1) │──fail──┐
|
||||
└─────────────────────┘ │
|
||||
│success │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ validate(op2) │──fail──┤
|
||||
└─────────────────────┘ │
|
||||
│success │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ execute(op2) │──fail──┤
|
||||
└─────────────────────┘ │
|
||||
│success ▼
|
||||
▼ ┌─────────────────────┐
|
||||
Commit Transaction │ compensate(op1) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
Return failure (rolled back)
|
||||
```
|
||||
|
||||
### Custom Operations
|
||||
|
||||
```typescript
|
||||
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
|
||||
|
||||
interface UpgradeData {
|
||||
playerId: string;
|
||||
itemId: string;
|
||||
targetLevel: number;
|
||||
}
|
||||
|
||||
interface UpgradeResult {
|
||||
newLevel: number;
|
||||
}
|
||||
|
||||
class UpgradeOperation extends BaseOperation<UpgradeData, UpgradeResult> {
|
||||
readonly name = 'upgrade';
|
||||
|
||||
private _previousLevel: number = 0;
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
// Validate item exists and can be upgraded
|
||||
const item = await this.getItem(ctx);
|
||||
return item !== null && item.level < this.data.targetLevel;
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<UpgradeResult>> {
|
||||
const item = await this.getItem(ctx);
|
||||
if (!item) {
|
||||
return this.failure('Item not found', 'ITEM_NOT_FOUND');
|
||||
}
|
||||
|
||||
this._previousLevel = item.level;
|
||||
item.level = this.data.targetLevel;
|
||||
await this.saveItem(ctx, item);
|
||||
|
||||
return this.success({ newLevel: item.level });
|
||||
}
|
||||
|
||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||
const item = await this.getItem(ctx);
|
||||
if (item) {
|
||||
item.level = this._previousLevel;
|
||||
await this.saveItem(ctx, item);
|
||||
}
|
||||
}
|
||||
|
||||
private async getItem(ctx: ITransactionContext) {
|
||||
// Get item from storage
|
||||
}
|
||||
|
||||
private async saveItem(ctx: ITransactionContext, item: any) {
|
||||
// Save item to storage
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Transaction Result
|
||||
|
||||
```typescript
|
||||
interface TransactionResult<T = unknown> {
|
||||
success: boolean; // Whether succeeded
|
||||
transactionId: string; // Transaction ID
|
||||
results: OperationResult[]; // Operation results
|
||||
data?: T; // Final data
|
||||
error?: string; // Error message
|
||||
duration: number; // Execution time (ms)
|
||||
}
|
||||
|
||||
const result = await manager.run((tx) => { ... });
|
||||
|
||||
console.log(`Transaction ${result.transactionId}`);
|
||||
console.log(`Success: ${result.success}`);
|
||||
console.log(`Duration: ${result.duration}ms`);
|
||||
|
||||
if (!result.success) {
|
||||
console.log(`Error: ${result.error}`);
|
||||
}
|
||||
```
|
||||
355
docs/src/content/docs/en/modules/transaction/distributed.md
Normal file
355
docs/src/content/docs/en/modules/transaction/distributed.md
Normal file
@@ -0,0 +1,355 @@
|
||||
---
|
||||
title: "Distributed Transactions"
|
||||
description: "Saga orchestrator and cross-server transaction support"
|
||||
---
|
||||
|
||||
## Saga Orchestrator
|
||||
|
||||
`SagaOrchestrator` is used to orchestrate distributed transactions across servers.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { SagaOrchestrator, RedisStorage } from '@esengine/transaction';
|
||||
|
||||
const orchestrator = new SagaOrchestrator({
|
||||
storage: new RedisStorage({ client: redis }),
|
||||
timeout: 30000,
|
||||
serverId: 'orchestrator-1',
|
||||
});
|
||||
|
||||
const result = await orchestrator.execute([
|
||||
{
|
||||
name: 'deduct_currency',
|
||||
serverId: 'game-server-1',
|
||||
data: { playerId: 'player1', amount: 100 },
|
||||
execute: async (data) => {
|
||||
// Call game server API to deduct currency
|
||||
const response = await gameServerApi.deductCurrency(data);
|
||||
return { success: response.ok };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
// Call game server API to restore currency
|
||||
await gameServerApi.addCurrency(data);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_item',
|
||||
serverId: 'inventory-server-1',
|
||||
data: { playerId: 'player1', itemId: 'sword' },
|
||||
execute: async (data) => {
|
||||
const response = await inventoryServerApi.addItem(data);
|
||||
return { success: response.ok };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await inventoryServerApi.removeItem(data);
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (result.success) {
|
||||
console.log('Saga completed successfully');
|
||||
} else {
|
||||
console.log('Saga failed:', result.error);
|
||||
console.log('Completed steps:', result.completedSteps);
|
||||
console.log('Failed at:', result.failedStep);
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```typescript
|
||||
interface SagaOrchestratorConfig {
|
||||
storage?: ITransactionStorage; // Storage instance
|
||||
timeout?: number; // Timeout in milliseconds
|
||||
serverId?: string; // Orchestrator server ID
|
||||
}
|
||||
```
|
||||
|
||||
### Saga Step
|
||||
|
||||
```typescript
|
||||
interface SagaStep<T = unknown> {
|
||||
name: string; // Step name
|
||||
serverId?: string; // Target server ID
|
||||
data: T; // Step data
|
||||
execute: (data: T) => Promise<OperationResult>; // Execute function
|
||||
compensate: (data: T) => Promise<void>; // Compensate function
|
||||
}
|
||||
```
|
||||
|
||||
### Saga Result
|
||||
|
||||
```typescript
|
||||
interface SagaResult {
|
||||
success: boolean; // Whether succeeded
|
||||
sagaId: string; // Saga ID
|
||||
completedSteps: string[]; // Completed steps
|
||||
failedStep?: string; // Failed step
|
||||
error?: string; // Error message
|
||||
duration: number; // Execution time (ms)
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```
|
||||
Start Saga
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Step 1: execute │──fail──┐
|
||||
└─────────────────────┘ │
|
||||
│success │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ Step 2: execute │──fail──┤
|
||||
└─────────────────────┘ │
|
||||
│success │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ Step 3: execute │──fail──┤
|
||||
└─────────────────────┘ │
|
||||
│success ▼
|
||||
▼ ┌─────────────────────┐
|
||||
Saga Complete │ Step 2: compensate │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Step 1: compensate │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
Saga Failed (compensated)
|
||||
```
|
||||
|
||||
## Saga Logs
|
||||
|
||||
The orchestrator records detailed execution logs:
|
||||
|
||||
```typescript
|
||||
interface SagaLog {
|
||||
id: string; // Saga ID
|
||||
state: SagaLogState; // State
|
||||
steps: SagaStepLog[]; // Step logs
|
||||
createdAt: number; // Creation time
|
||||
updatedAt: number; // Update time
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type SagaLogState =
|
||||
| 'pending' // Waiting to execute
|
||||
| 'running' // Executing
|
||||
| 'completed' // Completed
|
||||
| 'compensating' // Compensating
|
||||
| 'compensated' // Compensated
|
||||
| 'failed' // Failed
|
||||
|
||||
interface SagaStepLog {
|
||||
name: string; // Step name
|
||||
serverId?: string; // Server ID
|
||||
state: SagaStepState; // State
|
||||
startedAt?: number; // Start time
|
||||
completedAt?: number; // Completion time
|
||||
error?: string; // Error message
|
||||
}
|
||||
|
||||
type SagaStepState =
|
||||
| 'pending' // Waiting to execute
|
||||
| 'executing' // Executing
|
||||
| 'completed' // Completed
|
||||
| 'compensating' // Compensating
|
||||
| 'compensated' // Compensated
|
||||
| 'failed' // Failed
|
||||
```
|
||||
|
||||
### Query Saga Logs
|
||||
|
||||
```typescript
|
||||
const log = await orchestrator.getSagaLog('saga_xxx');
|
||||
|
||||
if (log) {
|
||||
console.log('Saga state:', log.state);
|
||||
for (const step of log.steps) {
|
||||
console.log(` ${step.name}: ${step.state}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Server Transaction Examples
|
||||
|
||||
### Scenario: Cross-Server Purchase
|
||||
|
||||
A player purchases an item on a game server, with currency on an account server and items on an inventory server.
|
||||
|
||||
```typescript
|
||||
const orchestrator = new SagaOrchestrator({
|
||||
storage: redisStorage,
|
||||
serverId: 'purchase-orchestrator',
|
||||
});
|
||||
|
||||
async function crossServerPurchase(
|
||||
playerId: string,
|
||||
itemId: string,
|
||||
price: number
|
||||
): Promise<SagaResult> {
|
||||
return orchestrator.execute([
|
||||
// Step 1: Deduct balance on account server
|
||||
{
|
||||
name: 'deduct_balance',
|
||||
serverId: 'account-server',
|
||||
data: { playerId, amount: price },
|
||||
execute: async (data) => {
|
||||
const result = await accountService.deduct(data.playerId, data.amount);
|
||||
return { success: result.ok, error: result.error };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await accountService.refund(data.playerId, data.amount);
|
||||
},
|
||||
},
|
||||
|
||||
// Step 2: Add item on inventory server
|
||||
{
|
||||
name: 'add_item',
|
||||
serverId: 'inventory-server',
|
||||
data: { playerId, itemId },
|
||||
execute: async (data) => {
|
||||
const result = await inventoryService.addItem(data.playerId, data.itemId);
|
||||
return { success: result.ok, error: result.error };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await inventoryService.removeItem(data.playerId, data.itemId);
|
||||
},
|
||||
},
|
||||
|
||||
// Step 3: Record purchase log
|
||||
{
|
||||
name: 'log_purchase',
|
||||
serverId: 'log-server',
|
||||
data: { playerId, itemId, price, timestamp: Date.now() },
|
||||
execute: async (data) => {
|
||||
await logService.recordPurchase(data);
|
||||
return { success: true };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await logService.cancelPurchase(data);
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### Scenario: Cross-Server Trade
|
||||
|
||||
Two players on different servers trade with each other.
|
||||
|
||||
```typescript
|
||||
async function crossServerTrade(
|
||||
playerA: { id: string; server: string; items: string[] },
|
||||
playerB: { id: string; server: string; items: string[] }
|
||||
): Promise<SagaResult> {
|
||||
const steps: SagaStep[] = [];
|
||||
|
||||
// Remove items from player A
|
||||
for (const itemId of playerA.items) {
|
||||
steps.push({
|
||||
name: `remove_${playerA.id}_${itemId}`,
|
||||
serverId: playerA.server,
|
||||
data: { playerId: playerA.id, itemId },
|
||||
execute: async (data) => {
|
||||
return await inventoryService.removeItem(data.playerId, data.itemId);
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await inventoryService.addItem(data.playerId, data.itemId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add items to player B (from A)
|
||||
for (const itemId of playerA.items) {
|
||||
steps.push({
|
||||
name: `add_${playerB.id}_${itemId}`,
|
||||
serverId: playerB.server,
|
||||
data: { playerId: playerB.id, itemId },
|
||||
execute: async (data) => {
|
||||
return await inventoryService.addItem(data.playerId, data.itemId);
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await inventoryService.removeItem(data.playerId, data.itemId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Similarly handle player B's items...
|
||||
|
||||
return orchestrator.execute(steps);
|
||||
}
|
||||
```
|
||||
|
||||
## Recovering Incomplete Sagas
|
||||
|
||||
Recover incomplete Sagas after server restart:
|
||||
|
||||
```typescript
|
||||
const orchestrator = new SagaOrchestrator({
|
||||
storage: redisStorage,
|
||||
serverId: 'my-orchestrator',
|
||||
});
|
||||
|
||||
// Recover incomplete Sagas (will execute compensation)
|
||||
const recoveredCount = await orchestrator.recover();
|
||||
console.log(`Recovered ${recoveredCount} sagas`);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Idempotency
|
||||
|
||||
Ensure all operations are idempotent:
|
||||
|
||||
```typescript
|
||||
{
|
||||
execute: async (data) => {
|
||||
// Use unique ID to ensure idempotency
|
||||
const result = await service.process(data.requestId, data);
|
||||
return { success: result.ok };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
// Compensation must also be idempotent
|
||||
await service.rollback(data.requestId);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Timeout Handling
|
||||
|
||||
Set appropriate timeout values:
|
||||
|
||||
```typescript
|
||||
const orchestrator = new SagaOrchestrator({
|
||||
timeout: 60000, // Cross-server operations need longer timeout
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Monitoring and Alerts
|
||||
|
||||
Log Saga execution results:
|
||||
|
||||
```typescript
|
||||
const result = await orchestrator.execute(steps);
|
||||
|
||||
if (!result.success) {
|
||||
// Send alert
|
||||
alertService.send({
|
||||
type: 'saga_failed',
|
||||
sagaId: result.sagaId,
|
||||
failedStep: result.failedStep,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
// Log details
|
||||
const log = await orchestrator.getSagaLog(result.sagaId);
|
||||
logger.error('Saga failed', { log });
|
||||
}
|
||||
```
|
||||
238
docs/src/content/docs/en/modules/transaction/index.md
Normal file
238
docs/src/content/docs/en/modules/transaction/index.md
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: "Transaction System"
|
||||
description: "Game transaction system with distributed support for shop purchases, player trading, and more"
|
||||
---
|
||||
|
||||
`@esengine/transaction` provides comprehensive game transaction capabilities based on the Saga pattern, supporting shop purchases, player trading, multi-step tasks, and distributed transactions with Redis/MongoDB.
|
||||
|
||||
## Overview
|
||||
|
||||
The transaction system solves common data consistency problems in games:
|
||||
|
||||
| Scenario | Problem | Solution |
|
||||
|----------|---------|----------|
|
||||
| Shop Purchase | Payment succeeded but item not delivered | Atomic transaction with auto-rollback |
|
||||
| Player Trade | One party transferred items but other didn't receive | Saga compensation mechanism |
|
||||
| Cross-Server | Data inconsistency across servers | Distributed lock + transaction log |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/transaction
|
||||
```
|
||||
|
||||
Optional dependencies (install based on storage needs):
|
||||
```bash
|
||||
npm install ioredis # Redis storage
|
||||
npm install mongodb # MongoDB storage
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Transaction Layer │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ TransactionManager - Manages transaction lifecycle │
|
||||
│ TransactionContext - Encapsulates operations and state │
|
||||
│ SagaOrchestrator - Distributed Saga orchestrator │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Storage Layer │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ MemoryStorage - In-memory (dev/test) │
|
||||
│ RedisStorage - Redis (distributed lock + cache) │
|
||||
│ MongoStorage - MongoDB (persistent log) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Operation Layer │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ CurrencyOperation - Currency operations │
|
||||
│ InventoryOperation - Inventory operations │
|
||||
│ TradeOperation - Trade operations │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TransactionManager,
|
||||
MemoryStorage,
|
||||
CurrencyOperation,
|
||||
InventoryOperation,
|
||||
} from '@esengine/transaction';
|
||||
|
||||
// Create transaction manager
|
||||
const manager = new TransactionManager({
|
||||
storage: new MemoryStorage(),
|
||||
defaultTimeout: 10000,
|
||||
});
|
||||
|
||||
// Execute transaction
|
||||
const result = await manager.run((tx) => {
|
||||
// Deduct gold
|
||||
tx.addOperation(new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
}));
|
||||
|
||||
// Add item
|
||||
tx.addOperation(new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player1',
|
||||
itemId: 'sword_001',
|
||||
quantity: 1,
|
||||
}));
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log('Purchase successful!');
|
||||
} else {
|
||||
console.log('Purchase failed:', result.error);
|
||||
}
|
||||
```
|
||||
|
||||
### Player Trading
|
||||
|
||||
```typescript
|
||||
import { TradeOperation } from '@esengine/transaction';
|
||||
|
||||
const result = await manager.run((tx) => {
|
||||
tx.addOperation(new TradeOperation({
|
||||
tradeId: 'trade_001',
|
||||
partyA: {
|
||||
playerId: 'player1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
}));
|
||||
}, { timeout: 30000 });
|
||||
```
|
||||
|
||||
### Using Redis Storage
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { TransactionManager, RedisStorage } from '@esengine/transaction';
|
||||
|
||||
const redis = new Redis('redis://localhost:6379');
|
||||
const storage = new RedisStorage({ client: redis });
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
```
|
||||
|
||||
### Using MongoDB Storage
|
||||
|
||||
```typescript
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { TransactionManager, MongoStorage } from '@esengine/transaction';
|
||||
|
||||
const client = new MongoClient('mongodb://localhost:27017');
|
||||
await client.connect();
|
||||
const db = client.db('game');
|
||||
|
||||
const storage = new MongoStorage({ db });
|
||||
await storage.ensureIndexes();
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
```
|
||||
|
||||
## Room Integration
|
||||
|
||||
```typescript
|
||||
import { Room } from '@esengine/server';
|
||||
import { withTransactions, CurrencyOperation, RedisStorage } from '@esengine/transaction';
|
||||
|
||||
class GameRoom extends withTransactions(Room, {
|
||||
storage: new RedisStorage({ client: redisClient }),
|
||||
}) {
|
||||
@onMessage('Buy')
|
||||
async handleBuy(data: { itemId: string }, player: Player) {
|
||||
const result = await this.runTransaction((tx) => {
|
||||
tx.addOperation(new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: player.id,
|
||||
currency: 'gold',
|
||||
amount: getItemPrice(data.itemId),
|
||||
}));
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
player.send('buy_success', { itemId: data.itemId });
|
||||
} else {
|
||||
player.send('buy_failed', { error: result.error });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Core Concepts](/en/modules/transaction/core/) - Transaction context, manager, Saga pattern
|
||||
- [Storage Layer](/en/modules/transaction/storage/) - MemoryStorage, RedisStorage, MongoStorage
|
||||
- [Operations](/en/modules/transaction/operations/) - Currency, inventory, trade operations
|
||||
- [Distributed Transactions](/en/modules/transaction/distributed/) - Saga orchestrator, cross-server transactions
|
||||
- [API Reference](/en/modules/transaction/api/) - Complete API documentation
|
||||
|
||||
## Service Tokens
|
||||
|
||||
For dependency injection:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TransactionManagerToken,
|
||||
TransactionStorageToken,
|
||||
} from '@esengine/transaction';
|
||||
|
||||
const manager = services.get(TransactionManagerToken);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Operation Granularity
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Fine-grained operations, easy to rollback
|
||||
tx.addOperation(new CurrencyOperation({ type: 'deduct', ... }));
|
||||
tx.addOperation(new InventoryOperation({ type: 'add', ... }));
|
||||
|
||||
// ❌ Bad: Coarse-grained operation, hard to partially rollback
|
||||
tx.addOperation(new ComplexPurchaseOperation({ ... }));
|
||||
```
|
||||
|
||||
### 2. Timeout Settings
|
||||
|
||||
```typescript
|
||||
// Simple operations: short timeout
|
||||
await manager.run(tx => { ... }, { timeout: 5000 });
|
||||
|
||||
// Complex trades: longer timeout
|
||||
await manager.run(tx => { ... }, { timeout: 30000 });
|
||||
|
||||
// Cross-server: even longer timeout
|
||||
await manager.run(tx => { ... }, { timeout: 60000, distributed: true });
|
||||
```
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
```typescript
|
||||
const result = await manager.run((tx) => { ... });
|
||||
|
||||
if (!result.success) {
|
||||
// Log the error
|
||||
logger.error('Transaction failed', {
|
||||
transactionId: result.transactionId,
|
||||
error: result.error,
|
||||
duration: result.duration,
|
||||
});
|
||||
|
||||
// Notify user
|
||||
player.send('error', { message: getErrorMessage(result.error) });
|
||||
}
|
||||
```
|
||||
313
docs/src/content/docs/en/modules/transaction/operations.md
Normal file
313
docs/src/content/docs/en/modules/transaction/operations.md
Normal file
@@ -0,0 +1,313 @@
|
||||
---
|
||||
title: "Operations"
|
||||
description: "Built-in transaction operations: currency, inventory, trade"
|
||||
---
|
||||
|
||||
## BaseOperation
|
||||
|
||||
Base class for all operations, providing a common implementation template.
|
||||
|
||||
```typescript
|
||||
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
|
||||
|
||||
class MyOperation extends BaseOperation<MyData, MyResult> {
|
||||
readonly name = 'myOperation';
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
// Validate preconditions
|
||||
return true;
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
|
||||
// Execute operation
|
||||
return this.success({ result: 'ok' });
|
||||
// or
|
||||
return this.failure('Something went wrong', 'ERROR_CODE');
|
||||
}
|
||||
|
||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||
// Rollback operation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CurrencyOperation
|
||||
|
||||
Handles currency addition and deduction.
|
||||
|
||||
### Deduct Currency
|
||||
|
||||
```typescript
|
||||
import { CurrencyOperation } from '@esengine/transaction';
|
||||
|
||||
tx.addOperation(new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
reason: 'purchase_item',
|
||||
}));
|
||||
```
|
||||
|
||||
### Add Currency
|
||||
|
||||
```typescript
|
||||
tx.addOperation(new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player1',
|
||||
currency: 'diamond',
|
||||
amount: 50,
|
||||
reason: 'daily_reward',
|
||||
}));
|
||||
```
|
||||
|
||||
### Operation Data
|
||||
|
||||
```typescript
|
||||
interface CurrencyOperationData {
|
||||
type: 'add' | 'deduct'; // Operation type
|
||||
playerId: string; // Player ID
|
||||
currency: string; // Currency type
|
||||
amount: number; // Amount
|
||||
reason?: string; // Reason/source
|
||||
}
|
||||
```
|
||||
|
||||
### Operation Result
|
||||
|
||||
```typescript
|
||||
interface CurrencyOperationResult {
|
||||
beforeBalance: number; // Balance before operation
|
||||
afterBalance: number; // Balance after operation
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Data Provider
|
||||
|
||||
```typescript
|
||||
interface ICurrencyProvider {
|
||||
getBalance(playerId: string, currency: string): Promise<number>;
|
||||
setBalance(playerId: string, currency: string, amount: number): Promise<void>;
|
||||
}
|
||||
|
||||
class MyCurrencyProvider implements ICurrencyProvider {
|
||||
async getBalance(playerId: string, currency: string): Promise<number> {
|
||||
// Get balance from database
|
||||
return await db.getCurrency(playerId, currency);
|
||||
}
|
||||
|
||||
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
|
||||
// Save to database
|
||||
await db.setCurrency(playerId, currency, amount);
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom provider
|
||||
const op = new CurrencyOperation({ ... });
|
||||
op.setProvider(new MyCurrencyProvider());
|
||||
tx.addOperation(op);
|
||||
```
|
||||
|
||||
## InventoryOperation
|
||||
|
||||
Handles item addition, removal, and updates.
|
||||
|
||||
### Add Item
|
||||
|
||||
```typescript
|
||||
import { InventoryOperation } from '@esengine/transaction';
|
||||
|
||||
tx.addOperation(new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player1',
|
||||
itemId: 'sword_001',
|
||||
quantity: 1,
|
||||
properties: { enchant: 'fire' },
|
||||
}));
|
||||
```
|
||||
|
||||
### Remove Item
|
||||
|
||||
```typescript
|
||||
tx.addOperation(new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player1',
|
||||
itemId: 'potion_hp',
|
||||
quantity: 5,
|
||||
}));
|
||||
```
|
||||
|
||||
### Update Item
|
||||
|
||||
```typescript
|
||||
tx.addOperation(new InventoryOperation({
|
||||
type: 'update',
|
||||
playerId: 'player1',
|
||||
itemId: 'sword_001',
|
||||
quantity: 1, // Optional, keeps original if not provided
|
||||
properties: { enchant: 'lightning', level: 5 },
|
||||
}));
|
||||
```
|
||||
|
||||
### Operation Data
|
||||
|
||||
```typescript
|
||||
interface InventoryOperationData {
|
||||
type: 'add' | 'remove' | 'update'; // Operation type
|
||||
playerId: string; // Player ID
|
||||
itemId: string; // Item ID
|
||||
quantity: number; // Quantity
|
||||
properties?: Record<string, unknown>; // Item properties
|
||||
reason?: string; // Reason/source
|
||||
}
|
||||
```
|
||||
|
||||
### Operation Result
|
||||
|
||||
```typescript
|
||||
interface InventoryOperationResult {
|
||||
beforeItem?: ItemData; // Item before operation
|
||||
afterItem?: ItemData; // Item after operation
|
||||
}
|
||||
|
||||
interface ItemData {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Data Provider
|
||||
|
||||
```typescript
|
||||
interface IInventoryProvider {
|
||||
getItem(playerId: string, itemId: string): Promise<ItemData | null>;
|
||||
setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void>;
|
||||
hasCapacity?(playerId: string, count: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
class MyInventoryProvider implements IInventoryProvider {
|
||||
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
|
||||
return await db.getItem(playerId, itemId);
|
||||
}
|
||||
|
||||
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
|
||||
if (item) {
|
||||
await db.saveItem(playerId, itemId, item);
|
||||
} else {
|
||||
await db.deleteItem(playerId, itemId);
|
||||
}
|
||||
}
|
||||
|
||||
async hasCapacity(playerId: string, count: number): Promise<boolean> {
|
||||
const current = await db.getItemCount(playerId);
|
||||
const max = await db.getMaxCapacity(playerId);
|
||||
return current + count <= max;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TradeOperation
|
||||
|
||||
Handles item and currency exchange between players.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { TradeOperation } from '@esengine/transaction';
|
||||
|
||||
tx.addOperation(new TradeOperation({
|
||||
tradeId: 'trade_001',
|
||||
partyA: {
|
||||
playerId: 'player1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
currencies: [{ currency: 'diamond', amount: 10 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
reason: 'player_trade',
|
||||
}));
|
||||
```
|
||||
|
||||
### Operation Data
|
||||
|
||||
```typescript
|
||||
interface TradeOperationData {
|
||||
tradeId: string; // Trade ID
|
||||
partyA: TradeParty; // Trade initiator
|
||||
partyB: TradeParty; // Trade receiver
|
||||
reason?: string; // Reason/note
|
||||
}
|
||||
|
||||
interface TradeParty {
|
||||
playerId: string; // Player ID
|
||||
items?: TradeItem[]; // Items to give
|
||||
currencies?: TradeCurrency[]; // Currencies to give
|
||||
}
|
||||
|
||||
interface TradeItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface TradeCurrency {
|
||||
currency: string;
|
||||
amount: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Execution Flow
|
||||
|
||||
TradeOperation internally generates the following sub-operation sequence:
|
||||
|
||||
```
|
||||
1. Remove partyA's items
|
||||
2. Add items to partyB (from partyA)
|
||||
3. Deduct partyA's currencies
|
||||
4. Add currencies to partyB (from partyA)
|
||||
5. Remove partyB's items
|
||||
6. Add items to partyA (from partyB)
|
||||
7. Deduct partyB's currencies
|
||||
8. Add currencies to partyA (from partyB)
|
||||
```
|
||||
|
||||
If any step fails, all previous operations are rolled back.
|
||||
|
||||
### Using Custom Providers
|
||||
|
||||
```typescript
|
||||
const op = new TradeOperation({ ... });
|
||||
op.setProvider({
|
||||
currencyProvider: new MyCurrencyProvider(),
|
||||
inventoryProvider: new MyInventoryProvider(),
|
||||
});
|
||||
tx.addOperation(op);
|
||||
```
|
||||
|
||||
## Factory Functions
|
||||
|
||||
Each operation class provides a factory function:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createCurrencyOperation,
|
||||
createInventoryOperation,
|
||||
createTradeOperation,
|
||||
} from '@esengine/transaction';
|
||||
|
||||
tx.addOperation(createCurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
}));
|
||||
|
||||
tx.addOperation(createInventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player1',
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
}));
|
||||
```
|
||||
238
docs/src/content/docs/en/modules/transaction/storage.md
Normal file
238
docs/src/content/docs/en/modules/transaction/storage.md
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: "Storage Layer"
|
||||
description: "Transaction storage interface and implementations: MemoryStorage, RedisStorage, MongoStorage"
|
||||
---
|
||||
|
||||
## Storage Interface
|
||||
|
||||
All storage implementations must implement the `ITransactionStorage` interface:
|
||||
|
||||
```typescript
|
||||
interface ITransactionStorage {
|
||||
// Lifecycle
|
||||
close?(): Promise<void>;
|
||||
|
||||
// Distributed lock
|
||||
acquireLock(key: string, ttl: number): Promise<string | null>;
|
||||
releaseLock(key: string, token: string): Promise<boolean>;
|
||||
|
||||
// Transaction log
|
||||
saveTransaction(tx: TransactionLog): Promise<void>;
|
||||
getTransaction(id: string): Promise<TransactionLog | null>;
|
||||
updateTransactionState(id: string, state: TransactionState): Promise<void>;
|
||||
updateOperationState(txId: string, opIndex: number, state: string, error?: string): Promise<void>;
|
||||
getPendingTransactions(serverId?: string): Promise<TransactionLog[]>;
|
||||
deleteTransaction(id: string): Promise<void>;
|
||||
|
||||
// Data operations
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
## MemoryStorage
|
||||
|
||||
In-memory storage, suitable for development and testing.
|
||||
|
||||
```typescript
|
||||
import { MemoryStorage } from '@esengine/transaction';
|
||||
|
||||
const storage = new MemoryStorage({
|
||||
maxTransactions: 1000, // Maximum transaction log count
|
||||
});
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
|
||||
- ✅ No external dependencies
|
||||
- ✅ Fast, good for debugging
|
||||
- ❌ Data only stored in memory
|
||||
- ❌ No true distributed locking
|
||||
- ❌ Data lost on restart
|
||||
|
||||
### Test Helpers
|
||||
|
||||
```typescript
|
||||
// Clear all data
|
||||
storage.clear();
|
||||
|
||||
// Get transaction count
|
||||
console.log(storage.transactionCount);
|
||||
```
|
||||
|
||||
## RedisStorage
|
||||
|
||||
Redis storage, suitable for production distributed systems. Uses factory pattern with lazy connection.
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { RedisStorage } from '@esengine/transaction';
|
||||
|
||||
// Factory pattern: lazy connection, connects on first operation
|
||||
const storage = new RedisStorage({
|
||||
factory: () => new Redis('redis://localhost:6379'),
|
||||
prefix: 'tx:', // Key prefix
|
||||
transactionTTL: 86400, // Transaction log TTL (seconds)
|
||||
});
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
|
||||
// Close connection when done
|
||||
await storage.close();
|
||||
|
||||
// Or use await using for automatic cleanup (TypeScript 5.2+)
|
||||
await using storage = new RedisStorage({
|
||||
factory: () => new Redis('redis://localhost:6379')
|
||||
});
|
||||
// Automatically closed when scope ends
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
|
||||
- ✅ High-performance distributed locking
|
||||
- ✅ Fast read/write
|
||||
- ✅ Supports TTL auto-expiration
|
||||
- ✅ Suitable for high concurrency
|
||||
- ❌ Requires Redis server
|
||||
|
||||
### Distributed Lock Implementation
|
||||
|
||||
Uses Redis `SET NX EX` for distributed locking:
|
||||
|
||||
```typescript
|
||||
// Acquire lock (atomic operation)
|
||||
SET tx:lock:player:123 <token> NX EX 10
|
||||
|
||||
// Release lock (Lua script for atomicity)
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
```
|
||||
|
||||
### Key Structure
|
||||
|
||||
```
|
||||
tx:lock:{key} - Distributed locks
|
||||
tx:tx:{id} - Transaction logs
|
||||
tx:server:{id}:txs - Server transaction index
|
||||
tx:data:{key} - Business data
|
||||
```
|
||||
|
||||
## MongoStorage
|
||||
|
||||
MongoDB storage, suitable for scenarios requiring persistence and complex queries. Uses shared connection from `@esengine/database-drivers`.
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers';
|
||||
import { createMongoStorage, TransactionManager } from '@esengine/transaction';
|
||||
|
||||
// Create shared connection
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
});
|
||||
await mongo.connect();
|
||||
|
||||
// Create storage using shared connection
|
||||
const storage = createMongoStorage(mongo, {
|
||||
transactionCollection: 'transactions', // Transaction log collection (optional)
|
||||
dataCollection: 'transaction_data', // Business data collection (optional)
|
||||
lockCollection: 'transaction_locks', // Lock collection (optional)
|
||||
});
|
||||
|
||||
// Create indexes (run on first startup)
|
||||
await storage.ensureIndexes();
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
|
||||
// Close storage (does not close shared connection)
|
||||
await storage.close();
|
||||
|
||||
// Shared connection can continue to be used by other modules
|
||||
const userRepo = new UserRepository(mongo); // @esengine/database
|
||||
|
||||
// Finally close the shared connection
|
||||
await mongo.disconnect();
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
|
||||
- ✅ Persistent storage
|
||||
- ✅ Supports complex queries
|
||||
- ✅ Transaction logs are traceable
|
||||
- ✅ Suitable for audit requirements
|
||||
- ❌ Slightly lower performance than Redis
|
||||
- ❌ Requires MongoDB server
|
||||
|
||||
### Index Structure
|
||||
|
||||
```javascript
|
||||
// transactions collection
|
||||
{ state: 1 }
|
||||
{ 'metadata.serverId': 1 }
|
||||
{ createdAt: 1 }
|
||||
|
||||
// transaction_locks collection
|
||||
{ expireAt: 1 } // TTL index
|
||||
|
||||
// transaction_data collection
|
||||
{ expireAt: 1 } // TTL index
|
||||
```
|
||||
|
||||
### Distributed Lock Implementation
|
||||
|
||||
Uses MongoDB unique index for distributed locking:
|
||||
|
||||
```typescript
|
||||
// Acquire lock
|
||||
db.transaction_locks.insertOne({
|
||||
_id: 'player:123',
|
||||
token: '<token>',
|
||||
expireAt: new Date(Date.now() + 10000)
|
||||
});
|
||||
|
||||
// If key exists, check if expired
|
||||
db.transaction_locks.updateOne(
|
||||
{ _id: 'player:123', expireAt: { $lt: new Date() } },
|
||||
{ $set: { token: '<token>', expireAt: new Date(Date.now() + 10000) } }
|
||||
);
|
||||
```
|
||||
|
||||
## Storage Selection Guide
|
||||
|
||||
| Scenario | Recommended Storage | Reason |
|
||||
|----------|---------------------|--------|
|
||||
| Development/Testing | MemoryStorage | No dependencies, fast startup |
|
||||
| Single-machine Production | RedisStorage | High performance, simple |
|
||||
| Distributed System | RedisStorage | True distributed locking |
|
||||
| Audit Required | MongoStorage | Persistent logs |
|
||||
| Mixed Requirements | Redis + Mongo | Redis for locks, Mongo for logs |
|
||||
|
||||
## Custom Storage
|
||||
|
||||
Implement `ITransactionStorage` interface to create custom storage:
|
||||
|
||||
```typescript
|
||||
import { ITransactionStorage, TransactionLog, TransactionState } from '@esengine/transaction';
|
||||
|
||||
class MyCustomStorage implements ITransactionStorage {
|
||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||
// Implement distributed lock acquisition
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
// Implement distributed lock release
|
||||
}
|
||||
|
||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||
// Save transaction log
|
||||
}
|
||||
|
||||
// ... implement other methods
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: "Chunk Manager API"
|
||||
description: "ChunkManager handles chunk lifecycle, loading queue, and spatial queries"
|
||||
---
|
||||
|
||||
The `ChunkManager` is the core service responsible for managing chunk lifecycle, including loading, unloading, and spatial queries.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
// Create manager with 512-unit chunks
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// Set data provider for loading chunks
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// Set event callbacks
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
console.log(`Chunk (${coord.x}, ${coord.y}) loaded with ${entities.length} entities`);
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
console.log(`Chunk (${coord.x}, ${coord.y}) unloaded`);
|
||||
},
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`Failed to load chunk (${coord.x}, ${coord.y}):`, error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Loading and Unloading
|
||||
|
||||
### Request Loading
|
||||
|
||||
```typescript
|
||||
import { EChunkPriority } from '@esengine/world-streaming';
|
||||
|
||||
// Request with priority
|
||||
chunkManager.requestLoad({ x: 0, y: 0 }, EChunkPriority.Immediate);
|
||||
chunkManager.requestLoad({ x: 1, y: 0 }, EChunkPriority.High);
|
||||
chunkManager.requestLoad({ x: 2, y: 0 }, EChunkPriority.Normal);
|
||||
chunkManager.requestLoad({ x: 3, y: 0 }, EChunkPriority.Low);
|
||||
chunkManager.requestLoad({ x: 4, y: 0 }, EChunkPriority.Prefetch);
|
||||
```
|
||||
|
||||
### Priority Levels
|
||||
|
||||
| Priority | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `Immediate` | 0 | Current chunk (player standing on) |
|
||||
| `High` | 1 | Adjacent chunks |
|
||||
| `Normal` | 2 | Nearby chunks |
|
||||
| `Low` | 3 | Distant visible chunks |
|
||||
| `Prefetch` | 4 | Movement direction prefetch |
|
||||
|
||||
### Request Unloading
|
||||
|
||||
```typescript
|
||||
// Request unload with 3 second delay
|
||||
chunkManager.requestUnload({ x: 5, y: 5 }, 3000);
|
||||
|
||||
// Cancel pending unload (player moved back)
|
||||
chunkManager.cancelUnload({ x: 5, y: 5 });
|
||||
```
|
||||
|
||||
### Process Queues
|
||||
|
||||
```typescript
|
||||
// In your update loop or system
|
||||
await chunkManager.processLoads(2); // Load up to 2 chunks per frame
|
||||
chunkManager.processUnloads(1); // Unload up to 1 chunk per frame
|
||||
```
|
||||
|
||||
## Spatial Queries
|
||||
|
||||
### Coordinate Conversion
|
||||
|
||||
```typescript
|
||||
// World position to chunk coordinates
|
||||
const coord = chunkManager.worldToChunk(1500, 2300);
|
||||
// Result: { x: 2, y: 4 } for 512-unit chunks
|
||||
|
||||
// Get chunk bounds in world space
|
||||
const bounds = chunkManager.getChunkBounds({ x: 2, y: 4 });
|
||||
// Result: { minX: 1024, minY: 2048, maxX: 1536, maxY: 2560 }
|
||||
```
|
||||
|
||||
### Chunk Queries
|
||||
|
||||
```typescript
|
||||
// Check if chunk is loaded
|
||||
if (chunkManager.isChunkLoaded({ x: 0, y: 0 })) {
|
||||
const chunk = chunkManager.getChunk({ x: 0, y: 0 });
|
||||
console.log('Entities:', chunk.entities.length);
|
||||
}
|
||||
|
||||
// Get missing chunks in radius
|
||||
const missing = chunkManager.getMissingChunks({ x: 0, y: 0 }, 2);
|
||||
for (const coord of missing) {
|
||||
chunkManager.requestLoad(coord);
|
||||
}
|
||||
|
||||
// Get chunks outside radius (for unloading)
|
||||
const outside = chunkManager.getChunksOutsideRadius({ x: 0, y: 0 }, 4);
|
||||
for (const coord of outside) {
|
||||
chunkManager.requestUnload(coord, 3000);
|
||||
}
|
||||
|
||||
// Iterate all loaded chunks
|
||||
chunkManager.forEachChunk((info, coord) => {
|
||||
console.log(`Chunk (${coord.x}, ${coord.y}): ${info.state}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Statistics
|
||||
|
||||
```typescript
|
||||
console.log('Loaded chunks:', chunkManager.loadedChunkCount);
|
||||
console.log('Pending loads:', chunkManager.pendingLoadCount);
|
||||
console.log('Pending unloads:', chunkManager.pendingUnloadCount);
|
||||
console.log('Chunk size:', chunkManager.chunkSize);
|
||||
```
|
||||
|
||||
## Chunk States
|
||||
|
||||
```typescript
|
||||
import { EChunkState } from '@esengine/world-streaming';
|
||||
|
||||
// Chunk lifecycle states
|
||||
EChunkState.Unloaded // Not in memory
|
||||
EChunkState.Loading // Being loaded
|
||||
EChunkState.Loaded // Ready for use
|
||||
EChunkState.Unloading // Being removed
|
||||
EChunkState.Failed // Load failed
|
||||
```
|
||||
|
||||
## Data Provider Interface
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class MyChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// Load from database, file, or procedural generation
|
||||
const data = await fetchChunkFromServer(coord);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// Save modified chunks
|
||||
await saveChunkToServer(data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```typescript
|
||||
// Unload all chunks
|
||||
chunkManager.clear();
|
||||
|
||||
// Full disposal (implements IService)
|
||||
chunkManager.dispose();
|
||||
```
|
||||
330
docs/src/content/docs/en/modules/world-streaming/examples.md
Normal file
330
docs/src/content/docs/en/modules/world-streaming/examples.md
Normal file
@@ -0,0 +1,330 @@
|
||||
---
|
||||
title: "Examples"
|
||||
description: "Practical examples of world streaming"
|
||||
---
|
||||
|
||||
## Infinite Procedural World
|
||||
|
||||
An infinite world with procedural resource generation.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
// Procedural world generator
|
||||
class WorldGenerator implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
private nextEntityId = 1;
|
||||
|
||||
constructor(seed: number = 12345) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const rng = this.createChunkRNG(coord);
|
||||
const entities = [];
|
||||
|
||||
// Generate 5-15 resources per chunk
|
||||
const resourceCount = 5 + Math.floor(rng() * 10);
|
||||
|
||||
for (let i = 0; i < resourceCount; i++) {
|
||||
const type = this.randomResourceType(rng);
|
||||
|
||||
entities.push({
|
||||
name: `Resource_${this.nextEntityId++}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
ResourceNode: {
|
||||
type,
|
||||
amount: this.getResourceAmount(type, rng),
|
||||
regenRate: this.getRegenRate(type)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { coord, entities, version: 1 };
|
||||
}
|
||||
|
||||
async saveChunkData(_data: IChunkData): Promise<void> {
|
||||
// Procedural - no persistence needed
|
||||
}
|
||||
|
||||
private createChunkRNG(coord: IChunkCoord) {
|
||||
let seed = this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private randomResourceType(rng: () => number) {
|
||||
const types = ['energyWell', 'oreVein', 'crystalDeposit'];
|
||||
const weights = [0.5, 0.35, 0.15];
|
||||
|
||||
let random = rng();
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
random -= weights[i];
|
||||
if (random <= 0) return types[i];
|
||||
}
|
||||
return types[0];
|
||||
}
|
||||
|
||||
private getResourceAmount(type: string, rng: () => number) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 300 + Math.floor(rng() * 200);
|
||||
case 'oreVein': return 500 + Math.floor(rng() * 300);
|
||||
case 'crystalDeposit': return 100 + Math.floor(rng() * 100);
|
||||
default: return 100;
|
||||
}
|
||||
}
|
||||
|
||||
private getRegenRate(type: string) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 2;
|
||||
case 'oreVein': return 1;
|
||||
case 'crystalDeposit': return 0.2;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(new WorldGenerator(12345));
|
||||
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
```
|
||||
|
||||
## MMO Server Chunks
|
||||
|
||||
Server-side chunk management for MMO with database persistence.
|
||||
|
||||
```typescript
|
||||
class ServerChunkProvider implements IChunkDataProvider {
|
||||
private db: Database;
|
||||
private cache = new Map<string, IChunkData>();
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
|
||||
// Check cache
|
||||
if (this.cache.has(key)) {
|
||||
return this.cache.get(key)!;
|
||||
}
|
||||
|
||||
// Load from database
|
||||
const row = await this.db.query(
|
||||
'SELECT data FROM chunks WHERE x = ? AND y = ?',
|
||||
[coord.x, coord.y]
|
||||
);
|
||||
|
||||
if (row) {
|
||||
const data = JSON.parse(row.data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Generate new chunk
|
||||
const data = this.generateChunk(coord);
|
||||
await this.saveChunkData(data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `${data.coord.x},${data.coord.y}`;
|
||||
this.cache.set(key, data);
|
||||
|
||||
await this.db.query(
|
||||
`INSERT INTO chunks (x, y, data) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE data = VALUES(data)`,
|
||||
[data.coord.x, data.coord.y, JSON.stringify(data)]
|
||||
);
|
||||
}
|
||||
|
||||
private generateChunk(coord: IChunkCoord): IChunkData {
|
||||
// Procedural generation for new chunks
|
||||
return { coord, entities: [], version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// Per-player chunk loading on server
|
||||
class PlayerChunkManager {
|
||||
private chunkManager: ChunkManager;
|
||||
private playerChunks = new Map<string, Set<string>>();
|
||||
|
||||
async updatePlayerPosition(playerId: string, x: number, y: number) {
|
||||
const centerCoord = this.chunkManager.worldToChunk(x, y);
|
||||
const loadRadius = 2;
|
||||
|
||||
const newChunks = new Set<string>();
|
||||
|
||||
// Load chunks around player
|
||||
for (let dx = -loadRadius; dx <= loadRadius; dx++) {
|
||||
for (let dy = -loadRadius; dy <= loadRadius; dy++) {
|
||||
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
newChunks.add(key);
|
||||
|
||||
if (!this.chunkManager.isChunkLoaded(coord)) {
|
||||
await this.chunkManager.requestLoad(coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track player's loaded chunks
|
||||
this.playerChunks.set(playerId, newChunks);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tile-Based World
|
||||
|
||||
Tilemap integration with chunk streaming.
|
||||
|
||||
```typescript
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
|
||||
class TilemapChunkProvider implements IChunkDataProvider {
|
||||
private tilemapData: number[][]; // Full tilemap
|
||||
private tileSize = 32;
|
||||
private chunkTiles = 16; // 16x16 tiles per chunk
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const startTileX = coord.x * this.chunkTiles;
|
||||
const startTileY = coord.y * this.chunkTiles;
|
||||
|
||||
// Extract tiles for this chunk
|
||||
const tiles: number[][] = [];
|
||||
for (let y = 0; y < this.chunkTiles; y++) {
|
||||
const row: number[] = [];
|
||||
for (let x = 0; x < this.chunkTiles; x++) {
|
||||
const tileX = startTileX + x;
|
||||
const tileY = startTileY + y;
|
||||
row.push(this.getTile(tileX, tileY));
|
||||
}
|
||||
tiles.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities: [{
|
||||
name: `TileChunk_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 0, y: 0 },
|
||||
components: {
|
||||
TilemapChunk: { tiles }
|
||||
}
|
||||
}],
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private getTile(x: number, y: number): number {
|
||||
if (x < 0 || y < 0 || y >= this.tilemapData.length) {
|
||||
return 0; // Out of bounds = empty
|
||||
}
|
||||
return this.tilemapData[y]?.[x] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom serializer for tilemap
|
||||
class TilemapSerializer extends ChunkSerializer {
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.TilemapChunk) {
|
||||
const data = components.TilemapChunk as { tiles: number[][] };
|
||||
const tilemap = entity.addComponent(new TilemapComponent());
|
||||
tilemap.loadTiles(data.tiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Loading Events
|
||||
|
||||
React to chunk loading for gameplay.
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// Enable physics
|
||||
for (const entity of entities) {
|
||||
const collider = entity.getComponent(ColliderComponent);
|
||||
collider?.enable();
|
||||
}
|
||||
|
||||
// Spawn NPCs for loaded chunks
|
||||
npcManager.spawnForChunk(coord);
|
||||
|
||||
// Update fog of war
|
||||
fogOfWar.revealChunk(coord);
|
||||
|
||||
// Notify clients (server)
|
||||
broadcast('ChunkLoaded', { coord, entityCount: entities.length });
|
||||
},
|
||||
|
||||
onChunkUnloaded: (coord) => {
|
||||
// Save NPC states
|
||||
npcManager.saveAndRemoveForChunk(coord);
|
||||
|
||||
// Update fog
|
||||
fogOfWar.hideChunk(coord);
|
||||
|
||||
// Notify clients
|
||||
broadcast('ChunkUnloaded', { coord });
|
||||
},
|
||||
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`Failed to load chunk ${coord.x},${coord.y}:`, error);
|
||||
|
||||
// Retry after delay
|
||||
setTimeout(() => {
|
||||
chunkManager.requestLoad(coord);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
```typescript
|
||||
// Adjust based on device performance
|
||||
function configureForDevice(loader: ChunkLoaderComponent) {
|
||||
const memory = navigator.deviceMemory ?? 4;
|
||||
const cores = navigator.hardwareConcurrency ?? 4;
|
||||
|
||||
if (memory <= 2 || cores <= 2) {
|
||||
// Low-end device
|
||||
loader.loadRadius = 1;
|
||||
loader.unloadRadius = 2;
|
||||
loader.maxLoadsPerFrame = 1;
|
||||
loader.bEnablePrefetch = false;
|
||||
} else if (memory <= 4) {
|
||||
// Mid-range
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 3;
|
||||
loader.maxLoadsPerFrame = 2;
|
||||
} else {
|
||||
// High-end
|
||||
loader.loadRadius = 3;
|
||||
loader.unloadRadius = 5;
|
||||
loader.maxLoadsPerFrame = 4;
|
||||
loader.prefetchRadius = 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
158
docs/src/content/docs/en/modules/world-streaming/index.md
Normal file
158
docs/src/content/docs/en/modules/world-streaming/index.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: "World Streaming"
|
||||
description: "Chunk-based world streaming for open world games"
|
||||
---
|
||||
|
||||
`@esengine/world-streaming` provides chunk-based world streaming and management for open world games. It handles dynamic loading/unloading of world chunks based on player position.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/world-streaming
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
StreamingAnchorComponent,
|
||||
ChunkLoaderComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// Create chunk manager (512 unit chunks)
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// Add streaming system
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// Create loader entity with config
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
|
||||
// Create player as streaming anchor
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// Update anchor position each frame
|
||||
function update() {
|
||||
anchor.x = player.position.x;
|
||||
anchor.y = player.position.y;
|
||||
}
|
||||
```
|
||||
|
||||
### Procedural Generation
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class ProceduralChunkProvider implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// Use deterministic random based on seed + coord
|
||||
const chunkSeed = this.hashCoord(coord);
|
||||
const rng = this.createRNG(chunkSeed);
|
||||
|
||||
// Generate chunk content
|
||||
const entities = this.generateEntities(coord, rng);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// Optional: persist modified chunks
|
||||
}
|
||||
|
||||
private hashCoord(coord: IChunkCoord): number {
|
||||
return this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
}
|
||||
|
||||
private createRNG(seed: number) {
|
||||
// Simple seeded random
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord, rng: () => number) {
|
||||
// Generate resources, trees, etc.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Use provider
|
||||
chunkManager.setDataProvider(new ProceduralChunkProvider(12345));
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Chunk Lifecycle
|
||||
|
||||
```
|
||||
Unloaded → Loading → Loaded → Unloading → Unloaded
|
||||
↓ ↓
|
||||
Failed (on error)
|
||||
```
|
||||
|
||||
### Streaming Anchor
|
||||
|
||||
`StreamingAnchorComponent` marks entities as chunk loading anchors. The system loads chunks around all anchors and unloads chunks outside the combined range.
|
||||
|
||||
```typescript
|
||||
// StreamingAnchorComponent implements IPositionable
|
||||
interface IPositionable {
|
||||
readonly position: { x: number; y: number };
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Property | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `chunkSize` | 512 | Chunk size in world units |
|
||||
| `loadRadius` | 2 | Chunks to load around anchor |
|
||||
| `unloadRadius` | 4 | Chunks to unload beyond this |
|
||||
| `maxLoadsPerFrame` | 2 | Max async loads per frame |
|
||||
| `unloadDelay` | 3000 | MS before unloading |
|
||||
| `bEnablePrefetch` | true | Prefetch in movement direction |
|
||||
|
||||
## Module Setup (Optional)
|
||||
|
||||
For quick setup, use the module helper:
|
||||
|
||||
```typescript
|
||||
import { worldStreamingModule } from '@esengine/world-streaming';
|
||||
|
||||
const chunkManager = worldStreamingModule.setup(
|
||||
scene,
|
||||
services,
|
||||
componentRegistry,
|
||||
{ chunkSize: 256, bEnableCulling: true }
|
||||
);
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Chunk Manager API](./chunk-manager) - Loading queue, chunk lifecycle
|
||||
- [Streaming System](./streaming-system) - Anchor-based loading
|
||||
- [Serialization](./serialization) - Custom chunk serialization
|
||||
- [Examples](./examples) - Procedural worlds, MMO chunks
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: "Chunk Serialization"
|
||||
description: "Custom serialization for chunk data"
|
||||
---
|
||||
|
||||
The `ChunkSerializer` handles converting between entity data and chunk storage format.
|
||||
|
||||
## Default Serializer
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer, ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
const serializer = new ChunkSerializer();
|
||||
const chunkManager = new ChunkManager(512, serializer);
|
||||
```
|
||||
|
||||
## Custom Serializer
|
||||
|
||||
Override `ChunkSerializer` for custom serialization logic:
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer } from '@esengine/world-streaming';
|
||||
import type { Entity, IScene } from '@esengine/ecs-framework';
|
||||
import type { IChunkCoord, IChunkData, IChunkBounds } from '@esengine/world-streaming';
|
||||
|
||||
class GameChunkSerializer extends ChunkSerializer {
|
||||
/**
|
||||
* Get position from entity
|
||||
* Override to use your position component
|
||||
*/
|
||||
protected getPositionable(entity: Entity) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
return { position: { x: transform.x, y: transform.y } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set position on entity after deserialization
|
||||
*/
|
||||
protected setEntityPosition(entity: Entity, x: number, y: number): void {
|
||||
const transform = entity.addComponent(new TransformComponent());
|
||||
transform.x = x;
|
||||
transform.y = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize components
|
||||
*/
|
||||
protected serializeComponents(entity: Entity): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
const resource = entity.getComponent(ResourceComponent);
|
||||
if (resource) {
|
||||
data.ResourceComponent = {
|
||||
type: resource.type,
|
||||
amount: resource.amount,
|
||||
maxAmount: resource.maxAmount
|
||||
};
|
||||
}
|
||||
|
||||
const npc = entity.getComponent(NPCComponent);
|
||||
if (npc) {
|
||||
data.NPCComponent = {
|
||||
id: npc.id,
|
||||
state: npc.state
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize components
|
||||
*/
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.ResourceComponent) {
|
||||
const data = components.ResourceComponent as any;
|
||||
const resource = entity.addComponent(new ResourceComponent());
|
||||
resource.type = data.type;
|
||||
resource.amount = data.amount;
|
||||
resource.maxAmount = data.maxAmount;
|
||||
}
|
||||
|
||||
if (components.NPCComponent) {
|
||||
const data = components.NPCComponent as any;
|
||||
const npc = entity.addComponent(new NPCComponent());
|
||||
npc.id = data.id;
|
||||
npc.state = data.state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter which components to serialize
|
||||
*/
|
||||
protected shouldSerializeComponent(componentName: string): boolean {
|
||||
const include = ['ResourceComponent', 'NPCComponent', 'BuildingComponent'];
|
||||
return include.includes(componentName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Chunk Data Format
|
||||
|
||||
```typescript
|
||||
interface IChunkData {
|
||||
coord: IChunkCoord; // Chunk coordinates
|
||||
entities: ISerializedEntity[]; // Entity data
|
||||
version: number; // Data version
|
||||
}
|
||||
|
||||
interface ISerializedEntity {
|
||||
name: string; // Entity name
|
||||
localPosition: { x: number; y: number }; // Position within chunk
|
||||
components: Record<string, unknown>; // Component data
|
||||
}
|
||||
|
||||
interface IChunkCoord {
|
||||
x: number; // Chunk X coordinate
|
||||
y: number; // Chunk Y coordinate
|
||||
}
|
||||
```
|
||||
|
||||
## Data Provider with Serialization
|
||||
|
||||
```typescript
|
||||
class DatabaseChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `chunk_${coord.x}_${coord.y}`;
|
||||
const json = await database.get(key);
|
||||
|
||||
if (!json) return null;
|
||||
return JSON.parse(json) as IChunkData;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `chunk_${data.coord.x}_${data.coord.y}`;
|
||||
await database.set(key, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Procedural Generation with Serializer
|
||||
|
||||
```typescript
|
||||
class ProceduralProvider implements IChunkDataProvider {
|
||||
private serializer: GameChunkSerializer;
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const entities = this.generateEntities(coord);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord): ISerializedEntity[] {
|
||||
const entities: ISerializedEntity[] = [];
|
||||
const rng = this.createRNG(coord);
|
||||
|
||||
// Generate trees
|
||||
const treeCount = Math.floor(rng() * 10);
|
||||
for (let i = 0; i < treeCount; i++) {
|
||||
entities.push({
|
||||
name: `Tree_${coord.x}_${coord.y}_${i}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
TreeComponent: { type: 'oak', health: 100 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate resources
|
||||
if (rng() > 0.7) {
|
||||
entities.push({
|
||||
name: `Resource_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 256, y: 256 },
|
||||
components: {
|
||||
ResourceComponent: {
|
||||
type: 'iron',
|
||||
amount: 500,
|
||||
maxAmount: 500
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Version Migration
|
||||
|
||||
```typescript
|
||||
class VersionedSerializer extends ChunkSerializer {
|
||||
private static readonly CURRENT_VERSION = 2;
|
||||
|
||||
deserialize(data: IChunkData, scene: IScene): Entity[] {
|
||||
// Migrate old data
|
||||
if (data.version < 2) {
|
||||
data = this.migrateV1toV2(data);
|
||||
}
|
||||
|
||||
return super.deserialize(data, scene);
|
||||
}
|
||||
|
||||
private migrateV1toV2(data: IChunkData): IChunkData {
|
||||
// Convert old component format
|
||||
for (const entity of data.entities) {
|
||||
if (entity.components.OldResource) {
|
||||
entity.components.ResourceComponent = entity.components.OldResource;
|
||||
delete entity.components.OldResource;
|
||||
}
|
||||
}
|
||||
|
||||
data.version = 2;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: "Streaming System"
|
||||
description: "ChunkStreamingSystem manages automatic chunk loading based on anchor positions"
|
||||
---
|
||||
|
||||
The `ChunkStreamingSystem` automatically manages chunk loading and unloading based on `StreamingAnchorComponent` positions.
|
||||
|
||||
## Setup
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// Create and configure chunk manager
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// Create streaming system
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// Create loader entity with configuration
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
```
|
||||
|
||||
## Streaming Anchor
|
||||
|
||||
The `StreamingAnchorComponent` marks entities as chunk loading anchors. Chunks are loaded around all anchors.
|
||||
|
||||
```typescript
|
||||
// Create player as streaming anchor
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// Update position each frame
|
||||
function update() {
|
||||
anchor.x = player.worldX;
|
||||
anchor.y = player.worldY;
|
||||
}
|
||||
```
|
||||
|
||||
### Anchor Properties
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `x` | number | 0 | World X position |
|
||||
| `y` | number | 0 | World Y position |
|
||||
| `weight` | number | 1.0 | Load radius multiplier |
|
||||
| `bEnablePrefetch` | boolean | true | Enable prefetch for this anchor |
|
||||
|
||||
### Multiple Anchors
|
||||
|
||||
```typescript
|
||||
// Main player - full load radius
|
||||
const playerAnchor = player.addComponent(new StreamingAnchorComponent());
|
||||
playerAnchor.weight = 1.0;
|
||||
|
||||
// Camera preview - smaller radius
|
||||
const cameraAnchor = camera.addComponent(new StreamingAnchorComponent());
|
||||
cameraAnchor.weight = 0.5; // Half the load radius
|
||||
cameraAnchor.bEnablePrefetch = false;
|
||||
```
|
||||
|
||||
## Loader Configuration
|
||||
|
||||
The `ChunkLoaderComponent` configures streaming behavior.
|
||||
|
||||
```typescript
|
||||
const loader = entity.addComponent(new ChunkLoaderComponent());
|
||||
|
||||
// Chunk dimensions
|
||||
loader.chunkSize = 512; // World units per chunk
|
||||
|
||||
// Loading radius
|
||||
loader.loadRadius = 2; // Load chunks within 2 chunks of anchor
|
||||
loader.unloadRadius = 4; // Unload beyond 4 chunks
|
||||
|
||||
// Performance tuning
|
||||
loader.maxLoadsPerFrame = 2; // Max async loads per frame
|
||||
loader.maxUnloadsPerFrame = 1; // Max unloads per frame
|
||||
loader.unloadDelay = 3000; // MS before unloading
|
||||
|
||||
// Prefetch
|
||||
loader.bEnablePrefetch = true; // Enable movement-based prefetch
|
||||
loader.prefetchRadius = 1; // Extra chunks to prefetch
|
||||
```
|
||||
|
||||
### Coordinate Helpers
|
||||
|
||||
```typescript
|
||||
// Convert world position to chunk coordinates
|
||||
const coord = loader.worldToChunk(1500, 2300);
|
||||
|
||||
// Get chunk bounds
|
||||
const bounds = loader.getChunkBounds(coord);
|
||||
```
|
||||
|
||||
## Prefetch System
|
||||
|
||||
When enabled, the system prefetches chunks in the movement direction:
|
||||
|
||||
```
|
||||
Movement Direction →
|
||||
|
||||
[ ][ ][ ] [ ][P][P] P = Prefetch
|
||||
[L][L][L] → [L][L][L] L = Loaded
|
||||
[ ][ ][ ] [ ][ ][ ]
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Enable prefetch
|
||||
loader.bEnablePrefetch = true;
|
||||
loader.prefetchRadius = 2; // Prefetch 2 chunks ahead
|
||||
|
||||
// Per-anchor prefetch control
|
||||
anchor.bEnablePrefetch = true; // Enable for main player
|
||||
cameraAnchor.bEnablePrefetch = false; // Disable for camera
|
||||
```
|
||||
|
||||
## System Processing
|
||||
|
||||
The system runs each frame and:
|
||||
|
||||
1. Updates anchor velocities
|
||||
2. Requests loads for chunks in range
|
||||
3. Cancels unloads for chunks back in range
|
||||
4. Requests unloads for chunks outside range
|
||||
5. Processes load/unload queues
|
||||
|
||||
```typescript
|
||||
// Access the chunk manager from system
|
||||
const system = scene.getSystem(ChunkStreamingSystem);
|
||||
const manager = system?.chunkManager;
|
||||
|
||||
if (manager) {
|
||||
console.log('Loaded:', manager.loadedChunkCount);
|
||||
}
|
||||
```
|
||||
|
||||
## Priority-Based Loading
|
||||
|
||||
Chunks are loaded with priority based on distance:
|
||||
|
||||
| Distance | Priority | Description |
|
||||
|----------|----------|-------------|
|
||||
| 0 | Immediate | Player's current chunk |
|
||||
| 1 | High | Adjacent chunks |
|
||||
| 2-4 | Normal | Nearby chunks |
|
||||
| 5+ | Low | Distant chunks |
|
||||
| Prefetch | Prefetch | Movement direction |
|
||||
|
||||
## Events
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// Chunk ready - spawn NPCs, enable collision
|
||||
for (const entity of entities) {
|
||||
entity.getComponent(ColliderComponent)?.enable();
|
||||
}
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
// Cleanup - save state, release resources
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -434,6 +434,6 @@ const found = hierarchySystem.findChild(parent, "Child");
|
||||
|
||||
## 下一步
|
||||
|
||||
- 了解 [实体类](./entity/) 的其他功能
|
||||
- 了解 [场景管理](./scene/) 如何组织实体和系统
|
||||
- 了解 [组件系统](./component/) 如何定义和使用组件
|
||||
- 了解 [实体类](/guide/entity/) 的其他功能
|
||||
- 了解 [场景管理](/guide/scene/) 如何组织实体和系统
|
||||
- 了解 [组件系统](/guide/component/) 如何定义和使用组件
|
||||
|
||||
363
docs/src/content/docs/guide/persistent-entity.md
Normal file
363
docs/src/content/docs/guide/persistent-entity.md
Normal file
@@ -0,0 +1,363 @@
|
||||
---
|
||||
title: "持久实体"
|
||||
---
|
||||
|
||||
# 持久实体
|
||||
|
||||
> **版本**: v2.3.0+
|
||||
|
||||
持久实体是一种特殊类型的实体,在场景切换时会自动迁移到新场景。适用于需要跨场景保持状态的游戏对象,如玩家、游戏管理器、音频管理器等。
|
||||
|
||||
## 基本概念
|
||||
|
||||
在 ECS 框架中,实体有两种生命周期策略:
|
||||
|
||||
| 策略 | 描述 | 默认 |
|
||||
|------|------|------|
|
||||
| `SceneLocal` | 场景局部实体,场景切换时销毁 | ✓ |
|
||||
| `Persistent` | 持久实体,场景切换时自动迁移 | |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 创建持久实体
|
||||
|
||||
```typescript
|
||||
import { Scene } from '@esengine/ecs-framework';
|
||||
|
||||
class GameScene extends Scene {
|
||||
protected initialize(): void {
|
||||
// 创建持久玩家实体
|
||||
const player = this.createEntity('Player').setPersistent();
|
||||
player.addComponent(new Position(100, 200));
|
||||
player.addComponent(new PlayerData('Hero', 500));
|
||||
|
||||
// 创建普通敌人实体(场景切换时销毁)
|
||||
const enemy = this.createEntity('Enemy');
|
||||
enemy.addComponent(new Position(300, 200));
|
||||
enemy.addComponent(new EnemyAI());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景切换时的行为
|
||||
|
||||
```typescript
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
|
||||
// 初始场景
|
||||
class Level1Scene extends Scene {
|
||||
protected initialize(): void {
|
||||
// 玩家 - 持久实体,将迁移到下一个场景
|
||||
const player = this.createEntity('Player').setPersistent();
|
||||
player.addComponent(new Position(0, 0));
|
||||
player.addComponent(new Health(100));
|
||||
|
||||
// 敌人 - 场景局部实体,场景切换时销毁
|
||||
const enemy = this.createEntity('Enemy');
|
||||
enemy.addComponent(new Position(100, 100));
|
||||
}
|
||||
}
|
||||
|
||||
// 目标场景
|
||||
class Level2Scene extends Scene {
|
||||
protected initialize(): void {
|
||||
// 新敌人
|
||||
const enemy = this.createEntity('Boss');
|
||||
enemy.addComponent(new Position(200, 200));
|
||||
}
|
||||
|
||||
public onStart(): void {
|
||||
// 玩家已自动迁移到此场景
|
||||
const player = this.findEntity('Player');
|
||||
console.log(player !== null); // true
|
||||
|
||||
// 位置和生命值数据完整保留
|
||||
const position = player?.getComponent(Position);
|
||||
const health = player?.getComponent(Health);
|
||||
console.log(position?.x, position?.y); // 0, 0
|
||||
console.log(health?.value); // 100
|
||||
}
|
||||
}
|
||||
|
||||
// 切换场景
|
||||
Core.create({ debug: true });
|
||||
Core.setScene(new Level1Scene());
|
||||
|
||||
// 稍后切换到 Level2
|
||||
Core.loadScene(new Level2Scene());
|
||||
// 玩家实体自动迁移,敌人实体被销毁
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### 实体方法
|
||||
|
||||
#### setPersistent()
|
||||
|
||||
将实体标记为持久实体,防止在场景切换时被销毁。
|
||||
|
||||
```typescript
|
||||
public setPersistent(): this
|
||||
```
|
||||
|
||||
**返回值**: 返回实体本身,支持链式调用
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
const player = scene.createEntity('Player')
|
||||
.setPersistent();
|
||||
|
||||
player.addComponent(new Position(100, 200));
|
||||
```
|
||||
|
||||
#### setSceneLocal()
|
||||
|
||||
将实体恢复为场景局部策略(默认)。
|
||||
|
||||
```typescript
|
||||
public setSceneLocal(): this
|
||||
```
|
||||
|
||||
**返回值**: 返回实体本身,支持链式调用
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
// 动态取消持久性
|
||||
player.setSceneLocal();
|
||||
```
|
||||
|
||||
#### isPersistent
|
||||
|
||||
检查实体是否为持久实体。
|
||||
|
||||
```typescript
|
||||
public get isPersistent(): boolean
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
if (entity.isPersistent) {
|
||||
console.log('这是一个持久实体');
|
||||
}
|
||||
```
|
||||
|
||||
#### lifecyclePolicy
|
||||
|
||||
获取实体的生命周期策略。
|
||||
|
||||
```typescript
|
||||
public get lifecyclePolicy(): EEntityLifecyclePolicy
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
import { EEntityLifecyclePolicy } from '@esengine/ecs-framework';
|
||||
|
||||
if (entity.lifecyclePolicy === EEntityLifecyclePolicy.Persistent) {
|
||||
console.log('持久实体');
|
||||
}
|
||||
```
|
||||
|
||||
### 场景方法
|
||||
|
||||
#### findPersistentEntities()
|
||||
|
||||
查找场景中所有持久实体。
|
||||
|
||||
```typescript
|
||||
public findPersistentEntities(): Entity[]
|
||||
```
|
||||
|
||||
**返回值**: 持久实体数组
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
const persistentEntities = scene.findPersistentEntities();
|
||||
console.log(`场景中有 ${persistentEntities.length} 个持久实体`);
|
||||
```
|
||||
|
||||
#### extractPersistentEntities()
|
||||
|
||||
提取并移除场景中所有持久实体(通常由框架内部调用)。
|
||||
|
||||
```typescript
|
||||
public extractPersistentEntities(): Entity[]
|
||||
```
|
||||
|
||||
**返回值**: 被提取的持久实体数组
|
||||
|
||||
#### receiveMigratedEntities()
|
||||
|
||||
接收迁移的实体(通常由框架内部调用)。
|
||||
|
||||
```typescript
|
||||
public receiveMigratedEntities(entities: Entity[]): void
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `entities` - 要接收的实体数组
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 1. 跨关卡的玩家实体
|
||||
|
||||
```typescript
|
||||
class PlayerSetupScene extends Scene {
|
||||
protected initialize(): void {
|
||||
// 玩家在所有关卡中保持状态
|
||||
const player = this.createEntity('Player').setPersistent();
|
||||
player.addComponent(new Transform(0, 0));
|
||||
player.addComponent(new Health(100));
|
||||
player.addComponent(new Inventory());
|
||||
player.addComponent(new PlayerStats());
|
||||
}
|
||||
}
|
||||
|
||||
class Level1 extends Scene { /* ... */ }
|
||||
class Level2 extends Scene { /* ... */ }
|
||||
class Level3 extends Scene { /* ... */ }
|
||||
|
||||
// 玩家实体在所有关卡之间自动迁移
|
||||
Core.setScene(new PlayerSetupScene());
|
||||
// ... 游戏进行
|
||||
Core.loadScene(new Level1());
|
||||
// ... 关卡完成
|
||||
Core.loadScene(new Level2());
|
||||
// 玩家数据(生命值、背包、属性)完整保留
|
||||
```
|
||||
|
||||
### 2. 全局管理器
|
||||
|
||||
```typescript
|
||||
class BootstrapScene extends Scene {
|
||||
protected initialize(): void {
|
||||
// 音频管理器 - 跨场景持久
|
||||
const audioManager = this.createEntity('AudioManager').setPersistent();
|
||||
audioManager.addComponent(new AudioController());
|
||||
|
||||
// 成就管理器 - 跨场景持久
|
||||
const achievementManager = this.createEntity('AchievementManager').setPersistent();
|
||||
achievementManager.addComponent(new AchievementTracker());
|
||||
|
||||
// 游戏设置 - 跨场景持久
|
||||
const settings = this.createEntity('GameSettings').setPersistent();
|
||||
settings.addComponent(new SettingsData());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 动态切换持久性
|
||||
|
||||
```typescript
|
||||
class GameScene extends Scene {
|
||||
protected initialize(): void {
|
||||
// 初始创建为普通实体
|
||||
const companion = this.createEntity('Companion');
|
||||
companion.addComponent(new Transform(0, 0));
|
||||
companion.addComponent(new CompanionAI());
|
||||
|
||||
// 监听招募事件
|
||||
this.eventSystem.on('companion:recruited', () => {
|
||||
// 招募后变为持久实体
|
||||
companion.setPersistent();
|
||||
console.log('同伴加入队伍,将跟随玩家跨场景');
|
||||
});
|
||||
|
||||
// 监听解散事件
|
||||
this.eventSystem.on('companion:dismissed', () => {
|
||||
// 解散后恢复为场景局部实体
|
||||
companion.setSceneLocal();
|
||||
console.log('同伴离开队伍,不再跨场景持久');
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 明确标识持久实体
|
||||
|
||||
```typescript
|
||||
// 推荐:创建时立即标记
|
||||
const player = this.createEntity('Player').setPersistent();
|
||||
|
||||
// 不推荐:创建后再标记(容易遗忘)
|
||||
const player = this.createEntity('Player');
|
||||
// ... 大量代码 ...
|
||||
player.setPersistent(); // 容易忘记
|
||||
```
|
||||
|
||||
### 2. 合理使用持久性
|
||||
|
||||
```typescript
|
||||
// ✓ 适合持久化的实体
|
||||
const player = this.createEntity('Player').setPersistent(); // 玩家
|
||||
const gameManager = this.createEntity('GameManager').setPersistent(); // 全局管理器
|
||||
const audioManager = this.createEntity('AudioManager').setPersistent(); // 音频系统
|
||||
|
||||
// ✗ 不应该持久化的实体
|
||||
const bullet = this.createEntity('Bullet'); // 临时对象
|
||||
const enemy = this.createEntity('Enemy'); // 关卡特定敌人
|
||||
const particle = this.createEntity('Particle'); // 特效粒子
|
||||
```
|
||||
|
||||
### 3. 检查迁移的实体
|
||||
|
||||
```typescript
|
||||
class NewScene extends Scene {
|
||||
public onStart(): void {
|
||||
// 检查预期的持久实体是否存在
|
||||
const player = this.findEntity('Player');
|
||||
if (!player) {
|
||||
console.error('玩家实体未正确迁移!');
|
||||
// 处理错误情况
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 避免循环引用
|
||||
|
||||
```typescript
|
||||
// ✗ 避免:持久实体引用场景局部实体
|
||||
class BadScene extends Scene {
|
||||
protected initialize(): void {
|
||||
const player = this.createEntity('Player').setPersistent();
|
||||
const enemy = this.createEntity('Enemy');
|
||||
|
||||
// 危险:player 是持久的但 enemy 不是
|
||||
// 场景切换后,enemy 被销毁,引用变为无效
|
||||
player.addComponent(new TargetComponent(enemy));
|
||||
}
|
||||
}
|
||||
|
||||
// ✓ 推荐:使用 ID 引用或事件系统
|
||||
class GoodScene extends Scene {
|
||||
protected initialize(): void {
|
||||
const player = this.createEntity('Player').setPersistent();
|
||||
const enemy = this.createEntity('Enemy');
|
||||
|
||||
// 存储 ID 而非直接引用
|
||||
player.addComponent(new TargetComponent(enemy.id));
|
||||
|
||||
// 或使用事件系统通信
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 重要说明
|
||||
|
||||
1. **已销毁的实体不会迁移**:如果实体在场景切换前被销毁,即使标记为持久也不会迁移。
|
||||
|
||||
2. **组件数据完整保留**:迁移过程中所有组件及其状态都会被保留。
|
||||
|
||||
3. **场景引用会更新**:迁移后,实体的 `scene` 属性将指向新场景。
|
||||
|
||||
4. **查询系统会更新**:迁移的实体会自动注册到新场景的查询系统中。
|
||||
|
||||
5. **延迟切换同样有效**:使用 `Core.loadScene()` 进行延迟切换时,持久实体同样会迁移。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [场景](/guide/scene/) - 了解场景基础知识
|
||||
- [场景管理器](/guide/scene-manager/) - 了解场景切换
|
||||
@@ -16,7 +16,7 @@ ECS框架提供了平台适配器接口,允许用户为不同的运行环境
|
||||
|
||||
## 支持的平台
|
||||
|
||||
### 🌐 [浏览器适配器](./platform-adapter/browser/)
|
||||
### 🌐 [浏览器适配器](/guide/platform-adapter/browser/)
|
||||
|
||||
支持所有现代浏览器环境,包括 Chrome、Firefox、Safari、Edge 等。
|
||||
|
||||
@@ -30,7 +30,7 @@ ECS框架提供了平台适配器接口,允许用户为不同的运行环境
|
||||
|
||||
---
|
||||
|
||||
### 📱 [微信小游戏适配器](./platform-adapter/wechat-minigame/)
|
||||
### 📱 [微信小游戏适配器](/guide/platform-adapter/wechat-minigame/)
|
||||
|
||||
专为微信小游戏环境设计,处理微信小游戏的特殊限制和API。
|
||||
|
||||
@@ -44,7 +44,7 @@ ECS框架提供了平台适配器接口,允许用户为不同的运行环境
|
||||
|
||||
---
|
||||
|
||||
### 🖥️ [Node.js适配器](./platform-adapter/nodejs/)
|
||||
### 🖥️ [Node.js适配器](/guide/platform-adapter/nodejs/)
|
||||
|
||||
为 Node.js 服务器环境提供支持,适用于游戏服务器和计算服务器。
|
||||
|
||||
|
||||
439
docs/src/content/docs/guide/scene-manager.md
Normal file
439
docs/src/content/docs/guide/scene-manager.md
Normal file
@@ -0,0 +1,439 @@
|
||||
---
|
||||
title: "场景管理器"
|
||||
---
|
||||
|
||||
# SceneManager
|
||||
|
||||
SceneManager 是 ECS Framework 提供的轻量级场景管理器,适用于 95% 的游戏应用。它提供简单直观的 API,支持场景切换和延迟加载。
|
||||
|
||||
## 适用场景
|
||||
|
||||
SceneManager 适用于:
|
||||
- 单人游戏
|
||||
- 简单多人游戏
|
||||
- 移动游戏
|
||||
- 需要场景切换的游戏(菜单、游戏、暂停等)
|
||||
- 不需要多 World 隔离的项目
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 轻量级,零额外开销
|
||||
- 简单直观的 API
|
||||
- 支持延迟场景切换(避免在帧中途切换)
|
||||
- 自动 ECS 流式 API 管理
|
||||
- 自动场景生命周期处理
|
||||
- 与 Core 集成,自动更新
|
||||
- 支持 [持久实体](/guide/persistent-entity/) 跨场景迁移(v2.3.0+)
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 推荐:使用 Core 的静态方法
|
||||
|
||||
这是最简单且推荐的方式,适用于大多数应用:
|
||||
|
||||
```typescript
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
|
||||
// 1. 初始化 Core
|
||||
Core.create({ debug: true });
|
||||
|
||||
// 2. 创建并设置场景
|
||||
class GameScene extends Scene {
|
||||
protected initialize(): void {
|
||||
this.name = "GameScene";
|
||||
|
||||
// 添加系统
|
||||
this.addSystem(new MovementSystem());
|
||||
this.addSystem(new RenderSystem());
|
||||
|
||||
// 创建初始实体
|
||||
const player = this.createEntity("Player");
|
||||
player.addComponent(new Transform(400, 300));
|
||||
player.addComponent(new Health(100));
|
||||
}
|
||||
|
||||
public onStart(): void {
|
||||
console.log("游戏场景已启动");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 设置场景
|
||||
Core.setScene(new GameScene());
|
||||
|
||||
// 4. 游戏循环(Core.update 自动更新场景)
|
||||
function gameLoop(deltaTime: number) {
|
||||
Core.update(deltaTime); // 自动更新所有服务和场景
|
||||
}
|
||||
|
||||
// Laya 引擎集成
|
||||
Laya.timer.frameLoop(1, this, () => {
|
||||
const deltaTime = Laya.timer.delta / 1000;
|
||||
Core.update(deltaTime);
|
||||
});
|
||||
|
||||
// Cocos Creator 集成
|
||||
update(deltaTime: number) {
|
||||
Core.update(deltaTime);
|
||||
}
|
||||
```
|
||||
|
||||
### 进阶:直接使用 SceneManager
|
||||
|
||||
如果需要更多控制,可以直接使用 SceneManager:
|
||||
|
||||
```typescript
|
||||
import { Core, SceneManager, Scene } from '@esengine/ecs-framework';
|
||||
|
||||
// 初始化 Core
|
||||
Core.create({ debug: true });
|
||||
|
||||
// 获取 SceneManager(已由 Core 自动创建并注册)
|
||||
const sceneManager = Core.services.resolve(SceneManager);
|
||||
|
||||
// 设置场景
|
||||
const gameScene = new GameScene();
|
||||
sceneManager.setScene(gameScene);
|
||||
|
||||
// 游戏循环(仍然使用 Core.update)
|
||||
function gameLoop(deltaTime: number) {
|
||||
Core.update(deltaTime); // Core 自动调用 sceneManager.update()
|
||||
}
|
||||
```
|
||||
|
||||
**重要提示**:无论使用哪种方式,在游戏循环中只需调用 `Core.update()`。它会自动更新 SceneManager 和场景。无需手动调用 `sceneManager.update()`。
|
||||
|
||||
## 场景切换
|
||||
|
||||
### 立即切换
|
||||
|
||||
使用 `Core.setScene()` 或 `sceneManager.setScene()` 立即切换场景:
|
||||
|
||||
```typescript
|
||||
// 方法 1:使用 Core(推荐)
|
||||
Core.setScene(new MenuScene());
|
||||
|
||||
// 方法 2:使用 SceneManager
|
||||
const sceneManager = Core.services.resolve(SceneManager);
|
||||
sceneManager.setScene(new MenuScene());
|
||||
```
|
||||
|
||||
### 延迟切换
|
||||
|
||||
使用 `Core.loadScene()` 或 `sceneManager.loadScene()` 进行延迟场景切换,在下一帧生效:
|
||||
|
||||
```typescript
|
||||
// 方法 1:使用 Core(推荐)
|
||||
Core.loadScene(new GameOverScene());
|
||||
|
||||
// 方法 2:使用 SceneManager
|
||||
const sceneManager = Core.services.resolve(SceneManager);
|
||||
sceneManager.loadScene(new GameOverScene());
|
||||
```
|
||||
|
||||
在 System 中切换场景时,使用延迟切换:
|
||||
|
||||
```typescript
|
||||
class GameOverSystem extends EntitySystem {
|
||||
process(entities: readonly Entity[]): void {
|
||||
const player = entities.find(e => e.name === 'Player');
|
||||
const health = player?.getComponent(Health);
|
||||
|
||||
if (health && health.value <= 0) {
|
||||
// 延迟切换到游戏结束场景(下一帧生效)
|
||||
Core.loadScene(new GameOverScene());
|
||||
// 当前帧继续执行,不会中断当前系统处理
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### Core 静态方法(推荐)
|
||||
|
||||
#### Core.setScene()
|
||||
|
||||
立即切换场景。
|
||||
|
||||
```typescript
|
||||
public static setScene<T extends IScene>(scene: T): T
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `scene` - 要设置的场景实例
|
||||
|
||||
**返回值**:
|
||||
- 返回设置的场景实例
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
const gameScene = Core.setScene(new GameScene());
|
||||
console.log(gameScene.name);
|
||||
```
|
||||
|
||||
#### Core.loadScene()
|
||||
|
||||
延迟场景加载(下一帧切换)。
|
||||
|
||||
```typescript
|
||||
public static loadScene<T extends IScene>(scene: T): void
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `scene` - 要加载的场景实例
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
Core.loadScene(new GameOverScene());
|
||||
```
|
||||
|
||||
#### Core.scene
|
||||
|
||||
获取当前活动场景。
|
||||
|
||||
```typescript
|
||||
public static get scene(): IScene | null
|
||||
```
|
||||
|
||||
**返回值**:
|
||||
- 当前场景实例,如果没有场景则返回 null
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
const currentScene = Core.scene;
|
||||
if (currentScene) {
|
||||
console.log(`当前场景: ${currentScene.name}`);
|
||||
}
|
||||
```
|
||||
|
||||
### SceneManager 方法(进阶)
|
||||
|
||||
如果需要直接使用 SceneManager,通过服务容器获取:
|
||||
|
||||
```typescript
|
||||
const sceneManager = Core.services.resolve(SceneManager);
|
||||
```
|
||||
|
||||
#### setScene()
|
||||
|
||||
立即切换场景。
|
||||
|
||||
```typescript
|
||||
public setScene<T extends IScene>(scene: T): T
|
||||
```
|
||||
|
||||
#### loadScene()
|
||||
|
||||
延迟场景加载。
|
||||
|
||||
```typescript
|
||||
public loadScene<T extends IScene>(scene: T): void
|
||||
```
|
||||
|
||||
#### currentScene
|
||||
|
||||
获取当前场景。
|
||||
|
||||
```typescript
|
||||
public get currentScene(): IScene | null
|
||||
```
|
||||
|
||||
#### hasScene
|
||||
|
||||
检查是否有活动场景。
|
||||
|
||||
```typescript
|
||||
public get hasScene(): boolean
|
||||
```
|
||||
|
||||
#### hasPendingScene
|
||||
|
||||
检查是否有待处理的场景切换。
|
||||
|
||||
```typescript
|
||||
public get hasPendingScene(): boolean
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 使用 Core 的静态方法
|
||||
|
||||
```typescript
|
||||
// 推荐:使用 Core 的静态方法
|
||||
Core.setScene(new GameScene());
|
||||
Core.loadScene(new MenuScene());
|
||||
const currentScene = Core.scene;
|
||||
|
||||
// 不推荐:除非有特殊需求,否则不要直接使用 SceneManager
|
||||
const sceneManager = Core.services.resolve(SceneManager);
|
||||
sceneManager.setScene(new GameScene());
|
||||
```
|
||||
|
||||
### 2. 只调用 Core.update()
|
||||
|
||||
```typescript
|
||||
// 正确:只调用 Core.update()
|
||||
function gameLoop(deltaTime: number) {
|
||||
Core.update(deltaTime); // 自动更新所有服务和场景
|
||||
}
|
||||
|
||||
// 错误:不要手动调用 sceneManager.update()
|
||||
function gameLoop(deltaTime: number) {
|
||||
Core.update(deltaTime);
|
||||
sceneManager.update(); // 重复更新,会导致问题!
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 使用延迟切换避免问题
|
||||
|
||||
在 System 中切换场景时,使用 `loadScene()` 而不是 `setScene()`:
|
||||
|
||||
```typescript
|
||||
// 推荐:延迟切换
|
||||
class HealthSystem extends EntitySystem {
|
||||
process(entities: readonly Entity[]): void {
|
||||
for (const entity of entities) {
|
||||
const health = entity.getComponent(Health);
|
||||
if (health.value <= 0) {
|
||||
Core.loadScene(new GameOverScene());
|
||||
// 当前帧继续处理其他实体
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不推荐:立即切换可能导致问题
|
||||
class HealthSystem extends EntitySystem {
|
||||
process(entities: readonly Entity[]): void {
|
||||
for (const entity of entities) {
|
||||
const health = entity.getComponent(Health);
|
||||
if (health.value <= 0) {
|
||||
Core.setScene(new GameOverScene());
|
||||
// 场景立即切换,当前帧其他实体可能无法正确处理
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 场景职责分离
|
||||
|
||||
每个场景应该只负责一个特定的游戏状态:
|
||||
|
||||
```typescript
|
||||
// 好的设计 - 职责清晰
|
||||
class MenuScene extends Scene {
|
||||
// 只处理菜单相关逻辑
|
||||
}
|
||||
|
||||
class GameScene extends Scene {
|
||||
// 只处理游戏逻辑
|
||||
}
|
||||
|
||||
class PauseScene extends Scene {
|
||||
// 只处理暂停界面逻辑
|
||||
}
|
||||
|
||||
// 避免这种设计 - 职责混杂
|
||||
class MegaScene extends Scene {
|
||||
// 包含菜单、游戏、暂停和所有其他逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 资源管理
|
||||
|
||||
在场景的 `unload()` 方法中清理资源:
|
||||
|
||||
```typescript
|
||||
class GameScene extends Scene {
|
||||
private textures: Map<string, any> = new Map();
|
||||
private sounds: Map<string, any> = new Map();
|
||||
|
||||
protected initialize(): void {
|
||||
this.loadResources();
|
||||
}
|
||||
|
||||
private loadResources(): void {
|
||||
this.textures.set('player', loadTexture('player.png'));
|
||||
this.sounds.set('bgm', loadSound('bgm.mp3'));
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
// 清理资源
|
||||
this.textures.clear();
|
||||
this.sounds.clear();
|
||||
console.log('场景资源已清理');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 事件驱动的场景切换
|
||||
|
||||
使用事件系统触发场景切换,保持代码解耦:
|
||||
|
||||
```typescript
|
||||
class GameScene extends Scene {
|
||||
protected initialize(): void {
|
||||
// 监听场景切换事件
|
||||
this.eventSystem.on('goto:menu', () => {
|
||||
Core.loadScene(new MenuScene());
|
||||
});
|
||||
|
||||
this.eventSystem.on('goto:gameover', (data) => {
|
||||
Core.loadScene(new GameOverScene());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 在 System 中触发事件
|
||||
class GameLogicSystem extends EntitySystem {
|
||||
process(entities: readonly Entity[]): void {
|
||||
if (levelComplete) {
|
||||
this.scene.eventSystem.emitSync('goto:gameover', {
|
||||
score: 1000,
|
||||
level: 5
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 架构概览
|
||||
|
||||
SceneManager 在 ECS Framework 中的位置:
|
||||
|
||||
```
|
||||
Core(全局服务)
|
||||
└── SceneManager(场景管理,自动更新)
|
||||
└── Scene(当前场景)
|
||||
├── EntitySystem(系统)
|
||||
├── Entity(实体)
|
||||
└── Component(组件)
|
||||
```
|
||||
|
||||
## 与 WorldManager 的比较
|
||||
|
||||
| 特性 | SceneManager | WorldManager |
|
||||
|------|--------------|--------------|
|
||||
| 适用场景 | 95% 的游戏应用 | 高级多世界隔离场景 |
|
||||
| 复杂度 | 简单 | 复杂 |
|
||||
| 场景数量 | 单场景(可切换) | 多个 World,每个包含多个场景 |
|
||||
| 性能开销 | 最小 | 较高 |
|
||||
| 使用方式 | `Core.setScene()` | `worldManager.createWorld()` |
|
||||
|
||||
**何时使用 SceneManager**:
|
||||
- 单人游戏
|
||||
- 简单多人游戏
|
||||
- 移动游戏
|
||||
- 需要切换但不需要同时运行的场景
|
||||
|
||||
**何时使用 WorldManager**:
|
||||
- MMO 游戏服务器(每个房间一个 World)
|
||||
- 游戏大厅系统(每个游戏房间完全隔离)
|
||||
- 需要运行多个完全独立的游戏实例
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [持久实体](/guide/persistent-entity/) - 了解如何在场景切换时保持实体
|
||||
|
||||
SceneManager 为大多数游戏提供了简单而强大的场景管理能力。通过 Core 的静态方法,你可以轻松管理场景切换。
|
||||
@@ -71,6 +71,55 @@ class ConfiguredScene extends Scene {
|
||||
}
|
||||
```
|
||||
|
||||
## 运行时环境
|
||||
|
||||
对于网络游戏,你可以配置运行时环境来区分服务端和客户端逻辑。
|
||||
|
||||
### 全局配置(推荐)
|
||||
|
||||
在 Core 层级设置一次运行时环境,所有场景都会继承此设置:
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
|
||||
// 方式1:在 Core.create() 中设置
|
||||
Core.create({ runtimeEnvironment: 'server' });
|
||||
|
||||
// 方式2:直接设置静态属性
|
||||
Core.runtimeEnvironment = 'server';
|
||||
```
|
||||
|
||||
### 单个场景覆盖
|
||||
|
||||
个别场景可以覆盖全局设置:
|
||||
|
||||
```typescript
|
||||
const clientScene = new Scene({ runtimeEnvironment: 'client' });
|
||||
```
|
||||
|
||||
### 环境类型
|
||||
|
||||
| 环境 | 使用场景 |
|
||||
|------|----------|
|
||||
| `'standalone'` | 单机游戏(默认) |
|
||||
| `'server'` | 游戏服务器,权威逻辑 |
|
||||
| `'client'` | 游戏客户端,渲染/输入 |
|
||||
|
||||
### 在系统中检查环境
|
||||
|
||||
```typescript
|
||||
class CollectibleSpawnSystem extends EntitySystem {
|
||||
private checkCollections(): void {
|
||||
// 客户端跳过 - 只有服务端处理权威逻辑
|
||||
if (!this.scene.isServer) return;
|
||||
|
||||
// 服务端权威生成逻辑...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
参见 [系统运行时装饰器](/guide/system/index#运行时环境装饰器) 了解基于装饰器的方式。
|
||||
|
||||
### 运行场景
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -160,6 +160,53 @@ scene.addSystem(new SystemA()); // addOrder = 0,先执行
|
||||
scene.addSystem(new SystemB()); // addOrder = 1,后执行
|
||||
```
|
||||
|
||||
## 运行时环境装饰器
|
||||
|
||||
对于网络游戏,你可以使用装饰器来控制系统方法在哪个环境下执行。
|
||||
|
||||
### 可用装饰器
|
||||
|
||||
| 装饰器 | 效果 |
|
||||
|--------|------|
|
||||
| `@ServerOnly()` | 方法仅在服务端执行 |
|
||||
| `@ClientOnly()` | 方法仅在客户端执行 |
|
||||
| `@NotServer()` | 方法在服务端跳过 |
|
||||
| `@NotClient()` | 方法在客户端跳过 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, ServerOnly, ClientOnly } from '@esengine/ecs-framework';
|
||||
|
||||
class GameSystem extends EntitySystem {
|
||||
@ServerOnly()
|
||||
private spawnEnemies(): void {
|
||||
// 仅在服务端运行 - 权威生成逻辑
|
||||
}
|
||||
|
||||
@ClientOnly()
|
||||
private playEffects(): void {
|
||||
// 仅在客户端运行 - 视觉效果
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 简单条件检查
|
||||
|
||||
对于简单场景,直接检查通常比装饰器更清晰:
|
||||
|
||||
```typescript
|
||||
class CollectibleSystem extends EntitySystem {
|
||||
private checkCollections(): void {
|
||||
if (!this.scene.isServer) return; // 客户端跳过
|
||||
|
||||
// 服务端权威逻辑...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
参见 [场景运行时环境](/guide/scene/index#运行时环境) 了解配置详情。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [系统类型](/guide/system/types) - 了解不同类型的系统基类
|
||||
|
||||
@@ -305,11 +305,11 @@ const tree = BehaviorTreeBuilder.create('Timeout')
|
||||
|
||||
### Cocos Creator集成
|
||||
|
||||
参见[Cocos Creator集成指南](./cocos-integration/)
|
||||
参见[Cocos Creator集成指南](/modules/behavior-tree/cocos-integration/)
|
||||
|
||||
### LayaAir集成
|
||||
|
||||
参见[LayaAir集成指南](./laya-integration/)
|
||||
参见[LayaAir集成指南](/modules/behavior-tree/laya-integration/)
|
||||
|
||||
## 最佳实践
|
||||
|
||||
@@ -389,6 +389,6 @@ const tree = BehaviorTreeBuilder.create('AI')
|
||||
|
||||
## 下一步
|
||||
|
||||
- 查看[自定义节点执行器](./custom-actions/)学习如何创建自定义节点
|
||||
- 阅读[最佳实践](./best-practices/)了解行为树设计技巧
|
||||
- 参考[编辑器使用指南](./editor-guide/)学习可视化编辑
|
||||
- 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何创建自定义节点
|
||||
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)了解行为树设计技巧
|
||||
- 参考[编辑器使用指南](/modules/behavior-tree/editor-guide/)学习可视化编辑
|
||||
|
||||
@@ -503,6 +503,6 @@ console.log(json);
|
||||
|
||||
## 下一步
|
||||
|
||||
- 学习[Cocos Creator 集成](./cocos-integration/)了解如何在游戏引擎中加载资源
|
||||
- 查看[自定义节点执行器](./custom-actions/)创建自定义行为
|
||||
- 阅读[最佳实践](./best-practices/)优化你的行为树设计
|
||||
- 学习[Cocos Creator 集成](/modules/behavior-tree/cocos-integration/)了解如何在游戏引擎中加载资源
|
||||
- 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)创建自定义行为
|
||||
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)优化你的行为树设计
|
||||
|
||||
@@ -26,7 +26,7 @@ Root Selector
|
||||
|
||||
### 2. 单一职责原则
|
||||
|
||||
每个节点应该只做一件事。要实现复杂动作,创建自定义执行器,参见[自定义节点执行器](./custom-actions/)。
|
||||
每个节点应该只做一件事。要实现复杂动作,创建自定义执行器,参见[自定义节点执行器](/modules/behavior-tree/custom-actions/)。
|
||||
|
||||
```typescript
|
||||
// 好的设计 - 使用内置节点
|
||||
@@ -465,6 +465,6 @@ export class SmartUpdate implements INodeExecutor {
|
||||
|
||||
## 下一步
|
||||
|
||||
- 学习[自定义节点执行器](./custom-actions/)扩展行为树功能
|
||||
- 探索[高级用法](./advanced-usage/)了解更多技巧
|
||||
- 参考[核心概念](./core-concepts/)深入理解原理
|
||||
- 学习[自定义节点执行器](/modules/behavior-tree/custom-actions/)扩展行为树功能
|
||||
- 探索[高级用法](/modules/behavior-tree/advanced-usage/)了解更多技巧
|
||||
- 参考[核心概念](/modules/behavior-tree/core-concepts/)深入理解原理
|
||||
|
||||
@@ -8,7 +8,7 @@ title: "Cocos Creator 集成"
|
||||
|
||||
- Cocos Creator 3.x 或更高版本
|
||||
- 基本的 TypeScript 知识
|
||||
- 已完成[快速开始](./getting-started/)教程
|
||||
- 已完成[快速开始](/modules/behavior-tree/getting-started/)教程
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -679,7 +679,7 @@ const updateInterval = sys.isNative ? 0.016 : 0.05;
|
||||
|
||||
## 下一步
|
||||
|
||||
- 查看[资产管理](./asset-management/)了解如何加载和管理行为树资产、使用子树
|
||||
- 学习[高级用法](./advanced-usage/)了解性能优化和调试技巧
|
||||
- 阅读[最佳实践](./best-practices/)优化你的 AI
|
||||
- 学习[自定义节点执行器](./custom-actions/)创建自定义行为
|
||||
- 查看[资产管理](/modules/behavior-tree/asset-management/)了解如何加载和管理行为树资产、使用子树
|
||||
- 学习[高级用法](/modules/behavior-tree/advanced-usage/)了解性能优化和调试技巧
|
||||
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)优化你的 AI
|
||||
- 学习[自定义节点执行器](/modules/behavior-tree/custom-actions/)创建自定义行为
|
||||
|
||||
@@ -192,7 +192,7 @@ const tree = BehaviorTreeBuilder.create('Actions')
|
||||
.build();
|
||||
```
|
||||
|
||||
要实现自定义动作,需要创建自定义执行器,参见[自定义节点执行器](./custom-actions/)。
|
||||
要实现自定义动作,需要创建自定义执行器,参见[自定义节点执行器](/modules/behavior-tree/custom-actions/)。
|
||||
|
||||
#### Condition(条件)
|
||||
|
||||
@@ -487,7 +487,7 @@ NodeRuntimeState
|
||||
|
||||
现在你已经理解了行为树的核心概念,接下来可以:
|
||||
|
||||
- 查看[快速开始](./getting-started/)创建第一个行为树
|
||||
- 学习[自定义节点执行器](./custom-actions/)创建自定义节点
|
||||
- 探索[高级用法](./advanced-usage/)了解更多功能
|
||||
- 阅读[最佳实践](./best-practices/)学习设计模式
|
||||
- 查看[快速开始](/modules/behavior-tree/getting-started/)创建第一个行为树
|
||||
- 学习[自定义节点执行器](/modules/behavior-tree/custom-actions/)创建自定义节点
|
||||
- 探索[高级用法](/modules/behavior-tree/advanced-usage/)了解更多功能
|
||||
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)学习设计模式
|
||||
|
||||
@@ -606,6 +606,107 @@ export class RetryDecorator implements INodeExecutor {
|
||||
}
|
||||
```
|
||||
|
||||
## 在代码中使用自定义执行器
|
||||
|
||||
定义了自定义执行器后,可以通过 `BehaviorTreeBuilder` 的 `.action()` 和 `.condition()` 方法在代码中使用:
|
||||
|
||||
### 使用 action() 方法
|
||||
|
||||
```typescript
|
||||
import { BehaviorTreeBuilder, BehaviorTreeStarter } from '@esengine/behavior-tree';
|
||||
|
||||
// 使用自定义执行器构建行为树
|
||||
const tree = BehaviorTreeBuilder.create('CombatAI')
|
||||
.defineBlackboardVariable('health', 100)
|
||||
.defineBlackboardVariable('target', null)
|
||||
.selector('Root')
|
||||
.sequence('AttackSequence')
|
||||
// 使用自定义动作 - implementationType 匹配装饰器中的定义
|
||||
.action('AttackAction', 'Attack', { damage: 25 })
|
||||
.action('MoveToPosition', 'Chase', { speed: 10 })
|
||||
.end()
|
||||
.action('DelayAction', 'Idle', { duration: 1.0 })
|
||||
.end()
|
||||
.build();
|
||||
|
||||
// 启动行为树
|
||||
const entity = scene.createEntity('Enemy');
|
||||
BehaviorTreeStarter.start(entity, tree);
|
||||
```
|
||||
|
||||
### 使用 condition() 方法
|
||||
|
||||
```typescript
|
||||
const tree = BehaviorTreeBuilder.create('AI')
|
||||
.selector('Root')
|
||||
.sequence('AttackBranch')
|
||||
// 使用自定义条件
|
||||
.condition('CheckHealth', 'IsHealthy', { threshold: 50, operator: 'greater' })
|
||||
.action('AttackAction', 'Attack')
|
||||
.end()
|
||||
.end()
|
||||
.build();
|
||||
```
|
||||
|
||||
### Builder 方法对照表
|
||||
|
||||
| 方法 | 说明 | 使用场景 |
|
||||
|------|------|----------|
|
||||
| `.action(type, name?, config?)` | 使用自定义动作执行器 | 自定义 Action 类 |
|
||||
| `.condition(type, name?, config?)` | 使用自定义条件执行器 | 自定义 Condition 类 |
|
||||
| `.executeAction(name)` | 调用黑板函数 `action_{name}` | 简单逻辑、快速原型 |
|
||||
| `.executeCondition(name)` | 调用黑板函数 `condition_{name}` | 简单条件判断 |
|
||||
|
||||
### 完整示例
|
||||
|
||||
```typescript
|
||||
import {
|
||||
BehaviorTreeBuilder,
|
||||
BehaviorTreeStarter,
|
||||
NodeExecutorMetadata,
|
||||
INodeExecutor,
|
||||
NodeExecutionContext,
|
||||
TaskStatus,
|
||||
NodeType,
|
||||
BindingHelper
|
||||
} from '@esengine/behavior-tree';
|
||||
|
||||
// 1. 定义自定义执行器
|
||||
@NodeExecutorMetadata({
|
||||
implementationType: 'AttackAction',
|
||||
nodeType: NodeType.Action,
|
||||
displayName: '攻击',
|
||||
category: 'Combat',
|
||||
configSchema: {
|
||||
damage: { type: 'number', default: 10, supportBinding: true }
|
||||
}
|
||||
})
|
||||
class AttackAction implements INodeExecutor {
|
||||
execute(context: NodeExecutionContext): TaskStatus {
|
||||
const damage = BindingHelper.getValue<number>(context, 'damage', 10);
|
||||
console.log(`执行攻击,造成 ${damage} 点伤害!`);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 构建行为树
|
||||
const enemyAI = BehaviorTreeBuilder.create('EnemyAI')
|
||||
.defineBlackboardVariable('health', 100)
|
||||
.defineBlackboardVariable('target', null)
|
||||
.selector('MainBehavior')
|
||||
.sequence('AttackBranch')
|
||||
.condition('CheckHealth', 'HasEnoughHealth', { threshold: 20, operator: 'greater' })
|
||||
.action('AttackAction', 'Attack', { damage: 50 })
|
||||
.end()
|
||||
.log('逃跑', 'Flee')
|
||||
.end()
|
||||
.build();
|
||||
|
||||
// 3. 启动行为树
|
||||
const entity = scene.createEntity('Enemy');
|
||||
BehaviorTreeStarter.start(entity, enemyAI);
|
||||
```
|
||||
|
||||
## 注册执行器
|
||||
|
||||
### 自动注册
|
||||
@@ -1022,6 +1123,6 @@ execute(context: NodeExecutionContext): TaskStatus {
|
||||
|
||||
## 下一步
|
||||
|
||||
- 学习[编辑器工作流](./editor-workflow/)了解如何在编辑器中使用自定义节点
|
||||
- 阅读[最佳实践](./best-practices/)学习行为树设计模式
|
||||
- 查看[高级用法](./advanced-usage/)了解更多功能
|
||||
- 学习[编辑器工作流](/modules/behavior-tree/editor-workflow/)了解如何在编辑器中使用自定义节点
|
||||
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)学习行为树设计模式
|
||||
- 查看[高级用法](/modules/behavior-tree/advanced-usage/)了解更多功能
|
||||
|
||||
@@ -117,5 +117,5 @@ BehaviorTreeStarter.start(entity, tree);
|
||||
|
||||
## 下一步
|
||||
|
||||
- 查看[编辑器工作流](./editor-workflow/)了解完整的开发流程
|
||||
- 查看[自定义节点执行器](./custom-actions/)学习如何扩展节点
|
||||
- 查看[编辑器工作流](/modules/behavior-tree/editor-workflow/)了解完整的开发流程
|
||||
- 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何扩展节点
|
||||
|
||||
@@ -112,7 +112,7 @@ setInterval(() => {
|
||||
|
||||
## 实现自定义执行器
|
||||
|
||||
要扩展行为树的功能,需要创建自定义执行器(详见[自定义节点执行器](./custom-actions/)):
|
||||
要扩展行为树的功能,需要创建自定义执行器(详见[自定义节点执行器](/modules/behavior-tree/custom-actions/)):
|
||||
|
||||
```typescript
|
||||
import {
|
||||
@@ -250,6 +250,6 @@ setInterval(() => {
|
||||
|
||||
## 下一步
|
||||
|
||||
- 查看[自定义节点执行器](./custom-actions/)学习如何创建自定义节点
|
||||
- 查看[高级用法](./advanced-usage/)了解性能优化等高级特性
|
||||
- 查看[最佳实践](./best-practices/)优化你的AI设计
|
||||
- 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何创建自定义节点
|
||||
- 查看[高级用法](/modules/behavior-tree/advanced-usage/)了解性能优化等高级特性
|
||||
- 查看[最佳实践](/modules/behavior-tree/best-practices/)优化你的AI设计
|
||||
|
||||
@@ -333,11 +333,11 @@ BehaviorTreeStarter.restart(entity);
|
||||
|
||||
现在你已经创建了第一个行为树,接下来可以:
|
||||
|
||||
1. 学习[核心概念](./core-concepts/)深入理解行为树原理
|
||||
2. 学习[资产管理](./asset-management/)了解如何加载和复用行为树、使用子树
|
||||
3. 查看[自定义节点执行器](./custom-actions/)学习如何创建自定义节点
|
||||
4. 根据你的场景查看集成教程:[Cocos Creator](./cocos-integration/) 或 [Node.js](./nodejs-usage.md)
|
||||
5. 查看[高级用法](./advanced-usage/)了解更多功能
|
||||
1. 学习[核心概念](/modules/behavior-tree/core-concepts/)深入理解行为树原理
|
||||
2. 学习[资产管理](/modules/behavior-tree/asset-management/)了解如何加载和复用行为树、使用子树
|
||||
3. 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何创建自定义节点
|
||||
4. 根据你的场景查看集成教程:[Cocos Creator](/modules/behavior-tree/cocos-integration/) 或 [Node.js](/modules/behavior-tree/nodejs-usage/)
|
||||
5. 查看[高级用法](/modules/behavior-tree/advanced-usage/)了解更多功能
|
||||
|
||||
## 常见问题
|
||||
|
||||
@@ -384,4 +384,4 @@ console.log('活动节点:', Array.from(runtime?.activeNodeIds || []));
|
||||
|
||||
内置的`executeAction`和`executeCondition`节点只是占位符。要实现真正的自定义逻辑,你需要创建自定义执行器:
|
||||
|
||||
参见[自定义节点执行器](./custom-actions/)学习如何创建。
|
||||
参见[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何创建。
|
||||
|
||||
@@ -8,7 +8,7 @@ title: "Laya 引擎集成"
|
||||
|
||||
- LayaAir 3.x 或更高版本
|
||||
- 基本的 TypeScript 知识
|
||||
- 已完成[快速开始](./getting-started/)教程
|
||||
- 已完成[快速开始](/modules/behavior-tree/getting-started/)教程
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -311,5 +311,5 @@ class AIManager {
|
||||
|
||||
## 下一步
|
||||
|
||||
- 查看[高级用法](./advanced-usage/)
|
||||
- 学习[最佳实践](./best-practices/)
|
||||
- 查看[高级用法](/modules/behavior-tree/advanced-usage/)
|
||||
- 学习[最佳实践](/modules/behavior-tree/best-practices/)
|
||||
|
||||
@@ -577,6 +577,6 @@ function loadAIState(entity: Entity, savedState: any) {
|
||||
|
||||
## 下一步
|
||||
|
||||
- 查看[资产管理](./asset-management/)了解资源加载和子树
|
||||
- 学习[自定义节点执行器](./custom-actions/)创建自定义行为
|
||||
- 阅读[最佳实践](./best-practices/)优化你的服务端AI
|
||||
- 查看[资产管理](/modules/behavior-tree/asset-management/)了解资源加载和子树
|
||||
- 学习[自定义节点执行器](/modules/behavior-tree/custom-actions/)创建自定义行为
|
||||
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)优化你的服务端AI
|
||||
|
||||
383
docs/src/content/docs/modules/blueprint/cocos-editor.md
Normal file
383
docs/src/content/docs/modules/blueprint/cocos-editor.md
Normal file
@@ -0,0 +1,383 @@
|
||||
---
|
||||
title: "Cocos Creator 蓝图编辑器"
|
||||
description: "在 Cocos Creator 中使用蓝图可视化脚本系统"
|
||||
---
|
||||
|
||||
本文档介绍如何在 Cocos Creator 项目中安装和使用蓝图可视化脚本编辑器扩展。
|
||||
|
||||
## 安装扩展
|
||||
|
||||
### 1. 复制扩展到项目
|
||||
|
||||
将 `cocos-node-editor` 扩展复制到你的 Cocos Creator 项目的 `extensions` 目录:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── assets/
|
||||
├── extensions/
|
||||
│ └── cocos-node-editor/ # 蓝图编辑器扩展
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
在扩展目录中安装依赖:
|
||||
|
||||
```bash
|
||||
cd extensions/cocos-node-editor
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 启用扩展
|
||||
|
||||
1. 打开 Cocos Creator
|
||||
2. 进入 **扩展 → 扩展管理器**
|
||||
3. 找到 `cocos-node-editor` 并启用
|
||||
|
||||
## 打开蓝图编辑器
|
||||
|
||||
通过菜单 **面板 → Node Editor** 打开蓝图编辑器面板。
|
||||
|
||||
## 编辑器界面
|
||||
|
||||
### 工具栏
|
||||
|
||||
| 按钮 | 快捷键 | 功能 |
|
||||
|------|--------|------|
|
||||
| 新建 | - | 创建空白蓝图 |
|
||||
| 加载 | - | 从文件加载蓝图 |
|
||||
| 保存 | `Ctrl+S` | 保存蓝图到文件 |
|
||||
| 撤销 | `Ctrl+Z` | 撤销上一步操作 |
|
||||
| 重做 | `Ctrl+Shift+Z` | 重做操作 |
|
||||
| 剪切 | `Ctrl+X` | 剪切选中节点 |
|
||||
| 复制 | `Ctrl+C` | 复制选中节点 |
|
||||
| 粘贴 | `Ctrl+V` | 粘贴节点 |
|
||||
| 删除 | `Delete` | 删除选中项 |
|
||||
| 重新扫描 | - | 重新扫描项目中的蓝图节点 |
|
||||
|
||||
### 画布操作
|
||||
|
||||
- **右键单击画布**:打开节点添加菜单
|
||||
- **拖拽节点**:移动节点位置
|
||||
- **点击节点**:选中节点
|
||||
- **Ctrl+点击**:多选节点
|
||||
- **拖拽引脚到引脚**:创建连接
|
||||
- **滚轮**:缩放画布
|
||||
- **中键拖拽**:平移画布
|
||||
|
||||
### 节点菜单
|
||||
|
||||
右键单击画布后会显示节点菜单:
|
||||
|
||||
- 顶部搜索框可以快速搜索节点
|
||||
- 节点按类别分组显示
|
||||
- 按 `Enter` 快速添加第一个搜索结果
|
||||
- 按 `Esc` 关闭菜单
|
||||
|
||||
## 蓝图文件格式
|
||||
|
||||
蓝图保存为 `.blueprint.json` 文件,格式与运行时完全兼容:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "blueprint",
|
||||
"metadata": {
|
||||
"name": "My Blueprint",
|
||||
"createdAt": 1704307200000,
|
||||
"modifiedAt": 1704307200000
|
||||
},
|
||||
"variables": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"type": "PrintString",
|
||||
"position": { "x": 100, "y": 200 },
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{
|
||||
"id": "conn-1",
|
||||
"fromNodeId": "node-1",
|
||||
"fromPin": "exec",
|
||||
"toNodeId": "node-2",
|
||||
"toPin": "exec"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 在游戏中运行蓝图
|
||||
|
||||
使用 ECS 系统方式管理和执行蓝图。
|
||||
|
||||
### 1. 定义蓝图组件
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, Property, Serialize } from '@esengine/ecs-framework';
|
||||
import type { BlueprintAsset } from '@esengine/blueprint';
|
||||
|
||||
@ECSComponent('Blueprint')
|
||||
export class BlueprintComponent extends Component {
|
||||
@Serialize()
|
||||
@Property({ type: 'asset', label: 'Blueprint Asset' })
|
||||
blueprintPath: string = '';
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Auto Start' })
|
||||
autoStart: boolean = true;
|
||||
|
||||
// 运行时数据(不序列化)
|
||||
blueprintAsset: BlueprintAsset | null = null;
|
||||
vm: BlueprintVM | null = null;
|
||||
isStarted: boolean = false;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 创建蓝图执行系统
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintVM,
|
||||
validateBlueprintAsset
|
||||
} from '@esengine/blueprint';
|
||||
import { BlueprintComponent } from './BlueprintComponent';
|
||||
|
||||
export class BlueprintExecutionSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(BlueprintComponent));
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
const dt = Time.deltaTime;
|
||||
|
||||
for (const entity of entities) {
|
||||
const bp = entity.getComponent(BlueprintComponent)!;
|
||||
|
||||
// 跳过没有蓝图资产的实体
|
||||
if (!bp.blueprintAsset) continue;
|
||||
|
||||
// 初始化 VM
|
||||
if (!bp.vm) {
|
||||
bp.vm = new BlueprintVM(bp.blueprintAsset, entity, this.scene!);
|
||||
}
|
||||
|
||||
// 自动启动
|
||||
if (bp.autoStart && !bp.isStarted) {
|
||||
bp.vm.start();
|
||||
bp.isStarted = true;
|
||||
}
|
||||
|
||||
// 更新蓝图
|
||||
if (bp.isStarted) {
|
||||
bp.vm.tick(dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override onRemoved(entity: Entity): void {
|
||||
const bp = entity.getComponent(BlueprintComponent);
|
||||
if (bp?.vm && bp.isStarted) {
|
||||
bp.vm.stop();
|
||||
bp.vm = null;
|
||||
bp.isStarted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 加载蓝图并添加到实体
|
||||
|
||||
```typescript
|
||||
import { resources, JsonAsset } from 'cc';
|
||||
import { validateBlueprintAsset } from '@esengine/blueprint';
|
||||
|
||||
// 加载蓝图资产
|
||||
async function loadBlueprint(path: string): Promise<BlueprintAsset | null> {
|
||||
return new Promise((resolve) => {
|
||||
resources.load(path, JsonAsset, (err, asset) => {
|
||||
if (err || !asset) {
|
||||
console.error('Failed to load blueprint:', err);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = asset.json;
|
||||
if (validateBlueprintAsset(data)) {
|
||||
resolve(data as BlueprintAsset);
|
||||
} else {
|
||||
console.error('Invalid blueprint format');
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 创建带蓝图的实体
|
||||
async function createBlueprintEntity(scene: IScene, blueprintPath: string): Promise<Entity> {
|
||||
const entity = scene.createEntity('BlueprintEntity');
|
||||
|
||||
const bpComponent = entity.addComponent(BlueprintComponent);
|
||||
bpComponent.blueprintPath = blueprintPath;
|
||||
bpComponent.blueprintAsset = await loadBlueprint(blueprintPath);
|
||||
|
||||
return entity;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 注册系统到场景
|
||||
|
||||
```typescript
|
||||
// 在场景初始化时
|
||||
scene.addSystem(new BlueprintExecutionSystem());
|
||||
```
|
||||
|
||||
## 创建自定义节点
|
||||
|
||||
### 使用装饰器标记组件
|
||||
|
||||
推荐使用装饰器让组件自动生成蓝图节点:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
|
||||
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: '生命值组件' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: '当前生命值', category: 'number' })
|
||||
current: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: '最大生命值', category: 'number' })
|
||||
max: number = 100;
|
||||
|
||||
@BlueprintMethod({ displayName: '治疗', isExec: true })
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: '受伤', isExec: true })
|
||||
takeDamage(amount: number): void {
|
||||
this.current = Math.max(this.current - amount, 0);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: '是否死亡' })
|
||||
isDead(): boolean {
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 注册组件节点
|
||||
|
||||
```typescript
|
||||
import { registerAllComponentNodes } from '@esengine/blueprint';
|
||||
|
||||
// 在应用启动时注册所有标记的组件
|
||||
registerAllComponentNodes();
|
||||
```
|
||||
|
||||
### 手动定义节点(高级)
|
||||
|
||||
如需完全自定义节点逻辑:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
BlueprintNodeTemplate,
|
||||
INodeExecutor,
|
||||
RegisterNode,
|
||||
ExecutionContext,
|
||||
ExecutionResult
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
const MyNodeTemplate: BlueprintNodeTemplate = {
|
||||
type: 'MyCustomNode',
|
||||
title: '我的自定义节点',
|
||||
category: 'custom',
|
||||
description: '自定义节点示例',
|
||||
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' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const value = context.getInput<number>(node.id, 'value');
|
||||
return {
|
||||
outputs: { result: value * 2 },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 节点类别
|
||||
|
||||
| 类别 | 说明 | 颜色 |
|
||||
|------|------|------|
|
||||
| `event` | 事件节点 | 红色 |
|
||||
| `flow` | 流程控制 | 灰色 |
|
||||
| `entity` | 实体操作 | 蓝色 |
|
||||
| `component` | 组件访问 | 青色 |
|
||||
| `math` | 数学运算 | 绿色 |
|
||||
| `logic` | 逻辑运算 | 红色 |
|
||||
| `variable` | 变量访问 | 紫色 |
|
||||
| `time` | 时间工具 | 青色 |
|
||||
| `debug` | 调试工具 | 灰色 |
|
||||
| `custom` | 自定义节点 | 蓝灰色 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **文件组织**
|
||||
- 将蓝图文件放在 `assets/blueprints/` 目录下
|
||||
- 使用有意义的文件名,如 `player-controller.blueprint.json`
|
||||
|
||||
2. **组件设计**
|
||||
- 使用 `@BlueprintExpose` 标记需要暴露给蓝图的组件
|
||||
- 为属性和方法提供清晰的 `displayName`
|
||||
- 将执行方法标记为 `isExec: true`
|
||||
|
||||
3. **性能考虑**
|
||||
- 避免在 Tick 事件中执行重计算
|
||||
- 使用变量缓存中间结果
|
||||
- 纯函数节点会自动缓存输出
|
||||
|
||||
4. **调试技巧**
|
||||
- 使用 Print 节点输出中间值
|
||||
- 启用 `vm.debug = true` 查看执行日志
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 节点菜单是空的?
|
||||
|
||||
A: 点击 **重新扫描** 按钮扫描项目中的蓝图节点类。确保已调用 `registerAllComponentNodes()`。
|
||||
|
||||
### Q: 蓝图不执行?
|
||||
|
||||
A: 检查:
|
||||
1. 实体是否添加了 `BlueprintComponent`
|
||||
2. `BlueprintExecutionSystem` 是否注册到场景
|
||||
3. `blueprintAsset` 是否正确加载
|
||||
4. `autoStart` 是否为 `true`
|
||||
|
||||
### Q: 如何触发自定义事件?
|
||||
|
||||
A: 通过 VM 触发:
|
||||
```typescript
|
||||
const bp = entity.getComponent(BlueprintComponent);
|
||||
bp.vm?.triggerCustomEvent('OnPickup', { item: itemEntity });
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [蓝图运行时 API](/modules/blueprint/) - BlueprintVM 和核心 API
|
||||
- [自定义节点](/modules/blueprint/custom-nodes) - 详细的节点创建指南
|
||||
- [内置节点](/modules/blueprint/nodes) - 内置节点参考
|
||||
@@ -28,13 +28,13 @@ const MyNodeTemplate: BlueprintNodeTemplate = {
|
||||
## 实现节点执行器
|
||||
|
||||
```typescript
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
// 获取输入
|
||||
const value = context.getInput<number>(node.id, 'value');
|
||||
// 获取输入(使用 evaluateInput)
|
||||
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
||||
|
||||
// 执行逻辑
|
||||
const result = value * 2;
|
||||
@@ -100,29 +100,58 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
||||
};
|
||||
```
|
||||
|
||||
## 实际示例:输入处理节点
|
||||
## 实际示例:ECS 组件操作节点
|
||||
|
||||
```typescript
|
||||
const InputMoveTemplate: BlueprintNodeTemplate = {
|
||||
type: 'InputMove',
|
||||
title: 'Get Movement Input',
|
||||
category: 'input',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'direction', type: 'vector2', direction: 'output' }
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
|
||||
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
|
||||
// 自定义治疗节点
|
||||
const HealEntityTemplate: BlueprintNodeTemplate = {
|
||||
type: 'HealEntity',
|
||||
title: 'Heal Entity',
|
||||
category: 'gameplay',
|
||||
color: '#22aa22',
|
||||
description: 'Heal an entity with HealthComponent',
|
||||
keywords: ['heal', 'health', 'restore'],
|
||||
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'entity', type: 'entity', displayName: 'Target' },
|
||||
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
|
||||
],
|
||||
isPure: true
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(InputMoveTemplate)
|
||||
class InputMoveExecutor implements INodeExecutor {
|
||||
@RegisterNode(HealEntityTemplate)
|
||||
class HealEntityExecutor 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 } };
|
||||
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!entity || entity.isDestroyed) {
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
// 获取 HealthComponent
|
||||
const health = entity.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
if (health) {
|
||||
health.current = Math.min(health.current + amount, health.max);
|
||||
return {
|
||||
outputs: { newHealth: health.current },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
876
docs/src/content/docs/modules/blueprint/editor-guide.md
Normal file
876
docs/src/content/docs/modules/blueprint/editor-guide.md
Normal file
@@ -0,0 +1,876 @@
|
||||
---
|
||||
title: "蓝图编辑器使用指南"
|
||||
description: "Cocos Creator 蓝图可视化脚本编辑器完整使用教程"
|
||||
---
|
||||
|
||||
<script src="/js/blueprint-graph.js"></script>
|
||||
|
||||
本指南介绍如何在 Cocos Creator 中使用蓝图可视化脚本编辑器。
|
||||
|
||||
## 下载与安装
|
||||
|
||||
### 下载
|
||||
|
||||
从 GitHub Release 下载最新版本(免费):
|
||||
|
||||
**[下载 Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
|
||||
> 技术交流 QQ 群:**481923584** | 官网:[esengine.cn](https://esengine.cn/)
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. 解压 `cocos-node-editor.zip` 到项目的 `extensions` 目录:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── assets/
|
||||
├── extensions/
|
||||
│ └── cocos-node-editor/ ← 解压到这里
|
||||
└── ...
|
||||
```
|
||||
|
||||
2. 重启 Cocos Creator
|
||||
|
||||
3. 通过菜单 **扩展 → 扩展管理器** 确认插件已启用
|
||||
|
||||
4. 通过菜单 **面板 → Node Editor** 打开编辑器
|
||||
|
||||
## 界面介绍
|
||||
|
||||
- **工具栏** - 位于顶部,包含新建、打开、保存、撤销、重做等操作
|
||||
- **变量面板** - 位于左上角,用于定义和管理蓝图变量
|
||||
- **画布区域** - 主区域,用于放置和连接节点
|
||||
- **节点菜单** - 右键点击画布空白处打开,按分类列出所有可用节点
|
||||
|
||||
## 画布操作
|
||||
|
||||
| 操作 | 方式 |
|
||||
|------|------|
|
||||
| 平移画布 | 鼠标中键拖拽 / Alt + 左键拖拽 |
|
||||
| 缩放画布 | 鼠标滚轮 |
|
||||
| 打开节点菜单 | 右键点击空白处 |
|
||||
| 框选多个节点 | 在空白处拖拽 |
|
||||
| 追加框选 | Ctrl + 拖拽 |
|
||||
| 删除选中 | Delete 键 |
|
||||
|
||||
## 节点操作
|
||||
|
||||
### 添加节点
|
||||
|
||||
1. **从节点面板拖拽** - 将节点从左侧面板拖到画布
|
||||
2. **右键菜单** - 右键点击画布空白处,选择节点
|
||||
|
||||
### 连接节点
|
||||
|
||||
1. 从输出引脚拖拽到输入引脚
|
||||
2. 兼容类型的引脚会高亮显示
|
||||
3. 松开鼠标完成连接
|
||||
|
||||
**引脚类型说明:**
|
||||
|
||||
| 引脚颜色 | 类型 | 说明 |
|
||||
|---------|------|------|
|
||||
| 白色 ▶ | Exec | 执行流程(控制执行顺序) |
|
||||
| 青色 ◆ | Entity | 实体引用 |
|
||||
| 紫色 ◆ | Component | 组件引用 |
|
||||
| 浅蓝 ◆ | String | 字符串 |
|
||||
| 绿色 ◆ | Number | 数值 |
|
||||
| 红色 ◆ | Boolean | 布尔值 |
|
||||
| 灰色 ◆ | Any | 任意类型 |
|
||||
|
||||
### 删除连接
|
||||
|
||||
点击连接线选中,按 Delete 键删除。
|
||||
|
||||
## 节点类型详解
|
||||
|
||||
### 事件节点 (Event)
|
||||
|
||||
事件节点是蓝图的入口点,当特定事件发生时触发执行。
|
||||
|
||||
| 节点 | 触发时机 | 输出 |
|
||||
|------|---------|------|
|
||||
| **Event BeginPlay** | 蓝图开始运行时 | Exec, Self (实体) |
|
||||
| **Event Tick** | 每帧执行 | Exec, Delta Time |
|
||||
| **Event EndPlay** | 蓝图停止时 | Exec |
|
||||
|
||||
**示例:游戏开始时打印消息**
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg1-exec","to":"eg1-print","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="eg1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg1-print"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Message</span>
|
||||
<span class="bp-pin-value">"游戏开始!"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 实体节点 (Entity)
|
||||
|
||||
操作 ECS 实体的节点。
|
||||
|
||||
| 节点 | 功能 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| **Get Self** | 获取当前实体 | - | Entity |
|
||||
| **Create Entity** | 创建新实体 | Exec, Name | Exec, Entity |
|
||||
| **Destroy Entity** | 销毁实体 | Exec, Entity | Exec |
|
||||
| **Find Entity By Name** | 按名称查找 | Name | Entity |
|
||||
| **Find Entities By Tag** | 按标签查找 | Tag | Entity[] |
|
||||
| **Is Valid** | 检查实体有效性 | Entity | Boolean |
|
||||
| **Get/Set Entity Name** | 获取/设置名称 | Entity | String |
|
||||
| **Set Active** | 设置激活状态 | Exec, Entity, Active | Exec |
|
||||
|
||||
**示例:创建新实体**
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg2-exec","to":"eg2-create","type":"exec"},{"from":"eg2-create-out","to":"eg2-add","type":"exec"},{"from":"eg2-entity","to":"eg2-add-entity","type":"entity"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="eg2-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg2-create"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Name</span>
|
||||
<span class="bp-pin-value">"Bullet"</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg2-create-out"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg2-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 520px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Add Transform</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg2-add"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg2-add-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 组件节点 (Component)
|
||||
|
||||
访问和操作 ECS 组件。
|
||||
|
||||
| 节点 | 功能 |
|
||||
|------|------|
|
||||
| **Has Component** | 检查实体是否有指定组件 |
|
||||
| **Get Component** | 获取组件实例 |
|
||||
| **Add Component** | 添加组件到实体 |
|
||||
| **Remove Component** | 移除组件 |
|
||||
| **Get/Set Property** | 获取/设置组件属性 |
|
||||
|
||||
**示例:修改 Transform 组件**
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg3-self","to":"eg3-getcomp","type":"entity"},{"from":"eg3-comp","to":"eg3-setprop","type":"component"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 100px;">
|
||||
<div class="bp-node-header pure">Get Self</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg3-self"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header pure">Get Component</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg3-getcomp"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg3-comp"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Transform</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg3-setprop"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 流程控制节点 (Flow)
|
||||
|
||||
控制执行流程的节点。
|
||||
|
||||
#### Branch (分支)
|
||||
|
||||
条件判断,类似 if/else。
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg4-true","to":"eg4-do1","type":"exec"},{"from":"eg4-false","to":"eg4-do2","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 50px; width: 110px;">
|
||||
<div class="bp-node-header flow">Branch</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#8c0000" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Condition</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg4-true"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">True</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg4-false"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">False</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header function">DoSomething</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg4-do1"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 110px; width: 130px;">
|
||||
<div class="bp-node-header function">DoOtherThing</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg4-do2"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
#### Sequence (序列)
|
||||
|
||||
按顺序执行多个分支。
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg5-then0","to":"eg5-step1","type":"exec"},{"from":"eg5-then1","to":"eg5-step2","type":"exec"},{"from":"eg5-then2","to":"eg5-step3","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 110px;">
|
||||
<div class="bp-node-header flow">Sequence</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg5-then0"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Then 0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg5-then1"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Then 1</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg5-then2"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Then 2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 20px; width: 100px;">
|
||||
<div class="bp-node-header function">Step 1</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg5-step1"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 100px; width: 100px;">
|
||||
<div class="bp-node-header function">Step 2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg5-step2"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 180px; width: 100px;">
|
||||
<div class="bp-node-header function">Step 3</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg5-step3"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
#### For Loop (循环)
|
||||
|
||||
循环执行指定次数。
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg6-body","to":"eg6-iter","type":"exec"},{"from":"eg6-done","to":"eg6-finish","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header flow">For Loop</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">First Index</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Last Index</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg6-body"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Loop Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg6-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 250px; top: 80px; width: 130px;">
|
||||
<div class="bp-node-header function">每次迭代执行</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg6-iter"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 250px; top: 160px; width: 140px;">
|
||||
<div class="bp-node-header function">循环结束后执行</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg6-finish"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
#### For Each (遍历)
|
||||
|
||||
遍历数组元素。
|
||||
|
||||
#### While Loop (条件循环)
|
||||
|
||||
当条件为真时持续循环。
|
||||
|
||||
#### Do Once (单次执行)
|
||||
|
||||
只执行一次,之后跳过。
|
||||
|
||||
#### Flip Flop (交替执行)
|
||||
|
||||
每次执行时交替触发 A 和 B 输出。
|
||||
|
||||
#### Gate (门)
|
||||
|
||||
可通过 Open/Close/Toggle 控制是否允许执行通过。
|
||||
|
||||
### 时间节点 (Time)
|
||||
|
||||
| 节点 | 功能 | 输出类型 |
|
||||
|------|------|---------|
|
||||
| **Delay** | 延迟指定时间后继续执行 | Exec |
|
||||
| **Get Delta Time** | 获取帧间隔时间 | Number |
|
||||
| **Get Time** | 获取运行总时间 | Number |
|
||||
|
||||
**示例:延迟 2 秒后执行**
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg7-exec","to":"eg7-delay","type":"exec"},{"from":"eg7-done","to":"eg7-print","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="eg7-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg7-delay"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg7-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 490px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg7-print"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"2秒后执行"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 数学节点 (Math)
|
||||
|
||||
| 节点 | 功能 |
|
||||
|------|------|
|
||||
| **Add / Subtract / Multiply / Divide** | 四则运算 |
|
||||
| **Abs** | 绝对值 |
|
||||
| **Clamp** | 限制在范围内 |
|
||||
| **Lerp** | 线性插值 |
|
||||
| **Min / Max** | 最小/最大值 |
|
||||
| **Random Range** | 随机数 |
|
||||
| **Sin / Cos / Tan** | 三角函数 |
|
||||
|
||||
### 调试节点 (Debug)
|
||||
|
||||
| 节点 | 功能 |
|
||||
|------|------|
|
||||
| **Print** | 输出到控制台 |
|
||||
|
||||
## 变量系统
|
||||
|
||||
变量用于在蓝图中存储和共享数据。
|
||||
|
||||
### 创建变量
|
||||
|
||||
1. 在变量面板点击 **+** 按钮
|
||||
2. 输入变量名称
|
||||
3. 选择变量类型
|
||||
4. 设置默认值(可选)
|
||||
|
||||
### 使用变量
|
||||
|
||||
- **拖拽到画布** - 创建 Get 或 Set 节点
|
||||
- **Get 节点** - 读取变量值
|
||||
- **Set 节点** - 写入变量值
|
||||
|
||||
### 变量类型
|
||||
|
||||
| 类型 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| Boolean | 布尔值 | false |
|
||||
| Number | 数值 | 0 |
|
||||
| String | 字符串 | "" |
|
||||
| Entity | 实体引用 | null |
|
||||
| Vector2 | 二维向量 | (0, 0) |
|
||||
| Vector3 | 三维向量 | (0, 0, 0) |
|
||||
|
||||
### 变量节点错误状态
|
||||
|
||||
如果删除了一个变量,但画布上还有引用该变量的节点:
|
||||
- 节点会显示 **红色边框** 和 **警告图标**
|
||||
- 需要重新创建变量或删除这些节点
|
||||
|
||||
## 节点分组
|
||||
|
||||
可以将多个节点组织到一个可视化组框中,便于整理复杂蓝图。
|
||||
|
||||
### 创建组
|
||||
|
||||
1. 框选或 Ctrl+点击 选中多个节点(至少 2 个)
|
||||
2. 右键点击选中的节点
|
||||
3. 选择 **创建分组**
|
||||
4. 组框会自动包裹所有选中的节点
|
||||
|
||||
### 组操作
|
||||
|
||||
| 操作 | 方式 |
|
||||
|------|------|
|
||||
| 移动组 | 拖拽组框头部,所有节点一起移动 |
|
||||
| 取消分组 | 右键点击组框 → **取消分组** |
|
||||
|
||||
### 特性
|
||||
|
||||
- **动态大小**:组框会自动调整大小以包裹所有节点
|
||||
- **独立移动**:可以单独移动组内的节点,组框会自动调整
|
||||
- **仅编辑器**:组是纯视觉组织,不影响运行时逻辑
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| `Ctrl + S` | 保存蓝图 |
|
||||
| `Ctrl + Z` | 撤销 |
|
||||
| `Ctrl + Shift + Z` | 重做 |
|
||||
| `Ctrl + C` | 复制选中节点 |
|
||||
| `Ctrl + X` | 剪切选中节点 |
|
||||
| `Ctrl + V` | 粘贴节点 |
|
||||
| `Delete` | 删除选中项 |
|
||||
| `Ctrl + A` | 全选 |
|
||||
|
||||
## 保存与加载
|
||||
|
||||
### 保存蓝图
|
||||
|
||||
1. 点击工具栏 **保存** 按钮
|
||||
2. 选择保存位置(**必须保存在 `assets/resources` 目录下**,否则 Cocos Creator 无法动态加载)
|
||||
3. 文件扩展名为 `.blueprint.json`
|
||||
|
||||
> **重要提示**:蓝图文件必须放在 `resources` 目录下,游戏运行时才能通过 `cc.resources.load()` 加载。
|
||||
|
||||
### 加载蓝图
|
||||
|
||||
1. 点击工具栏 **打开** 按钮
|
||||
2. 选择 `.blueprint.json` 文件
|
||||
|
||||
### 蓝图文件格式
|
||||
|
||||
蓝图保存为 JSON 格式,可与 `@esengine/blueprint` 运行时兼容:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "blueprint",
|
||||
"metadata": {
|
||||
"name": "PlayerController",
|
||||
"description": "玩家控制逻辑"
|
||||
},
|
||||
"variables": [],
|
||||
"nodes": [],
|
||||
"connections": []
|
||||
}
|
||||
```
|
||||
|
||||
## 实战示例
|
||||
|
||||
### 示例 1:移动控制
|
||||
|
||||
实现每帧移动实体:
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"ex1-exec","to":"ex1-setprop","type":"exec"},{"from":"ex1-delta","to":"ex1-mul-a","type":"float"},{"from":"ex1-mul-result","to":"ex1-x","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="ex1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex1-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta Time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
|
||||
<div class="bp-node-header math">Multiply</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B (Speed)</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex1-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-setprop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 示例 2:生命值系统
|
||||
|
||||
受伤后检查死亡逻辑。`Event OnDamage` 是一个自定义事件节点,可以通过代码 `vm.triggerCustomEvent('OnDamage', { damage: 50 })` 触发:
|
||||
|
||||
<div class="bp-graph" data-graph='{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "event", "title": "Event OnDamage", "category": "event",
|
||||
"outputs": [
|
||||
{"id": "event-exec", "type": "exec", "inHeader": true},
|
||||
{"id": "event-self", "type": "entity", "label": "Self"},
|
||||
{"id": "event-damage", "type": "float", "label": "Damage"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "getcomp", "title": "Get Component", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "getcomp-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "getcomp-entity", "type": "entity", "label": "Entity"},
|
||||
{"id": "getcomp-type", "type": "string", "label": "Type", "value": "Health", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "getcomp-out", "type": "exec"},
|
||||
{"id": "getcomp-comp", "type": "component", "label": "Component"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "getprop", "title": "Get Property", "category": "pure",
|
||||
"inputs": [
|
||||
{"id": "getprop-target", "type": "component", "label": "Target"},
|
||||
{"id": "getprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "getprop-val", "type": "float", "label": "Value"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sub", "title": "Subtract", "category": "math",
|
||||
"inputs": [
|
||||
{"id": "sub-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "sub-a", "type": "float", "label": "A"},
|
||||
{"id": "sub-b", "type": "float", "label": "B"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "sub-out", "type": "exec"},
|
||||
{"id": "sub-result", "type": "float", "label": "Result"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "setprop", "title": "Set Property", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "setprop-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "setprop-target", "type": "component", "label": "Target"},
|
||||
{"id": "setprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false},
|
||||
{"id": "setprop-val", "type": "float", "label": "Value"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "setprop-out", "type": "exec"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lte", "title": "Less Or Equal", "category": "pure",
|
||||
"inputs": [
|
||||
{"id": "lte-a", "type": "float", "label": "A"},
|
||||
{"id": "lte-b", "type": "float", "label": "B", "value": "0", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "lte-result", "type": "bool", "label": "Result"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "branch", "title": "Branch", "category": "flow",
|
||||
"inputs": [
|
||||
{"id": "branch-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "branch-cond", "type": "bool", "label": "Condition"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "branch-true", "type": "exec", "label": "True"},
|
||||
{"id": "branch-false", "type": "exec", "label": "False"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "destroy", "title": "Destroy Entity", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "destroy-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "destroy-entity", "type": "entity", "label": "Entity"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{"from": "event-exec", "to": "getcomp-exec", "type": "exec"},
|
||||
{"from": "getcomp-out", "to": "sub-exec", "type": "exec"},
|
||||
{"from": "sub-out", "to": "setprop-exec", "type": "exec"},
|
||||
{"from": "setprop-out", "to": "branch-exec", "type": "exec"},
|
||||
{"from": "branch-true", "to": "destroy-exec", "type": "exec"},
|
||||
{"from": "event-self", "to": "getcomp-entity", "type": "entity"},
|
||||
{"from": "event-self", "to": "destroy-entity", "type": "entity"},
|
||||
{"from": "getcomp-comp", "to": "getprop-target", "type": "component"},
|
||||
{"from": "getcomp-comp", "to": "setprop-target", "type": "component"},
|
||||
{"from": "getprop-val", "to": "sub-a", "type": "float"},
|
||||
{"from": "event-damage", "to": "sub-b", "type": "float"},
|
||||
{"from": "sub-result", "to": "setprop-val", "type": "float"},
|
||||
{"from": "sub-result", "to": "lte-a", "type": "float"},
|
||||
{"from": "lte-result", "to": "branch-cond", "type": "bool"}
|
||||
]
|
||||
}'></div>
|
||||
|
||||
### 示例 3:延迟生成
|
||||
|
||||
每 2 秒生成一个敌人:
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"ex3-begin-exec","to":"ex3-loop","type":"exec"},{"from":"ex3-loop-body","to":"ex3-delay","type":"exec"},{"from":"ex3-delay-done","to":"ex3-create","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="ex3-begin-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header flow">Do N Times</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-loop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">N</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex3-loop-body"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Loop Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-delay"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex3-delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 610px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-create"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Name</span>
|
||||
<span class="bp-pin-value">"Enemy"</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 节点无法连接?
|
||||
|
||||
检查引脚类型是否匹配。执行引脚(白色)只能连接执行引脚,数据引脚需要类型兼容。
|
||||
|
||||
### Q: 蓝图不执行?
|
||||
|
||||
1. 确保实体添加了 `BlueprintComponent`
|
||||
2. 确保场景添加了 `BlueprintSystem`
|
||||
3. 检查 `autoStart` 是否为 `true`
|
||||
|
||||
### Q: 如何调试?
|
||||
|
||||
使用 **Print** 节点输出变量值到控制台。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [ECS 节点参考](/modules/blueprint/nodes) - 完整节点列表
|
||||
- [自定义节点](/modules/blueprint/custom-nodes) - 创建自定义节点
|
||||
- [运行时集成](/modules/blueprint/vm) - 蓝图虚拟机 API
|
||||
- [实际示例](/modules/blueprint/examples) - 更多游戏逻辑示例
|
||||
@@ -3,85 +3,127 @@ title: "实际示例"
|
||||
description: "ECS 集成和最佳实践"
|
||||
---
|
||||
|
||||
## 玩家控制蓝图
|
||||
## 完整游戏集成示例
|
||||
|
||||
```typescript
|
||||
// 定义输入处理节点
|
||||
const InputMoveTemplate: BlueprintNodeTemplate = {
|
||||
type: 'InputMove',
|
||||
title: 'Get Movement Input',
|
||||
category: 'input',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'direction', type: 'vector2', direction: 'output' }
|
||||
],
|
||||
isPure: true
|
||||
};
|
||||
import { Scene, Core, Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintSystem,
|
||||
BlueprintComponent,
|
||||
BlueprintExpose,
|
||||
BlueprintProperty,
|
||||
BlueprintMethod
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
@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 } };
|
||||
// 1. 定义游戏组件
|
||||
@ECSComponent('Player')
|
||||
@BlueprintExpose({ displayName: '玩家', category: 'gameplay' })
|
||||
export class PlayerComponent extends Component {
|
||||
@BlueprintProperty({ displayName: '移动速度', type: 'float' })
|
||||
moveSpeed: number = 5;
|
||||
|
||||
@BlueprintProperty({ displayName: '分数', type: 'int' })
|
||||
score: number = 0;
|
||||
|
||||
@BlueprintMethod({ displayName: '增加分数' })
|
||||
addScore(points: number): void {
|
||||
this.score += points;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: '当前生命值' })
|
||||
current: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: '最大生命值' })
|
||||
max: number = 100;
|
||||
|
||||
@BlueprintMethod({ displayName: '治疗' })
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: '受伤' })
|
||||
takeDamage(amount: number): boolean {
|
||||
this.current -= amount;
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 初始化游戏
|
||||
async function initGame() {
|
||||
const scene = new Scene();
|
||||
|
||||
// 添加蓝图系统
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
|
||||
Core.setScene(scene);
|
||||
|
||||
// 3. 创建玩家
|
||||
const player = scene.createEntity('Player');
|
||||
player.addComponent(new PlayerComponent());
|
||||
player.addComponent(new HealthComponent());
|
||||
|
||||
// 添加蓝图控制
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
player.addComponent(blueprint);
|
||||
}
|
||||
```
|
||||
|
||||
## 状态切换逻辑
|
||||
## 自定义节点示例
|
||||
|
||||
```typescript
|
||||
// 在蓝图中实现状态机逻辑
|
||||
const stateBlueprint = createEmptyBlueprint('PlayerState');
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintNodeTemplate,
|
||||
BlueprintNode,
|
||||
ExecutionContext,
|
||||
ExecutionResult,
|
||||
INodeExecutor,
|
||||
RegisterNode
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
// 添加状态变量
|
||||
stateBlueprint.variables.push({
|
||||
name: 'currentState',
|
||||
type: 'string',
|
||||
defaultValue: 'idle',
|
||||
scope: 'instance'
|
||||
});
|
||||
|
||||
// 在 Tick 事件中检查状态转换
|
||||
// ... 通过节点连接实现
|
||||
```
|
||||
|
||||
## 伤害处理系统
|
||||
|
||||
```typescript
|
||||
// 自定义伤害节点
|
||||
const ApplyDamageTemplate: BlueprintNodeTemplate = {
|
||||
type: 'ApplyDamage',
|
||||
title: 'Apply Damage',
|
||||
category: 'combat',
|
||||
color: '#aa2222',
|
||||
description: '对带有 Health 组件的实体造成伤害',
|
||||
keywords: ['damage', 'hurt', 'attack'],
|
||||
menuPath: ['Combat', 'Apply Damage'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
|
||||
{ name: 'target', type: 'entity', direction: 'input' },
|
||||
{ name: 'amount', type: 'number', direction: 'input', defaultValue: 10 }
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'target', type: 'entity', displayName: '目标' },
|
||||
{ name: 'amount', type: 'float', displayName: '伤害量', defaultValue: 10 }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
|
||||
{ name: 'killed', type: 'boolean', direction: 'output' }
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'killed', type: 'bool', displayName: '已击杀' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(ApplyDamageTemplate)
|
||||
class ApplyDamageExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const target = context.getInput<Entity>(node.id, 'target');
|
||||
const amount = context.getInput<number>(node.id, 'amount');
|
||||
const target = context.evaluateInput(node.id, 'target', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!target || target.isDestroyed) {
|
||||
return { outputs: { killed: false }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
const health = target.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
const health = target.getComponent(HealthComponent);
|
||||
if (health) {
|
||||
health.current -= amount;
|
||||
const killed = health.current <= 0;
|
||||
return {
|
||||
outputs: { killed },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
return { outputs: { killed }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
return { outputs: { killed: false }, nextExec: 'exec' };
|
||||
@@ -89,25 +131,6 @@ class ApplyDamageExecutor implements INodeExecutor {
|
||||
}
|
||||
```
|
||||
|
||||
## 技能冷却系统
|
||||
|
||||
```typescript
|
||||
// 冷却检查节点
|
||||
const CheckCooldownTemplate: BlueprintNodeTemplate = {
|
||||
type: 'CheckCooldown',
|
||||
title: 'Check Cooldown',
|
||||
category: 'ability',
|
||||
inputs: [
|
||||
{ name: 'skillId', type: 'string', direction: 'input' }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'ready', type: 'boolean', direction: 'output' },
|
||||
{ name: 'remaining', type: 'number', direction: 'output' }
|
||||
],
|
||||
isPure: true
|
||||
};
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 使用片段复用逻辑
|
||||
@@ -151,7 +174,8 @@ vm.maxStepsPerFrame = 1000;
|
||||
|
||||
```typescript
|
||||
// 启用调试模式查看执行日志
|
||||
vm.debug = true;
|
||||
const blueprint = entity.getComponent(BlueprintComponent);
|
||||
blueprint.debug = true;
|
||||
|
||||
// 使用 Print 节点输出中间值
|
||||
// 在编辑器中设置断点
|
||||
|
||||
@@ -1,114 +1,162 @@
|
||||
---
|
||||
title: "蓝图可视化脚本 (Blueprint)"
|
||||
description: "完整的可视化脚本系统"
|
||||
description: "与 ECS 框架深度集成的可视化脚本系统"
|
||||
---
|
||||
|
||||
`@esengine/blueprint` 提供了一个功能完整的可视化脚本系统,支持节点式编程、事件驱动和蓝图组合。
|
||||
`@esengine/blueprint` 提供与 ECS 框架深度集成的可视化脚本系统,支持通过节点式编程控制实体行为。
|
||||
|
||||
## 安装
|
||||
## 编辑器下载
|
||||
|
||||
Cocos Creator 蓝图编辑器插件(免费):
|
||||
|
||||
**[下载 Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
|
||||
> 技术交流 QQ 群:**481923584** | 官网:[esengine.cn](https://esengine.cn/)
|
||||
|
||||
详细使用教程请参考 [编辑器使用指南](./editor-guide)。
|
||||
|
||||
## 安装运行时
|
||||
|
||||
```bash
|
||||
npm install @esengine/blueprint
|
||||
```
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **ECS 深度集成** - 内置 Entity、Component 操作节点
|
||||
- **组件自动节点生成** - 使用装饰器标记组件,自动生成 Get/Set/Call 节点
|
||||
- **运行时蓝图执行** - 高效的虚拟机执行蓝图逻辑
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 添加蓝图系统
|
||||
|
||||
```typescript
|
||||
import { Scene, Core } from '@esengine/ecs-framework';
|
||||
import { BlueprintSystem } from '@esengine/blueprint';
|
||||
|
||||
// 创建场景并添加蓝图系统
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
|
||||
// 设置场景
|
||||
Core.setScene(scene);
|
||||
```
|
||||
|
||||
### 2. 为实体添加蓝图
|
||||
|
||||
```typescript
|
||||
import { BlueprintComponent } from '@esengine/blueprint';
|
||||
|
||||
// 创建实体
|
||||
const player = scene.createEntity('Player');
|
||||
|
||||
// 添加蓝图组件
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
blueprint.autoStart = true;
|
||||
player.addComponent(blueprint);
|
||||
```
|
||||
|
||||
### 3. 标记组件(自动生成蓝图节点)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createBlueprintSystem,
|
||||
createBlueprintComponentData,
|
||||
NodeRegistry,
|
||||
RegisterNode
|
||||
BlueprintExpose,
|
||||
BlueprintProperty,
|
||||
BlueprintMethod
|
||||
} from '@esengine/blueprint';
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
|
||||
// 创建蓝图系统
|
||||
const blueprintSystem = createBlueprintSystem(scene);
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: '当前生命值', type: 'float' })
|
||||
current: number = 100;
|
||||
|
||||
// 加载蓝图资产
|
||||
const blueprint = await loadBlueprintAsset('player.bp');
|
||||
@BlueprintProperty({ displayName: '最大生命值', type: 'float' })
|
||||
max: number = 100;
|
||||
|
||||
// 创建蓝图组件数据
|
||||
const componentData = createBlueprintComponentData();
|
||||
componentData.blueprintAsset = blueprint;
|
||||
@BlueprintMethod({
|
||||
displayName: '治疗',
|
||||
params: [{ name: 'amount', type: 'float' }]
|
||||
})
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
// 在游戏循环中更新
|
||||
function gameLoop(dt: number) {
|
||||
blueprintSystem.process(entities, dt);
|
||||
@BlueprintMethod({ displayName: '受伤' })
|
||||
takeDamage(amount: number): boolean {
|
||||
this.current -= amount;
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
标记后,蓝图编辑器中会自动出现以下节点:
|
||||
- **Get Health** - 获取 Health 组件
|
||||
- **Get 当前生命值** - 获取 current 属性
|
||||
- **Set 当前生命值** - 设置 current 属性
|
||||
- **治疗** - 调用 heal 方法
|
||||
- **受伤** - 调用 takeDamage 方法
|
||||
|
||||
### 蓝图资产结构
|
||||
## ECS 集成架构
|
||||
|
||||
蓝图保存为 `.bp` 文件,包含以下结构:
|
||||
|
||||
```typescript
|
||||
interface BlueprintAsset {
|
||||
version: number; // 格式版本
|
||||
type: 'blueprint'; // 资产类型
|
||||
metadata: BlueprintMetadata; // 元数据
|
||||
variables: BlueprintVariable[]; // 变量定义
|
||||
nodes: BlueprintNode[]; // 节点实例
|
||||
connections: BlueprintConnection[]; // 连接
|
||||
}
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Core.update() │
|
||||
│ ↓ │
|
||||
│ Scene.updateSystems() │
|
||||
│ ↓ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ BlueprintSystem │ │
|
||||
│ │ │ │
|
||||
│ │ Matcher.all(BlueprintComponent) │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ process(entities) → blueprint.tick() for each entity │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ BlueprintVM.tick(dt) │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ Execute Event/ECS/Flow Nodes │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 节点类型
|
||||
|
||||
节点按功能分为以下类别:
|
||||
## 节点类型
|
||||
|
||||
| 类别 | 说明 | 颜色 |
|
||||
|------|------|------|
|
||||
| `event` | 事件节点(入口点) | 红色 |
|
||||
| `flow` | 流程控制 | 灰色 |
|
||||
| `entity` | 实体操作 | 蓝色 |
|
||||
| `component` | 组件访问 | 青色 |
|
||||
| `event` | 事件节点(BeginPlay, Tick, EndPlay) | 红色 |
|
||||
| `entity` | ECS 实体操作 | 蓝色 |
|
||||
| `component` | ECS 组件访问 | 青色 |
|
||||
| `flow` | 流程控制(Branch, Sequence, Loop) | 灰色 |
|
||||
| `math` | 数学运算 | 绿色 |
|
||||
| `logic` | 逻辑运算 | 红色 |
|
||||
| `variable` | 变量访问 | 紫色 |
|
||||
| `time` | 时间工具 | 青色 |
|
||||
| `debug` | 调试工具 | 灰色 |
|
||||
| `time` | 时间工具(Delay, GetDeltaTime) | 青色 |
|
||||
| `debug` | 调试工具(Print) | 灰色 |
|
||||
|
||||
### 引脚类型
|
||||
## 蓝图资产结构
|
||||
|
||||
节点通过引脚连接:
|
||||
蓝图保存为 `.bp` 文件:
|
||||
|
||||
```typescript
|
||||
interface BlueprintPinDefinition {
|
||||
name: string; // 引脚名称
|
||||
type: PinDataType; // 数据类型
|
||||
direction: 'input' | 'output';
|
||||
isExec?: boolean; // 是否是执行引脚
|
||||
defaultValue?: unknown;
|
||||
interface BlueprintAsset {
|
||||
version: number;
|
||||
type: 'blueprint';
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
variables: BlueprintVariable[];
|
||||
nodes: BlueprintNode[];
|
||||
connections: BlueprintConnection[];
|
||||
}
|
||||
|
||||
// 支持的数据类型
|
||||
type PinDataType =
|
||||
| 'exec' // 执行流
|
||||
| 'boolean' // 布尔值
|
||||
| 'number' // 数字
|
||||
| 'string' // 字符串
|
||||
| 'vector2' // 2D 向量
|
||||
| 'vector3' // 3D 向量
|
||||
| 'entity' // 实体引用
|
||||
| 'component' // 组件引用
|
||||
| 'any'; // 任意类型
|
||||
```
|
||||
|
||||
### 变量作用域
|
||||
|
||||
```typescript
|
||||
type VariableScope =
|
||||
| 'local' // 每次执行独立
|
||||
| 'instance' // 每个实体独立
|
||||
| 'global'; // 全局共享
|
||||
```
|
||||
|
||||
## 文档导航
|
||||
|
||||
- [虚拟机 API](./vm) - BlueprintVM 执行和上下文
|
||||
- [自定义节点](./custom-nodes) - 创建自定义节点
|
||||
- [内置节点](./nodes) - 内置节点参考
|
||||
- [蓝图组合](./composition) - 片段和组合器
|
||||
- [实际示例](./examples) - ECS 集成和最佳实践
|
||||
- [编辑器使用指南](./editor-guide) - Cocos Creator 蓝图编辑器教程
|
||||
- [虚拟机 API](./vm) - BlueprintVM 与 ECS 集成
|
||||
- [ECS 节点参考](./nodes) - 内置 ECS 操作节点
|
||||
- [自定义节点](./custom-nodes) - 创建自定义 ECS 节点
|
||||
- [蓝图组合](./composition) - 片段复用
|
||||
- [实际示例](./examples) - ECS 游戏逻辑示例
|
||||
|
||||
@@ -1,107 +1,547 @@
|
||||
---
|
||||
title: "内置节点"
|
||||
description: "蓝图内置节点参考"
|
||||
title: "ECS 节点参考"
|
||||
description: "蓝图内置 ECS 操作节点完整参考"
|
||||
---
|
||||
|
||||
本文档提供蓝图系统所有内置节点的完整参考,包含可视化示例。
|
||||
|
||||
## 引脚类型说明
|
||||
|
||||
<div class="bp-legend">
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff" stroke="#fff" stroke-width="1"/></svg> 执行流 (Exec)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#00a0e0" stroke-width="2"/></svg> 实体 (Entity)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7030c0" stroke-width="2"/></svg> 组件 (Component)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7ecd32" stroke-width="2"/></svg> 数值 (Float)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#e060e0" stroke-width="2"/></svg> 字符串 (String)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#8c0000" stroke-width="2"/></svg> 布尔 (Boolean)</div>
|
||||
</div>
|
||||
|
||||
## 事件节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `EventBeginPlay` | 蓝图启动时触发 |
|
||||
| `EventTick` | 每帧触发 |
|
||||
| `EventEndPlay` | 蓝图停止时触发 |
|
||||
| `EventCollision` | 碰撞时触发 |
|
||||
| `EventInput` | 输入事件触发 |
|
||||
| `EventTimer` | 定时器触发 |
|
||||
| `EventMessage` | 自定义消息触发 |
|
||||
生命周期事件,作为蓝图执行的入口点:
|
||||
|
||||
| 节点 | 说明 | 输出 |
|
||||
|------|------|------|
|
||||
| `EventBeginPlay` | 蓝图启动时触发 | Exec, Self (Entity) |
|
||||
| `EventTick` | 每帧触发 | Exec, Delta Time |
|
||||
| `EventEndPlay` | 蓝图停止时触发 | Exec |
|
||||
|
||||
### 示例:游戏初始化
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"beginplay-exec","to":"print-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="beginplay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Message</span>
|
||||
<span class="bp-pin-value">"游戏开始!"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 示例:每帧移动
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"tick-exec","to":"setprop-exec","type":"exec"},{"from":"tick-delta","to":"mul-a","type":"float"},{"from":"mul-result","to":"setprop-x","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="tick-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="tick-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta Time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
|
||||
<div class="bp-node-header math">Multiply</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="setprop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="setprop-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 实体节点 (Entity)
|
||||
|
||||
操作 ECS 实体:
|
||||
|
||||
| 节点 | 说明 | 类型 |
|
||||
|------|------|------|
|
||||
| `Get Self` | 获取拥有此蓝图的实体 | 纯节点 |
|
||||
| `Create Entity` | 在场景中创建新实体 | 执行节点 |
|
||||
| `Destroy Entity` | 销毁指定实体 | 执行节点 |
|
||||
| `Destroy Self` | 销毁自身实体 | 执行节点 |
|
||||
| `Is Valid` | 检查实体是否有效 | 纯节点 |
|
||||
| `Get Entity Name` | 获取实体名称 | 纯节点 |
|
||||
| `Set Entity Name` | 设置实体名称 | 执行节点 |
|
||||
| `Find Entity By Name` | 按名称查找实体 | 纯节点 |
|
||||
| `Find Entities By Tag` | 按标签查找所有实体 | 纯节点 |
|
||||
|
||||
### 示例:创建子弹
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"bp-exec","to":"create-exec","type":"exec"},{"from":"create-exec-out","to":"add-exec","type":"exec"},{"from":"create-entity","to":"add-entity","type":"entity"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="create-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="create-exec-out"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="create-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 520px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Add Transform</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="add-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="add-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 组件节点 (Component)
|
||||
|
||||
读写组件属性:
|
||||
|
||||
| 节点 | 说明 | 类型 |
|
||||
|------|------|------|
|
||||
| `Get Component` | 从实体获取指定类型组件 | 纯节点 |
|
||||
| `Has Component` | 检查实体是否拥有指定组件 | 纯节点 |
|
||||
| `Add Component` | 为实体添加组件 | 执行节点 |
|
||||
| `Remove Component` | 从实体移除组件 | 执行节点 |
|
||||
| `Get Property` | 获取组件属性值 | 纯节点 |
|
||||
| `Set Property` | 设置组件属性值 | 执行节点 |
|
||||
|
||||
### 示例:修改位置
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"self-entity","to":"getcomp-entity","type":"entity"},{"from":"getcomp-transform","to":"getprop-target","type":"component"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 100px;">
|
||||
<div class="bp-node-header pure">Get Self</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="self-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header pure">Get Component</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="getcomp-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="getcomp-transform"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Transform</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header pure">Get Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="getprop-target"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 流程控制节点
|
||||
|
||||
控制蓝图执行流程:
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Branch` | 条件分支 (if/else) |
|
||||
| `Sequence` | 顺序执行多个输出 |
|
||||
| `ForLoop` | 循环执行 |
|
||||
| `WhileLoop` | 条件循环 |
|
||||
| `DoOnce` | 只执行一次 |
|
||||
| `FlipFlop` | 交替执行两个分支 |
|
||||
| `Gate` | 可开关的执行门 |
|
||||
| `Branch` | 条件分支(if/else) |
|
||||
| `Sequence` | 按顺序执行多个分支 |
|
||||
| `For Loop` | 指定次数循环 |
|
||||
| `For Each` | 遍历数组元素 |
|
||||
| `While Loop` | 条件循环 |
|
||||
| `Do Once` | 仅执行一次 |
|
||||
| `Flip Flop` | 交替执行 A/B |
|
||||
| `Gate` | 门控开关 |
|
||||
|
||||
### 示例:条件分支
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"cond-exec","to":"branch-exec","type":"exec"},{"from":"cond-result","to":"branch-cond","type":"bool"},{"from":"branch-true","to":"print1-exec","type":"exec"},{"from":"branch-false","to":"print2-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 60px; width: 120px;">
|
||||
<div class="bp-node-header pure">Condition</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="cond-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="cond-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 60px; width: 110px;">
|
||||
<div class="bp-node-header flow">Branch</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="branch-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="branch-cond"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
|
||||
<span class="bp-pin-label">Cond</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="branch-true"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">True</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="branch-false"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">False</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 420px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="print1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"是"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 420px; top: 130px; width: 120px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="print2-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"否"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 示例:For 循环
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"forloop-bp-exec","to":"forloop-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="forloop-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header flow">For Loop</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="forloop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">First</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Last</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 时间节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Delay` | 延迟执行 |
|
||||
| `GetDeltaTime` | 获取帧间隔 |
|
||||
| `GetTime` | 获取运行时间 |
|
||||
| `SetTimer` | 设置定时器 |
|
||||
| `ClearTimer` | 清除定时器 |
|
||||
| 节点 | 说明 | 输出 |
|
||||
|------|------|------|
|
||||
| `Delay` | 延迟执行指定秒数 | Exec |
|
||||
| `Get Delta Time` | 获取帧间隔时间 | Float |
|
||||
| `Get Time` | 获取运行总时间 | Float |
|
||||
|
||||
### 示例:延迟执行
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"delay-bp-exec","to":"delay-exec","type":"exec"},{"from":"delay-done","to":"delay-print-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="delay-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="delay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 490px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="delay-print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"2秒后"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 数学节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Add` | 加法 |
|
||||
| `Subtract` | 减法 |
|
||||
| `Multiply` | 乘法 |
|
||||
| `Divide` | 除法 |
|
||||
| `Abs` | 绝对值 |
|
||||
| `Clamp` | 限制范围 |
|
||||
| `Lerp` | 线性插值 |
|
||||
| `Min` / `Max` | 最小/最大值 |
|
||||
| `Sin` / `Cos` | 三角函数 |
|
||||
| `Sqrt` | 平方根 |
|
||||
| `Power` | 幂运算 |
|
||||
### 基础运算
|
||||
|
||||
## 逻辑节点
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Add` | 加法 | A, B | A + B |
|
||||
| `Subtract` | 减法 | A, B | A - B |
|
||||
| `Multiply` | 乘法 | A, B | A × B |
|
||||
| `Divide` | 除法 | A, B | A / B |
|
||||
| `Modulo` | 取模 | A, B | A % B |
|
||||
|
||||
### 数学函数
|
||||
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Abs` | 绝对值 | Value | \|Value\| |
|
||||
| `Sqrt` | 平方根 | Value | √Value |
|
||||
| `Pow` | 幂运算 | Base, Exp | Base^Exp |
|
||||
| `Floor` | 向下取整 | Value | ⌊Value⌋ |
|
||||
| `Ceil` | 向上取整 | Value | ⌈Value⌉ |
|
||||
| `Round` | 四舍五入 | Value | round(Value) |
|
||||
| `Clamp` | 区间钳制 | Value, Min, Max | min(max(V, Min), Max) |
|
||||
| `Lerp` | 线性插值 | A, B, Alpha | A + (B-A) × Alpha |
|
||||
| `Min` | 取最小值 | A, B | min(A, B) |
|
||||
| `Max` | 取最大值 | A, B | max(A, B) |
|
||||
|
||||
### 三角函数
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `And` | 逻辑与 |
|
||||
| `Or` | 逻辑或 |
|
||||
| `Not` | 逻辑非 |
|
||||
| `Equal` | 相等比较 |
|
||||
| `NotEqual` | 不等比较 |
|
||||
| `Greater` | 大于比较 |
|
||||
| `Less` | 小于比较 |
|
||||
| `Sin` | 正弦 |
|
||||
| `Cos` | 余弦 |
|
||||
| `Tan` | 正切 |
|
||||
| `Asin` | 反正弦 |
|
||||
| `Acos` | 反余弦 |
|
||||
| `Atan` | 反正切 |
|
||||
| `Atan2` | 二参数反正切 |
|
||||
|
||||
## 向量节点
|
||||
### 随机数
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `MakeVector2` | 创建 2D 向量 |
|
||||
| `BreakVector2` | 分解 2D 向量 |
|
||||
| `VectorAdd` | 向量加法 |
|
||||
| `VectorSubtract` | 向量减法 |
|
||||
| `VectorMultiply` | 向量乘法 |
|
||||
| `VectorLength` | 向量长度 |
|
||||
| `VectorNormalize` | 向量归一化 |
|
||||
| `VectorDistance` | 向量距离 |
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Random` | 随机浮点数 [0, 1) | - | Float |
|
||||
| `Random Range` | 范围内随机数 | Min, Max | Float |
|
||||
| `Random Int` | 随机整数 | Min, Max | Int |
|
||||
|
||||
## 实体节点
|
||||
### 比较节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `GetSelf` | 获取当前实体 |
|
||||
| `GetComponent` | 获取组件 |
|
||||
| `HasComponent` | 检查组件 |
|
||||
| `AddComponent` | 添加组件 |
|
||||
| `RemoveComponent` | 移除组件 |
|
||||
| `SpawnEntity` | 创建实体 |
|
||||
| `DestroyEntity` | 销毁实体 |
|
||||
| 节点 | 说明 | 输出 |
|
||||
|------|------|------|
|
||||
| `Equal` | A == B | Boolean |
|
||||
| `Not Equal` | A != B | Boolean |
|
||||
| `Greater` | A > B | Boolean |
|
||||
| `Greater Or Equal` | A >= B | Boolean |
|
||||
| `Less` | A < B | Boolean |
|
||||
| `Less Or Equal` | A <= B | Boolean |
|
||||
|
||||
### 扩展数学节点
|
||||
|
||||
> **Vector2、Fixed32、FixedVector2、Color** 等高级数学节点由 `@esengine/ecs-framework-math` 模块提供。
|
||||
>
|
||||
> 详见:[数学库蓝图节点](/modules/math/blueprint-nodes)
|
||||
|
||||
### 示例:钳制数值
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"rand-result","to":"clamp-value","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math">Random Range</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Min</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Max</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="rand-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math">Clamp</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="clamp-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Min</span>
|
||||
<span class="bp-pin-value">20</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Max</span>
|
||||
<span class="bp-pin-value">80</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 变量节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `GetVariable` | 获取变量值 |
|
||||
| `SetVariable` | 设置变量值 |
|
||||
蓝图定义的变量会自动生成 Get 和 Set 节点:
|
||||
|
||||
| 节点 | 说明 | 类型 |
|
||||
|------|------|------|
|
||||
| `Get <变量名>` | 读取变量值 | 纯节点 |
|
||||
| `Set <变量名>` | 设置变量值 | 执行节点 |
|
||||
|
||||
## 调试节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Print` | 打印到控制台 |
|
||||
| `DrawDebugLine` | 绘制调试线 |
|
||||
| `DrawDebugPoint` | 绘制调试点 |
|
||||
| `Breakpoint` | 调试断点 |
|
||||
| `Print` | 输出消息到控制台 |
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [数学库蓝图节点](/modules/math/blueprint-nodes) - Vector2、Fixed32、Color 等数学节点
|
||||
- [蓝图编辑器指南](/modules/blueprint/editor-guide) - 学习如何使用编辑器
|
||||
- [自定义节点](/modules/blueprint/custom-nodes) - 创建自定义节点
|
||||
- [蓝图虚拟机](/modules/blueprint/vm) - 运行时 API
|
||||
|
||||
@@ -45,7 +45,7 @@ interface ExecutionContext {
|
||||
time: number; // 总运行时间
|
||||
|
||||
// 获取输入值
|
||||
getInput<T>(nodeId: string, pinName: string): T;
|
||||
evaluateInput(nodeId: string, pinName: string, defaultValue: unknown): unknown;
|
||||
|
||||
// 设置输出值
|
||||
setOutput(nodeId: string, pinName: string, value: unknown): void;
|
||||
@@ -70,35 +70,33 @@ interface ExecutionResult {
|
||||
|
||||
## 与 ECS 集成
|
||||
|
||||
### 使用蓝图系统
|
||||
### 使用内置蓝图系统
|
||||
|
||||
```typescript
|
||||
import { createBlueprintSystem } from '@esengine/blueprint';
|
||||
import { Scene, Core } from '@esengine/ecs-framework';
|
||||
import { BlueprintSystem, BlueprintComponent } from '@esengine/blueprint';
|
||||
|
||||
class GameScene {
|
||||
private blueprintSystem: BlueprintSystem;
|
||||
// 添加蓝图系统到场景
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
Core.setScene(scene);
|
||||
|
||||
initialize() {
|
||||
this.blueprintSystem = createBlueprintSystem(this.scene);
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
// 处理所有带蓝图组件的实体
|
||||
this.blueprintSystem.process(this.entities, dt);
|
||||
}
|
||||
}
|
||||
// 为实体添加蓝图
|
||||
const entity = scene.createEntity('Player');
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
entity.addComponent(blueprint);
|
||||
```
|
||||
|
||||
### 触发蓝图事件
|
||||
|
||||
```typescript
|
||||
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
|
||||
|
||||
// 触发内置事件
|
||||
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
|
||||
|
||||
// 触发自定义事件
|
||||
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
|
||||
// 从实体获取蓝图组件并触发事件
|
||||
const blueprint = entity.getComponent(BlueprintComponent);
|
||||
if (blueprint?.vm) {
|
||||
blueprint.vm.triggerEvent('EventCollision', { other: otherEntity });
|
||||
blueprint.vm.triggerCustomEvent('OnPickup', { item: itemEntity });
|
||||
}
|
||||
```
|
||||
|
||||
## 序列化
|
||||
|
||||
136
docs/src/content/docs/modules/database-drivers/index.md
Normal file
136
docs/src/content/docs/modules/database-drivers/index.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: "数据库驱动"
|
||||
description: "MongoDB、Redis 等数据库的连接管理和驱动封装"
|
||||
---
|
||||
|
||||
`@esengine/database-drivers` 是 ESEngine 的数据库连接管理层,提供 MongoDB、Redis 等数据库的统一连接管理。
|
||||
|
||||
## 特性
|
||||
|
||||
- **连接池管理** - 自动管理连接池,优化资源使用
|
||||
- **自动重连** - 连接断开时自动重连
|
||||
- **事件通知** - 连接状态变化事件
|
||||
- **类型解耦** - 简化接口,不依赖原生驱动类型
|
||||
- **共享连接** - 单一连接可供多个模块共享
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/database-drivers
|
||||
```
|
||||
|
||||
**对等依赖:**
|
||||
```bash
|
||||
npm install mongodb # MongoDB 支持
|
||||
npm install ioredis # Redis 支持
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ @esengine/database-drivers (Layer 1) │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ MongoConnection │ │ RedisConnection │ │
|
||||
│ │ - 连接池管理 │ │ - 自动重连 │ │
|
||||
│ │ - 自动重连 │ │ - Key 前缀 │ │
|
||||
│ │ - 事件发射器 │ │ - 事件发射器 │ │
|
||||
│ └──────────┬──────────┘ └─────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IMongoCollection<T> │ ← 类型安全接口 │
|
||||
│ │ (适配器模式) │ 与 mongodb 类型解耦 │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ @esengine/database │ │ @esengine/transaction │
|
||||
│ (仓库模式) │ │ (分布式事务) │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### MongoDB 连接
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
|
||||
// 创建连接
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game',
|
||||
pool: {
|
||||
minSize: 5,
|
||||
maxSize: 20
|
||||
},
|
||||
autoReconnect: true
|
||||
})
|
||||
|
||||
// 监听事件
|
||||
mongo.on('connected', () => console.log('MongoDB 已连接'))
|
||||
mongo.on('disconnected', () => console.log('MongoDB 已断开'))
|
||||
mongo.on('error', (e) => console.error('错误:', e.error))
|
||||
|
||||
// 建立连接
|
||||
await mongo.connect()
|
||||
|
||||
// 使用集合
|
||||
const users = mongo.collection<User>('users')
|
||||
await users.insertOne({ name: 'John', score: 100 })
|
||||
|
||||
const user = await users.findOne({ name: 'John' })
|
||||
|
||||
// 完成后断开连接
|
||||
await mongo.disconnect()
|
||||
```
|
||||
|
||||
### Redis 连接
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection } from '@esengine/database-drivers'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
keyPrefix: 'game:',
|
||||
autoReconnect: true
|
||||
})
|
||||
|
||||
await redis.connect()
|
||||
|
||||
// 基本操作
|
||||
await redis.set('session:123', 'data', 3600) // 带 TTL
|
||||
const value = await redis.get('session:123')
|
||||
|
||||
await redis.disconnect()
|
||||
```
|
||||
|
||||
## 服务容器集成
|
||||
|
||||
```typescript
|
||||
import { ServiceContainer } from '@esengine/ecs-framework'
|
||||
import {
|
||||
createMongoConnection,
|
||||
MongoConnectionToken,
|
||||
RedisConnectionToken
|
||||
} from '@esengine/database-drivers'
|
||||
|
||||
const services = new ServiceContainer()
|
||||
|
||||
// 注册连接
|
||||
const mongo = createMongoConnection({ uri: '...', database: 'game' })
|
||||
await mongo.connect()
|
||||
services.register(MongoConnectionToken, mongo)
|
||||
|
||||
// 在其他模块中获取
|
||||
const connection = services.get(MongoConnectionToken)
|
||||
const users = connection.collection('users')
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [MongoDB 连接](/modules/database-drivers/mongo/) - MongoDB 连接详细配置
|
||||
- [Redis 连接](/modules/database-drivers/redis/) - Redis 连接详细配置
|
||||
- [服务令牌](/modules/database-drivers/tokens/) - 依赖注入集成
|
||||
265
docs/src/content/docs/modules/database-drivers/mongo.md
Normal file
265
docs/src/content/docs/modules/database-drivers/mongo.md
Normal file
@@ -0,0 +1,265 @@
|
||||
---
|
||||
title: "MongoDB 连接"
|
||||
description: "MongoDB 连接管理、连接池、自动重连"
|
||||
---
|
||||
|
||||
## 配置选项
|
||||
|
||||
```typescript
|
||||
interface MongoConnectionConfig {
|
||||
/** MongoDB 连接 URI */
|
||||
uri: string
|
||||
|
||||
/** 数据库名称 */
|
||||
database: string
|
||||
|
||||
/** 连接池配置 */
|
||||
pool?: {
|
||||
minSize?: number // 最小连接数
|
||||
maxSize?: number // 最大连接数
|
||||
acquireTimeout?: number // 获取连接超时(毫秒)
|
||||
maxLifetime?: number // 连接最大生命周期(毫秒)
|
||||
}
|
||||
|
||||
/** 是否自动重连(默认 true) */
|
||||
autoReconnect?: boolean
|
||||
|
||||
/** 重连间隔(毫秒,默认 5000) */
|
||||
reconnectInterval?: number
|
||||
|
||||
/** 最大重连次数(默认 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection, MongoConnectionToken } from '@esengine/database-drivers'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game',
|
||||
pool: {
|
||||
minSize: 5,
|
||||
maxSize: 20,
|
||||
acquireTimeout: 5000,
|
||||
maxLifetime: 300000
|
||||
},
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10
|
||||
})
|
||||
|
||||
// 事件监听
|
||||
mongo.on('connected', () => {
|
||||
console.log('MongoDB 已连接')
|
||||
})
|
||||
|
||||
mongo.on('disconnected', () => {
|
||||
console.log('MongoDB 已断开')
|
||||
})
|
||||
|
||||
mongo.on('reconnecting', () => {
|
||||
console.log('MongoDB 正在重连...')
|
||||
})
|
||||
|
||||
mongo.on('reconnected', () => {
|
||||
console.log('MongoDB 重连成功')
|
||||
})
|
||||
|
||||
mongo.on('error', (event) => {
|
||||
console.error('MongoDB 错误:', event.error)
|
||||
})
|
||||
|
||||
// 连接
|
||||
await mongo.connect()
|
||||
|
||||
// 检查状态
|
||||
console.log('已连接:', mongo.isConnected())
|
||||
console.log('Ping:', await mongo.ping())
|
||||
```
|
||||
|
||||
## IMongoConnection 接口
|
||||
|
||||
```typescript
|
||||
interface IMongoConnection {
|
||||
/** 连接 ID */
|
||||
readonly id: string
|
||||
|
||||
/** 连接状态 */
|
||||
readonly state: ConnectionState
|
||||
|
||||
/** 建立连接 */
|
||||
connect(): Promise<void>
|
||||
|
||||
/** 断开连接 */
|
||||
disconnect(): Promise<void>
|
||||
|
||||
/** 检查是否已连接 */
|
||||
isConnected(): boolean
|
||||
|
||||
/** 测试连接 */
|
||||
ping(): Promise<boolean>
|
||||
|
||||
/** 获取类型化集合 */
|
||||
collection<T extends object>(name: string): IMongoCollection<T>
|
||||
|
||||
/** 获取数据库接口 */
|
||||
getDatabase(): IMongoDatabase
|
||||
|
||||
/** 获取原生客户端(高级用法) */
|
||||
getNativeClient(): MongoClientType
|
||||
|
||||
/** 获取原生数据库(高级用法) */
|
||||
getNativeDatabase(): Db
|
||||
}
|
||||
```
|
||||
|
||||
## IMongoCollection 接口
|
||||
|
||||
类型安全的集合接口,与原生 MongoDB 类型解耦:
|
||||
|
||||
```typescript
|
||||
interface IMongoCollection<T extends object> {
|
||||
readonly name: string
|
||||
|
||||
// 查询
|
||||
findOne(filter: object, options?: FindOptions): Promise<T | null>
|
||||
find(filter: object, options?: FindOptions): Promise<T[]>
|
||||
countDocuments(filter?: object): Promise<number>
|
||||
|
||||
// 插入
|
||||
insertOne(doc: T): Promise<InsertOneResult>
|
||||
insertMany(docs: T[]): Promise<InsertManyResult>
|
||||
|
||||
// 更新
|
||||
updateOne(filter: object, update: object): Promise<UpdateResult>
|
||||
updateMany(filter: object, update: object): Promise<UpdateResult>
|
||||
findOneAndUpdate(
|
||||
filter: object,
|
||||
update: object,
|
||||
options?: FindOneAndUpdateOptions
|
||||
): Promise<T | null>
|
||||
|
||||
// 删除
|
||||
deleteOne(filter: object): Promise<DeleteResult>
|
||||
deleteMany(filter: object): Promise<DeleteResult>
|
||||
|
||||
// 索引
|
||||
createIndex(
|
||||
spec: Record<string, 1 | -1>,
|
||||
options?: IndexOptions
|
||||
): Promise<string>
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本 CRUD
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
score: number
|
||||
}
|
||||
|
||||
const users = mongo.collection<User>('users')
|
||||
|
||||
// 插入
|
||||
await users.insertOne({
|
||||
id: '1',
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
score: 100
|
||||
})
|
||||
|
||||
// 查询
|
||||
const user = await users.findOne({ name: 'John' })
|
||||
|
||||
const topUsers = await users.find(
|
||||
{ score: { $gte: 100 } },
|
||||
{ sort: { score: -1 }, limit: 10 }
|
||||
)
|
||||
|
||||
// 更新
|
||||
await users.updateOne(
|
||||
{ id: '1' },
|
||||
{ $inc: { score: 10 } }
|
||||
)
|
||||
|
||||
// 删除
|
||||
await users.deleteOne({ id: '1' })
|
||||
```
|
||||
|
||||
### 批量操作
|
||||
|
||||
```typescript
|
||||
// 批量插入
|
||||
await users.insertMany([
|
||||
{ id: '1', name: 'Alice', email: 'alice@example.com', score: 100 },
|
||||
{ id: '2', name: 'Bob', email: 'bob@example.com', score: 200 },
|
||||
{ id: '3', name: 'Carol', email: 'carol@example.com', score: 150 }
|
||||
])
|
||||
|
||||
// 批量更新
|
||||
await users.updateMany(
|
||||
{ score: { $lt: 100 } },
|
||||
{ $set: { status: 'inactive' } }
|
||||
)
|
||||
|
||||
// 批量删除
|
||||
await users.deleteMany({ status: 'inactive' })
|
||||
```
|
||||
|
||||
### 索引管理
|
||||
|
||||
```typescript
|
||||
// 创建索引
|
||||
await users.createIndex({ email: 1 }, { unique: true })
|
||||
await users.createIndex({ score: -1 })
|
||||
await users.createIndex({ name: 1, score: -1 })
|
||||
```
|
||||
|
||||
## 与其他模块集成
|
||||
|
||||
### 与 @esengine/database 集成
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { UserRepository, createRepository } from '@esengine/database'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// 使用 UserRepository
|
||||
const userRepo = new UserRepository(mongo)
|
||||
await userRepo.register({ username: 'john', password: '123456' })
|
||||
|
||||
// 使用通用仓库
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
```
|
||||
|
||||
### 与 @esengine/transaction 集成
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { createMongoStorage, TransactionManager } from '@esengine/transaction'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// 创建事务存储(共享连接)
|
||||
const storage = createMongoStorage(mongo)
|
||||
await storage.ensureIndexes()
|
||||
|
||||
const txManager = new TransactionManager({ storage })
|
||||
```
|
||||
228
docs/src/content/docs/modules/database-drivers/redis.md
Normal file
228
docs/src/content/docs/modules/database-drivers/redis.md
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: "Redis 连接"
|
||||
description: "Redis 连接管理、自动重连、键前缀"
|
||||
---
|
||||
|
||||
## 配置选项
|
||||
|
||||
```typescript
|
||||
interface RedisConnectionConfig {
|
||||
/** Redis 主机 */
|
||||
host?: string
|
||||
|
||||
/** Redis 端口 */
|
||||
port?: number
|
||||
|
||||
/** 认证密码 */
|
||||
password?: string
|
||||
|
||||
/** 数据库编号 */
|
||||
db?: number
|
||||
|
||||
/** 键前缀 */
|
||||
keyPrefix?: string
|
||||
|
||||
/** 是否自动重连(默认 true) */
|
||||
autoReconnect?: boolean
|
||||
|
||||
/** 重连间隔(毫秒,默认 5000) */
|
||||
reconnectInterval?: number
|
||||
|
||||
/** 最大重连次数(默认 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection, RedisConnectionToken } from '@esengine/database-drivers'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: 'your-password',
|
||||
db: 0,
|
||||
keyPrefix: 'game:',
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10
|
||||
})
|
||||
|
||||
// 事件监听
|
||||
redis.on('connected', () => {
|
||||
console.log('Redis 已连接')
|
||||
})
|
||||
|
||||
redis.on('disconnected', () => {
|
||||
console.log('Redis 已断开')
|
||||
})
|
||||
|
||||
redis.on('error', (event) => {
|
||||
console.error('Redis 错误:', event.error)
|
||||
})
|
||||
|
||||
// 连接
|
||||
await redis.connect()
|
||||
|
||||
// 检查状态
|
||||
console.log('已连接:', redis.isConnected())
|
||||
console.log('Ping:', await redis.ping())
|
||||
```
|
||||
|
||||
## IRedisConnection 接口
|
||||
|
||||
```typescript
|
||||
interface IRedisConnection {
|
||||
/** 连接 ID */
|
||||
readonly id: string
|
||||
|
||||
/** 连接状态 */
|
||||
readonly state: ConnectionState
|
||||
|
||||
/** 建立连接 */
|
||||
connect(): Promise<void>
|
||||
|
||||
/** 断开连接 */
|
||||
disconnect(): Promise<void>
|
||||
|
||||
/** 检查是否已连接 */
|
||||
isConnected(): boolean
|
||||
|
||||
/** 测试连接 */
|
||||
ping(): Promise<boolean>
|
||||
|
||||
/** 获取值 */
|
||||
get(key: string): Promise<string | null>
|
||||
|
||||
/** 设置值(可选 TTL,单位秒) */
|
||||
set(key: string, value: string, ttl?: number): Promise<void>
|
||||
|
||||
/** 删除键 */
|
||||
del(key: string): Promise<boolean>
|
||||
|
||||
/** 检查键是否存在 */
|
||||
exists(key: string): Promise<boolean>
|
||||
|
||||
/** 设置过期时间(秒) */
|
||||
expire(key: string, seconds: number): Promise<boolean>
|
||||
|
||||
/** 获取剩余过期时间(秒) */
|
||||
ttl(key: string): Promise<number>
|
||||
|
||||
/** 获取原生客户端(高级用法) */
|
||||
getNativeClient(): Redis
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本操作
|
||||
|
||||
```typescript
|
||||
// 设置值
|
||||
await redis.set('user:1:name', 'John')
|
||||
|
||||
// 设置带过期时间的值(1 小时)
|
||||
await redis.set('session:abc123', 'user-data', 3600)
|
||||
|
||||
// 获取值
|
||||
const name = await redis.get('user:1:name')
|
||||
|
||||
// 检查键是否存在
|
||||
const exists = await redis.exists('user:1:name')
|
||||
|
||||
// 删除键
|
||||
await redis.del('user:1:name')
|
||||
|
||||
// 获取剩余过期时间
|
||||
const ttl = await redis.ttl('session:abc123')
|
||||
```
|
||||
|
||||
### 键前缀
|
||||
|
||||
配置 `keyPrefix` 后,所有操作自动添加前缀:
|
||||
|
||||
```typescript
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
keyPrefix: 'game:'
|
||||
})
|
||||
|
||||
// 实际操作的键是 'game:user:1'
|
||||
await redis.set('user:1', 'data')
|
||||
|
||||
// 实际查询的键是 'game:user:1'
|
||||
const data = await redis.get('user:1')
|
||||
```
|
||||
|
||||
### 高级操作
|
||||
|
||||
使用原生客户端进行高级操作:
|
||||
|
||||
```typescript
|
||||
const client = redis.getNativeClient()
|
||||
|
||||
// 使用 Pipeline
|
||||
const pipeline = client.pipeline()
|
||||
pipeline.set('key1', 'value1')
|
||||
pipeline.set('key2', 'value2')
|
||||
pipeline.set('key3', 'value3')
|
||||
await pipeline.exec()
|
||||
|
||||
// 使用事务
|
||||
const multi = client.multi()
|
||||
multi.incr('counter')
|
||||
multi.get('counter')
|
||||
const results = await multi.exec()
|
||||
|
||||
// 使用 Lua 脚本
|
||||
const result = await client.eval(
|
||||
`return redis.call('get', KEYS[1])`,
|
||||
1,
|
||||
'mykey'
|
||||
)
|
||||
```
|
||||
|
||||
## 与事务系统集成
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection } from '@esengine/database-drivers'
|
||||
import { RedisStorage, TransactionManager } from '@esengine/transaction'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
keyPrefix: 'tx:'
|
||||
})
|
||||
await redis.connect()
|
||||
|
||||
// 创建事务存储
|
||||
const storage = new RedisStorage({
|
||||
factory: () => redis.getNativeClient(),
|
||||
prefix: 'tx:'
|
||||
})
|
||||
|
||||
const txManager = new TransactionManager({ storage })
|
||||
```
|
||||
|
||||
## 连接状态
|
||||
|
||||
```typescript
|
||||
type ConnectionState =
|
||||
| 'disconnected' // 未连接
|
||||
| 'connecting' // 连接中
|
||||
| 'connected' // 已连接
|
||||
| 'disconnecting' // 断开中
|
||||
| 'error' // 错误状态
|
||||
```
|
||||
|
||||
## 事件
|
||||
|
||||
| 事件 | 描述 |
|
||||
|------|------|
|
||||
| `connected` | 连接成功 |
|
||||
| `disconnected` | 连接断开 |
|
||||
| `reconnecting` | 正在重连 |
|
||||
| `reconnected` | 重连成功 |
|
||||
| `error` | 发生错误 |
|
||||
140
docs/src/content/docs/modules/database/index.md
Normal file
140
docs/src/content/docs/modules/database/index.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: "数据库仓库"
|
||||
description: "Repository 模式的数据库操作层,支持 CRUD、分页、软删除"
|
||||
---
|
||||
|
||||
`@esengine/database` 是 ESEngine 的数据库操作层,基于 Repository 模式提供类型安全的 CRUD 操作。
|
||||
|
||||
## 特性
|
||||
|
||||
- **Repository 模式** - 泛型 CRUD 操作,类型安全
|
||||
- **分页查询** - 内置分页支持
|
||||
- **软删除** - 可选的软删除与恢复
|
||||
- **用户管理** - 开箱即用的 UserRepository
|
||||
- **密码安全** - 使用 scrypt 的密码哈希工具
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/database @esengine/database-drivers
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基本仓库
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { Repository, createRepository } from '@esengine/database'
|
||||
|
||||
// 定义实体
|
||||
interface Player {
|
||||
id: string
|
||||
name: string
|
||||
score: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// 创建连接
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// 创建仓库
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
|
||||
// CRUD 操作
|
||||
const player = await playerRepo.create({
|
||||
name: 'John',
|
||||
score: 0
|
||||
})
|
||||
|
||||
const found = await playerRepo.findById(player.id)
|
||||
|
||||
await playerRepo.update(player.id, { score: 100 })
|
||||
|
||||
await playerRepo.delete(player.id)
|
||||
```
|
||||
|
||||
### 自定义仓库
|
||||
|
||||
```typescript
|
||||
import { Repository, BaseEntity } from '@esengine/database'
|
||||
import type { IMongoConnection } from '@esengine/database-drivers'
|
||||
|
||||
interface Player extends BaseEntity {
|
||||
name: string
|
||||
score: number
|
||||
rank?: string
|
||||
}
|
||||
|
||||
class PlayerRepository extends Repository<Player> {
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'players')
|
||||
}
|
||||
|
||||
async findTopPlayers(limit: number = 10): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit
|
||||
})
|
||||
}
|
||||
|
||||
async findByRank(rank: string): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
where: { rank }
|
||||
})
|
||||
}
|
||||
|
||||
async incrementScore(playerId: string, amount: number): Promise<Player | null> {
|
||||
const player = await this.findById(playerId)
|
||||
if (!player) return null
|
||||
return this.update(playerId, { score: player.score + amount })
|
||||
}
|
||||
}
|
||||
|
||||
// 使用
|
||||
const playerRepo = new PlayerRepository(mongo)
|
||||
const topPlayers = await playerRepo.findTopPlayers(5)
|
||||
```
|
||||
|
||||
### 用户仓库
|
||||
|
||||
```typescript
|
||||
import { UserRepository } from '@esengine/database'
|
||||
|
||||
const userRepo = new UserRepository(mongo)
|
||||
|
||||
// 注册新用户
|
||||
const user = await userRepo.register({
|
||||
username: 'john',
|
||||
password: 'securePassword123',
|
||||
email: 'john@example.com'
|
||||
})
|
||||
|
||||
// 认证
|
||||
const authenticated = await userRepo.authenticate('john', 'securePassword123')
|
||||
if (authenticated) {
|
||||
console.log('登录成功:', authenticated.username)
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
await userRepo.changePassword(user.id, 'securePassword123', 'newPassword456')
|
||||
|
||||
// 角色管理
|
||||
await userRepo.addRole(user.id, 'admin')
|
||||
await userRepo.removeRole(user.id, 'admin')
|
||||
|
||||
// 查询用户
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
const john = await userRepo.findByUsername('john')
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [仓库 API](/modules/database/repository/) - Repository 详细 API
|
||||
- [用户管理](/modules/database/user/) - UserRepository 用法
|
||||
- [查询语法](/modules/database/query/) - 查询条件语法
|
||||
185
docs/src/content/docs/modules/database/query.md
Normal file
185
docs/src/content/docs/modules/database/query.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: "查询语法"
|
||||
description: "查询条件操作符和语法"
|
||||
---
|
||||
|
||||
## 基本查询
|
||||
|
||||
### 精确匹配
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
name: 'John',
|
||||
status: 'active'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 使用操作符
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 查询操作符
|
||||
|
||||
| 操作符 | 描述 | 示例 |
|
||||
|--------|------|------|
|
||||
| `$eq` | 等于 | `{ score: { $eq: 100 } }` |
|
||||
| `$ne` | 不等于 | `{ status: { $ne: 'banned' } }` |
|
||||
| `$gt` | 大于 | `{ score: { $gt: 50 } }` |
|
||||
| `$gte` | 大于等于 | `{ level: { $gte: 10 } }` |
|
||||
| `$lt` | 小于 | `{ age: { $lt: 18 } }` |
|
||||
| `$lte` | 小于等于 | `{ price: { $lte: 100 } }` |
|
||||
| `$in` | 在数组中 | `{ rank: { $in: ['gold', 'platinum'] } }` |
|
||||
| `$nin` | 不在数组中 | `{ status: { $nin: ['banned', 'suspended'] } }` |
|
||||
| `$like` | 模式匹配 | `{ name: { $like: '%john%' } }` |
|
||||
| `$regex` | 正则匹配 | `{ email: { $regex: '@gmail.com$' } }` |
|
||||
|
||||
## 逻辑操作符
|
||||
|
||||
### $or
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ score: { $gte: 1000 } },
|
||||
{ rank: 'legendary' }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### $and
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
$and: [
|
||||
{ score: { $gte: 100 } },
|
||||
{ score: { $lte: 500 } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 组合使用
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
$or: [
|
||||
{ rank: 'gold' },
|
||||
{ score: { $gte: 1000 } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 模式匹配
|
||||
|
||||
### $like 语法
|
||||
|
||||
- `%` - 匹配任意字符序列
|
||||
- `_` - 匹配单个字符
|
||||
|
||||
```typescript
|
||||
// 以 'John' 开头
|
||||
{ name: { $like: 'John%' } }
|
||||
|
||||
// 以 'son' 结尾
|
||||
{ name: { $like: '%son' } }
|
||||
|
||||
// 包含 'oh'
|
||||
{ name: { $like: '%oh%' } }
|
||||
|
||||
// 第二个字符是 'o'
|
||||
{ name: { $like: '_o%' } }
|
||||
```
|
||||
|
||||
### $regex 语法
|
||||
|
||||
使用标准正则表达式:
|
||||
|
||||
```typescript
|
||||
// 以 'John' 开头(大小写不敏感)
|
||||
{ name: { $regex: '^john' } }
|
||||
|
||||
// Gmail 邮箱
|
||||
{ email: { $regex: '@gmail\\.com$' } }
|
||||
|
||||
// 包含数字
|
||||
{ username: { $regex: '\\d+' } }
|
||||
```
|
||||
|
||||
## 排序
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
sort: {
|
||||
score: 'desc', // 降序
|
||||
name: 'asc' // 升序
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 分页
|
||||
|
||||
### 使用 limit/offset
|
||||
|
||||
```typescript
|
||||
// 第一页
|
||||
await repo.findMany({
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
|
||||
// 第二页
|
||||
await repo.findMany({
|
||||
limit: 20,
|
||||
offset: 20
|
||||
})
|
||||
```
|
||||
|
||||
### 使用 findPaginated
|
||||
|
||||
```typescript
|
||||
const result = await repo.findPaginated(
|
||||
{ page: 2, pageSize: 20 },
|
||||
{ sort: { createdAt: 'desc' } }
|
||||
)
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
// 查找活跃的金牌玩家,分数在 100-1000 之间
|
||||
// 按分数降序排列,取前 10 个
|
||||
const players = await repo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
rank: 'gold',
|
||||
score: { $gte: 100, $lte: 1000 }
|
||||
},
|
||||
sort: { score: 'desc' },
|
||||
limit: 10
|
||||
})
|
||||
|
||||
// 搜索用户名包含 'john' 或邮箱是 gmail 的用户
|
||||
const users = await repo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ username: { $like: '%john%' } },
|
||||
{ email: { $regex: '@gmail\\.com$' } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
244
docs/src/content/docs/modules/database/repository.md
Normal file
244
docs/src/content/docs/modules/database/repository.md
Normal file
@@ -0,0 +1,244 @@
|
||||
---
|
||||
title: "Repository API"
|
||||
description: "泛型仓库接口,CRUD 操作、分页、软删除"
|
||||
---
|
||||
|
||||
## 创建仓库
|
||||
|
||||
### 使用工厂函数
|
||||
|
||||
```typescript
|
||||
import { createRepository } from '@esengine/database'
|
||||
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
|
||||
// 启用软删除
|
||||
const playerRepo = createRepository<Player>(mongo, 'players', true)
|
||||
```
|
||||
|
||||
### 继承 Repository
|
||||
|
||||
```typescript
|
||||
import { Repository, BaseEntity } from '@esengine/database'
|
||||
|
||||
interface Player extends BaseEntity {
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
class PlayerRepository extends Repository<Player> {
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'players', false) // 第三个参数:启用软删除
|
||||
}
|
||||
|
||||
// 添加自定义方法
|
||||
async findTopPlayers(limit: number): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## BaseEntity 接口
|
||||
|
||||
所有实体必须继承 `BaseEntity`:
|
||||
|
||||
```typescript
|
||||
interface BaseEntity {
|
||||
id: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
deletedAt?: Date // 软删除时使用
|
||||
}
|
||||
```
|
||||
|
||||
## 查询方法
|
||||
|
||||
### findById
|
||||
|
||||
```typescript
|
||||
const player = await repo.findById('player-123')
|
||||
```
|
||||
|
||||
### findOne
|
||||
|
||||
```typescript
|
||||
const player = await repo.findOne({
|
||||
where: { name: 'John' }
|
||||
})
|
||||
|
||||
const topPlayer = await repo.findOne({
|
||||
sort: { score: 'desc' }
|
||||
})
|
||||
```
|
||||
|
||||
### findMany
|
||||
|
||||
```typescript
|
||||
// 简单查询
|
||||
const players = await repo.findMany({
|
||||
where: { rank: 'gold' }
|
||||
})
|
||||
|
||||
// 复杂查询
|
||||
const players = await repo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] }
|
||||
},
|
||||
sort: { score: 'desc', name: 'asc' },
|
||||
limit: 10,
|
||||
offset: 0
|
||||
})
|
||||
```
|
||||
|
||||
### findPaginated
|
||||
|
||||
```typescript
|
||||
const result = await repo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { rank: 'gold' },
|
||||
sort: { score: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
console.log(result.data) // Player[]
|
||||
console.log(result.total) // 总数量
|
||||
console.log(result.totalPages) // 总页数
|
||||
console.log(result.hasNext) // 是否有下一页
|
||||
console.log(result.hasPrev) // 是否有上一页
|
||||
```
|
||||
|
||||
### count
|
||||
|
||||
```typescript
|
||||
const count = await repo.count({
|
||||
where: { rank: 'gold' }
|
||||
})
|
||||
```
|
||||
|
||||
### exists
|
||||
|
||||
```typescript
|
||||
const exists = await repo.exists({
|
||||
where: { email: 'john@example.com' }
|
||||
})
|
||||
```
|
||||
|
||||
## 创建方法
|
||||
|
||||
### create
|
||||
|
||||
```typescript
|
||||
const player = await repo.create({
|
||||
name: 'John',
|
||||
score: 0
|
||||
})
|
||||
// 自动生成 id, createdAt, updatedAt
|
||||
```
|
||||
|
||||
### createMany
|
||||
|
||||
```typescript
|
||||
const players = await repo.createMany([
|
||||
{ name: 'Alice', score: 100 },
|
||||
{ name: 'Bob', score: 200 },
|
||||
{ name: 'Carol', score: 150 }
|
||||
])
|
||||
```
|
||||
|
||||
## 更新方法
|
||||
|
||||
### update
|
||||
|
||||
```typescript
|
||||
const updated = await repo.update('player-123', {
|
||||
score: 200,
|
||||
rank: 'gold'
|
||||
})
|
||||
// 自动更新 updatedAt
|
||||
```
|
||||
|
||||
## 删除方法
|
||||
|
||||
### delete
|
||||
|
||||
```typescript
|
||||
// 普通删除
|
||||
await repo.delete('player-123')
|
||||
|
||||
// 软删除(如果启用)
|
||||
// 实际是设置 deletedAt 字段
|
||||
```
|
||||
|
||||
### deleteMany
|
||||
|
||||
```typescript
|
||||
const count = await repo.deleteMany({
|
||||
where: { score: { $lt: 10 } }
|
||||
})
|
||||
```
|
||||
|
||||
## 软删除
|
||||
|
||||
### 启用软删除
|
||||
|
||||
```typescript
|
||||
const repo = createRepository<Player>(mongo, 'players', true)
|
||||
```
|
||||
|
||||
### 查询行为
|
||||
|
||||
```typescript
|
||||
// 默认排除软删除记录
|
||||
const players = await repo.findMany()
|
||||
|
||||
// 包含软删除记录
|
||||
const allPlayers = await repo.findMany({
|
||||
includeSoftDeleted: true
|
||||
})
|
||||
```
|
||||
|
||||
### 恢复记录
|
||||
|
||||
```typescript
|
||||
await repo.restore('player-123')
|
||||
```
|
||||
|
||||
## QueryOptions
|
||||
|
||||
```typescript
|
||||
interface QueryOptions<T> {
|
||||
/** 查询条件 */
|
||||
where?: WhereCondition<T>
|
||||
|
||||
/** 排序 */
|
||||
sort?: Partial<Record<keyof T, 'asc' | 'desc'>>
|
||||
|
||||
/** 限制数量 */
|
||||
limit?: number
|
||||
|
||||
/** 偏移量 */
|
||||
offset?: number
|
||||
|
||||
/** 包含软删除记录(仅在启用软删除时有效) */
|
||||
includeSoftDeleted?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## PaginatedResult
|
||||
|
||||
```typescript
|
||||
interface PaginatedResult<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
```
|
||||
277
docs/src/content/docs/modules/database/user.md
Normal file
277
docs/src/content/docs/modules/database/user.md
Normal file
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: "用户管理"
|
||||
description: "UserRepository 用户注册、认证、角色管理"
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
`UserRepository` 提供开箱即用的用户管理功能:
|
||||
|
||||
- 用户注册与认证
|
||||
- 密码哈希(使用 scrypt)
|
||||
- 角色管理
|
||||
- 账户状态管理
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { UserRepository } from '@esengine/database'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
const userRepo = new UserRepository(mongo)
|
||||
```
|
||||
|
||||
## 用户注册
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.register({
|
||||
username: 'john',
|
||||
password: 'securePassword123',
|
||||
email: 'john@example.com', // 可选
|
||||
displayName: 'John Doe', // 可选
|
||||
roles: ['player'] // 可选,默认 []
|
||||
})
|
||||
|
||||
console.log(user)
|
||||
// {
|
||||
// id: 'uuid-...',
|
||||
// username: 'john',
|
||||
// email: 'john@example.com',
|
||||
// displayName: 'John Doe',
|
||||
// roles: ['player'],
|
||||
// status: 'active',
|
||||
// createdAt: Date,
|
||||
// updatedAt: Date
|
||||
// }
|
||||
```
|
||||
|
||||
**注意**:`register` 返回的 `SafeUser` 不包含密码哈希。
|
||||
|
||||
## 用户认证
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.authenticate('john', 'securePassword123')
|
||||
|
||||
if (user) {
|
||||
console.log('登录成功:', user.username)
|
||||
} else {
|
||||
console.log('用户名或密码错误')
|
||||
}
|
||||
```
|
||||
|
||||
## 密码管理
|
||||
|
||||
### 修改密码
|
||||
|
||||
```typescript
|
||||
const success = await userRepo.changePassword(
|
||||
userId,
|
||||
'oldPassword123',
|
||||
'newPassword456'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
console.log('密码修改成功')
|
||||
} else {
|
||||
console.log('原密码错误')
|
||||
}
|
||||
```
|
||||
|
||||
### 重置密码
|
||||
|
||||
```typescript
|
||||
// 管理员直接重置密码
|
||||
const success = await userRepo.resetPassword(userId, 'newPassword123')
|
||||
```
|
||||
|
||||
## 角色管理
|
||||
|
||||
### 添加角色
|
||||
|
||||
```typescript
|
||||
await userRepo.addRole(userId, 'admin')
|
||||
await userRepo.addRole(userId, 'moderator')
|
||||
```
|
||||
|
||||
### 移除角色
|
||||
|
||||
```typescript
|
||||
await userRepo.removeRole(userId, 'moderator')
|
||||
```
|
||||
|
||||
### 查询角色
|
||||
|
||||
```typescript
|
||||
// 查找所有管理员
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
|
||||
// 检查用户是否有某角色
|
||||
const user = await userRepo.findById(userId)
|
||||
const isAdmin = user?.roles.includes('admin')
|
||||
```
|
||||
|
||||
## 查询用户
|
||||
|
||||
### 按用户名查找
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.findByUsername('john')
|
||||
```
|
||||
|
||||
### 按邮箱查找
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.findByEmail('john@example.com')
|
||||
```
|
||||
|
||||
### 按角色查找
|
||||
|
||||
```typescript
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
```
|
||||
|
||||
### 使用继承的方法
|
||||
|
||||
```typescript
|
||||
// 分页查询
|
||||
const result = await userRepo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { status: 'active' },
|
||||
sort: { createdAt: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
// 复杂查询
|
||||
const users = await userRepo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
roles: { $in: ['admin', 'moderator'] }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 账户状态
|
||||
|
||||
```typescript
|
||||
type UserStatus = 'active' | 'inactive' | 'banned' | 'suspended'
|
||||
```
|
||||
|
||||
### 更新状态
|
||||
|
||||
```typescript
|
||||
await userRepo.update(userId, { status: 'banned' })
|
||||
```
|
||||
|
||||
### 查询特定状态
|
||||
|
||||
```typescript
|
||||
const activeUsers = await userRepo.findMany({
|
||||
where: { status: 'active' }
|
||||
})
|
||||
|
||||
const bannedUsers = await userRepo.findMany({
|
||||
where: { status: 'banned' }
|
||||
})
|
||||
```
|
||||
|
||||
## 类型定义
|
||||
|
||||
### UserEntity
|
||||
|
||||
```typescript
|
||||
interface UserEntity extends BaseEntity {
|
||||
username: string
|
||||
passwordHash: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
roles: string[]
|
||||
status: UserStatus
|
||||
lastLoginAt?: Date
|
||||
}
|
||||
```
|
||||
|
||||
### SafeUser
|
||||
|
||||
```typescript
|
||||
type SafeUser = Omit<UserEntity, 'passwordHash'>
|
||||
```
|
||||
|
||||
### CreateUserParams
|
||||
|
||||
```typescript
|
||||
interface CreateUserParams {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
roles?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
## 密码工具
|
||||
|
||||
独立的密码工具函数:
|
||||
|
||||
```typescript
|
||||
import { hashPassword, verifyPassword } from '@esengine/database'
|
||||
|
||||
// 哈希密码
|
||||
const hash = await hashPassword('myPassword123')
|
||||
|
||||
// 验证密码
|
||||
const isValid = await verifyPassword('myPassword123', hash)
|
||||
```
|
||||
|
||||
### 安全说明
|
||||
|
||||
- 使用 Node.js 内置的 `scrypt` 算法
|
||||
- 自动生成随机盐值
|
||||
- 默认使用安全的迭代参数
|
||||
- 哈希格式:`salt:hash`(均为 hex 编码)
|
||||
|
||||
## 扩展 UserRepository
|
||||
|
||||
```typescript
|
||||
import { UserRepository, UserEntity } from '@esengine/database'
|
||||
|
||||
interface GameUser extends UserEntity {
|
||||
level: number
|
||||
experience: number
|
||||
coins: number
|
||||
}
|
||||
|
||||
class GameUserRepository extends UserRepository {
|
||||
// 重写集合名
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'game_users')
|
||||
}
|
||||
|
||||
// 添加游戏相关方法
|
||||
async addExperience(userId: string, amount: number): Promise<GameUser | null> {
|
||||
const user = await this.findById(userId) as GameUser | null
|
||||
if (!user) return null
|
||||
|
||||
const newExp = user.experience + amount
|
||||
const newLevel = Math.floor(newExp / 1000) + 1
|
||||
|
||||
return this.update(userId, {
|
||||
experience: newExp,
|
||||
level: newLevel
|
||||
}) as Promise<GameUser | null>
|
||||
}
|
||||
|
||||
async findTopPlayers(limit: number = 10): Promise<GameUser[]> {
|
||||
return this.findMany({
|
||||
sort: { level: 'desc', experience: 'desc' },
|
||||
limit
|
||||
}) as Promise<GameUser[]>
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -28,12 +28,21 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
|
||||
|------|------|------|
|
||||
| [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 |
|
||||
| [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 |
|
||||
| [世界流式加载](/modules/world-streaming/) | `@esengine/world-streaming` | 开放世界区块流式加载 |
|
||||
|
||||
### 网络模块
|
||||
|
||||
| 模块 | 包名 | 描述 |
|
||||
|------|------|------|
|
||||
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
|
||||
| [事务系统](/modules/transaction/) | `@esengine/transaction` | 游戏事务处理,支持分布式事务 |
|
||||
|
||||
### 数据库模块
|
||||
|
||||
| 模块 | 包名 | 描述 |
|
||||
|------|------|------|
|
||||
| [数据库驱动](/modules/database-drivers/) | `@esengine/database-drivers` | MongoDB、Redis 连接管理 |
|
||||
| [数据库仓库](/modules/database/) | `@esengine/database` | Repository 模式数据操作 |
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
489
docs/src/content/docs/modules/math/blueprint-nodes.md
Normal file
489
docs/src/content/docs/modules/math/blueprint-nodes.md
Normal file
@@ -0,0 +1,489 @@
|
||||
---
|
||||
title: "数学库蓝图节点"
|
||||
description: "Math 模块提供的蓝图节点 - Vector2、Fixed32、FixedVector2、Color"
|
||||
---
|
||||
|
||||
本文档介绍 `@esengine/ecs-framework-math` 模块提供的蓝图节点。
|
||||
|
||||
> **注意**:这些节点需要安装 math 模块才能使用。
|
||||
|
||||
<script src="/js/blueprint-graph.js"></script>
|
||||
|
||||
## 引脚类型说明
|
||||
|
||||
<div class="bp-legend">
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7ecd32" stroke-width="2"/></svg> 浮点数 (Float)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#2196F3" stroke-width="2"/></svg> Vector2</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#9C27B0" stroke-width="2"/></svg> Fixed32</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#673AB7" stroke-width="2"/></svg> FixedVector2</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#FF9800" stroke-width="2"/></svg> Color</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Vector2 节点
|
||||
|
||||
2D 向量操作,用于位置、速度、方向计算。
|
||||
|
||||
### 节点列表
|
||||
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Make Vector2` | 从 X, Y 创建 Vector2 | X, Y | Vector2 |
|
||||
| `Break Vector2` | 分解 Vector2 为 X, Y | Vector | X, Y |
|
||||
| `Vector2 +` | 向量加法 | A, B | Vector2 |
|
||||
| `Vector2 -` | 向量减法 | A, B | Vector2 |
|
||||
| `Vector2 *` | 向量缩放 | Vector, Scalar | Vector2 |
|
||||
| `Vector2 Length` | 获取向量长度 | Vector | Float |
|
||||
| `Vector2 Normalize` | 归一化为单位向量 | Vector | Vector2 |
|
||||
| `Vector2 Dot` | 点积 | A, B | Float |
|
||||
| `Vector2 Cross` | 2D 叉积 | A, B | Float |
|
||||
| `Vector2 Distance` | 两点距离 | A, B | Float |
|
||||
| `Vector2 Lerp` | 线性插值 | A, B, T | Vector2 |
|
||||
| `Vector2 Rotate` | 旋转(弧度) | Vector, Angle | Vector2 |
|
||||
| `Vector2 From Angle` | 从角度创建单位向量 | Angle | Vector2 |
|
||||
|
||||
### 示例:计算移动方向
|
||||
|
||||
从起点到终点的方向向量:
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"v2-start","to":"v2-sub-a","type":"vector2"},{"from":"v2-end","to":"v2-sub-b","type":"vector2"},{"from":"v2-sub-result","to":"v2-norm-in","type":"vector2"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 130px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Make Vector2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">X</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Y</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-start"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 180px; width: 130px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Make Vector2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">X</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Y</span>
|
||||
<span class="bp-pin-value">50</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-end"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 220px; top: 90px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 -</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-sub-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-sub-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-sub-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 400px; top: 55px; width: 140px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 Normalize</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-norm-in"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 示例:圆周运动
|
||||
|
||||
使用角度和半径计算圆周位置:
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"v2-angle-out","to":"v2-scale-vec","type":"vector2"},{"from":"v2-scale-result","to":"v2-add-b","type":"vector2"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 40px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 From Angle</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Angle</span>
|
||||
<span class="bp-pin-value">1.57</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-angle-out"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 230px; top: 40px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 *</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-scale-vec"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Vector</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Scalar</span>
|
||||
<span class="bp-pin-value">50</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="v2-scale-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 420px; top: 40px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #2196F3;">Vector2 +</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#2196F3" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">A (Center)</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="v2-add-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
|
||||
<span class="bp-pin-label">Position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Fixed32 定点数节点
|
||||
|
||||
Q16.16 定点数运算,适用于帧同步网络游戏,保证跨平台计算一致性。
|
||||
|
||||
### 节点列表
|
||||
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Fixed32 From Float` | 从浮点数创建 | Float | Fixed32 |
|
||||
| `Fixed32 From Int` | 从整数创建 | Int | Fixed32 |
|
||||
| `Fixed32 To Float` | 转换为浮点数 | Fixed32 | Float |
|
||||
| `Fixed32 To Int` | 转换为整数 | Fixed32 | Int |
|
||||
| `Fixed32 +` | 加法 | A, B | Fixed32 |
|
||||
| `Fixed32 -` | 减法 | A, B | Fixed32 |
|
||||
| `Fixed32 *` | 乘法 | A, B | Fixed32 |
|
||||
| `Fixed32 /` | 除法 | A, B | Fixed32 |
|
||||
| `Fixed32 Abs` | 绝对值 | Value | Fixed32 |
|
||||
| `Fixed32 Sqrt` | 平方根 | Value | Fixed32 |
|
||||
| `Fixed32 Floor` | 向下取整 | Value | Fixed32 |
|
||||
| `Fixed32 Ceil` | 向上取整 | Value | Fixed32 |
|
||||
| `Fixed32 Round` | 四舍五入 | Value | Fixed32 |
|
||||
| `Fixed32 Sign` | 符号 (-1, 0, 1) | Value | Fixed32 |
|
||||
| `Fixed32 Min` | 最小值 | A, B | Fixed32 |
|
||||
| `Fixed32 Max` | 最大值 | A, B | Fixed32 |
|
||||
| `Fixed32 Clamp` | 钳制范围 | Value, Min, Max | Fixed32 |
|
||||
| `Fixed32 Lerp` | 线性插值 | A, B, T | Fixed32 |
|
||||
|
||||
### 示例:帧同步移动速度计算
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"f32-speed","to":"f32-mul-a","type":"fixed32"},{"from":"f32-dt","to":"f32-mul-b","type":"fixed32"},{"from":"f32-mul-result","to":"f32-tofloat","type":"fixed32"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 From Float</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
<span class="bp-pin-value">5.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="f32-speed"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">Speed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 160px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 From Float</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
<span class="bp-pin-value">0.016</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="f32-dt"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">DeltaTime</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 240px; top: 75px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 *</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="f32-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="f32-mul-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="f32-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 430px; top: 75px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 To Float</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="f32-tofloat"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
|
||||
<span class="bp-pin-label">Fixed</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Float</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## FixedVector2 定点向量节点
|
||||
|
||||
定点向量运算,用于确定性物理计算,适用于帧同步。
|
||||
|
||||
### 节点列表
|
||||
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Make FixedVector2` | 从 X, Y 浮点数创建 | X, Y | FixedVector2 |
|
||||
| `Break FixedVector2` | 分解为 X, Y 浮点数 | Vector | X, Y |
|
||||
| `FixedVector2 +` | 向量加法 | A, B | FixedVector2 |
|
||||
| `FixedVector2 -` | 向量减法 | A, B | FixedVector2 |
|
||||
| `FixedVector2 *` | 按 Fixed32 缩放 | Vector, Scalar | FixedVector2 |
|
||||
| `FixedVector2 Negate` | 取反 | Vector | FixedVector2 |
|
||||
| `FixedVector2 Length` | 获取长度 | Vector | Fixed32 |
|
||||
| `FixedVector2 Normalize` | 归一化 | Vector | FixedVector2 |
|
||||
| `FixedVector2 Dot` | 点积 | A, B | Fixed32 |
|
||||
| `FixedVector2 Cross` | 2D 叉积 | A, B | Fixed32 |
|
||||
| `FixedVector2 Distance` | 两点距离 | A, B | Fixed32 |
|
||||
| `FixedVector2 Lerp` | 线性插值 | A, B, T | FixedVector2 |
|
||||
|
||||
### 示例:确定性位置更新
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"fv2-pos","to":"fv2-add-a","type":"fixedvector2"},{"from":"fv2-vel","to":"fv2-add-b","type":"fixedvector2"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #673AB7;">Make FixedVector2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">X</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Y</span>
|
||||
<span class="bp-pin-value">20</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="fv2-pos"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">Position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 180px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #673AB7;">Make FixedVector2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">X</span>
|
||||
<span class="bp-pin-value">1</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Y</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="fv2-vel"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">Velocity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 250px; top: 90px; width: 140px;">
|
||||
<div class="bp-node-header math" style="background: #673AB7;">FixedVector2 +</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="fv2-add-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="fv2-add-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
|
||||
<span class="bp-pin-label">New Position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Color 颜色节点
|
||||
|
||||
颜色创建与操作节点。
|
||||
|
||||
### 节点列表
|
||||
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Make Color` | 从 RGBA 创建 | R, G, B, A | Color |
|
||||
| `Break Color` | 分解为 RGBA | Color | R, G, B, A |
|
||||
| `Color From Hex` | 从十六进制字符串创建 | Hex | Color |
|
||||
| `Color To Hex` | 转换为十六进制字符串 | Color | String |
|
||||
| `Color From HSL` | 从 HSL 创建 | H, S, L | Color |
|
||||
| `Color To HSL` | 转换为 HSL | Color | H, S, L |
|
||||
| `Color Lerp` | 颜色插值 | A, B, T | Color |
|
||||
| `Color Lighten` | 提亮 | Color, Amount | Color |
|
||||
| `Color Darken` | 变暗 | Color, Amount | Color |
|
||||
| `Color Saturate` | 增加饱和度 | Color, Amount | Color |
|
||||
| `Color Desaturate` | 降低饱和度 | Color, Amount | Color |
|
||||
| `Color Invert` | 反色 | Color | Color |
|
||||
| `Color Grayscale` | 灰度化 | Color | Color |
|
||||
| `Color Luminance` | 获取亮度 | Color | Float |
|
||||
|
||||
### 颜色常量
|
||||
|
||||
| 节点 | 值 |
|
||||
|------|------|
|
||||
| `Color White` | (1, 1, 1, 1) |
|
||||
| `Color Black` | (0, 0, 0, 1) |
|
||||
| `Color Red` | (1, 0, 0, 1) |
|
||||
| `Color Green` | (0, 1, 0, 1) |
|
||||
| `Color Blue` | (0, 0, 1, 1) |
|
||||
| `Color Transparent` | (0, 0, 0, 0) |
|
||||
|
||||
### 示例:颜色过渡动画
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"color-a","to":"color-lerp-a","type":"color"},{"from":"color-b","to":"color-lerp-b","type":"color"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Color Red</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="color-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Color</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 130px; width: 120px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Color Blue</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="color-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Color</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 220px; top: 50px; width: 130px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Color Lerp</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="color-lerp-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="color-lerp-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">T</span>
|
||||
<span class="bp-pin-value">0.5</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 示例:从 Hex 创建颜色
|
||||
|
||||
<div class="bp-graph" data-connections='[{"from":"hex-color","to":"break-color","type":"color"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="position: absolute; left: 20px; top: 30px; width: 150px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Color From Hex</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Hex</span>
|
||||
<span class="bp-pin-value">"#FF5722"</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="hex-color"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Color</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="position: absolute; left: 250px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math" style="background: #FF9800;">Break Color</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="break-color"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
|
||||
<span class="bp-pin-label">Color</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">R</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">G</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [蓝图节点参考](/modules/blueprint/nodes) - 核心蓝图节点
|
||||
- [蓝图编辑器指南](/modules/blueprint/editor-guide) - 编辑器使用方法
|
||||
- [自定义节点](/modules/blueprint/custom-nodes) - 创建自定义节点
|
||||
79
docs/src/content/docs/modules/math/index.md
Normal file
79
docs/src/content/docs/modules/math/index.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "数学库"
|
||||
description: "ESEngine 数学库 - Vector2, Fixed32, FixedVector2, Color 等数学类型"
|
||||
---
|
||||
|
||||
`@esengine/ecs-framework-math` 模块提供游戏开发常用的数学类型和运算。
|
||||
|
||||
## 核心类型
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `Vector2` | 2D 浮点向量,用于位置、速度、方向 |
|
||||
| `Fixed32` | Q16.16 定点数,用于帧同步确定性计算 |
|
||||
| `FixedVector2` | 2D 定点向量,用于确定性物理 |
|
||||
| `Color` | RGBA 颜色 |
|
||||
|
||||
## 功能特性
|
||||
|
||||
### Vector2
|
||||
|
||||
- 加法、减法、缩放
|
||||
- 点积、叉积
|
||||
- 长度、归一化
|
||||
- 距离、插值
|
||||
- 旋转、角度转换
|
||||
|
||||
### Fixed32 定点数
|
||||
|
||||
专为帧同步网络游戏设计,保证跨平台计算一致性:
|
||||
|
||||
- 基本运算:加、减、乘、除
|
||||
- 数学函数:绝对值、平方根、取整
|
||||
- 比较、钳制、插值
|
||||
- 常量:0、1、0.5、π、2π
|
||||
|
||||
### Color 颜色
|
||||
|
||||
- RGB/RGBA 创建与分解
|
||||
- Hex 十六进制转换
|
||||
- HSL 色彩空间转换
|
||||
- 颜色操作:提亮、变暗、饱和度调整
|
||||
- 颜色混合与插值
|
||||
|
||||
## 蓝图支持
|
||||
|
||||
数学库提供了丰富的蓝图节点,详见:
|
||||
|
||||
- [数学库蓝图节点](/modules/math/blueprint-nodes)
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pnpm add @esengine/ecs-framework-math
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
```typescript
|
||||
import { Vector2, Fixed32, FixedVector2, Color } from '@esengine/ecs-framework-math';
|
||||
|
||||
// Vector2
|
||||
const pos = new Vector2(10, 20);
|
||||
const dir = pos.normalized();
|
||||
|
||||
// Fixed32 (帧同步)
|
||||
const speed = Fixed32.from(5.0);
|
||||
const dt = Fixed32.from(0.016);
|
||||
const distance = speed.mul(dt);
|
||||
|
||||
// FixedVector2
|
||||
const fixedPos = FixedVector2.from(10, 20);
|
||||
const fixedVel = FixedVector2.from(1, 0);
|
||||
const newPos = fixedPos.add(fixedVel);
|
||||
|
||||
// Color
|
||||
const red = Color.RED;
|
||||
const blue = Color.BLUE;
|
||||
const purple = Color.lerp(red, blue, 0.5);
|
||||
```
|
||||
283
docs/src/content/docs/modules/network/aoi.md
Normal file
283
docs/src/content/docs/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: "兴趣区域管理 (AOI)"
|
||||
description: "基于视野范围的网络实体过滤"
|
||||
---
|
||||
|
||||
AOI(Area of Interest,兴趣区域)是大规模多人游戏中用于优化网络带宽的关键技术。通过只同步玩家视野范围内的实体,可以大幅减少网络流量。
|
||||
|
||||
## NetworkAOISystem
|
||||
|
||||
`NetworkAOISystem` 提供基于网格的兴趣区域管理。
|
||||
|
||||
### 启用 AOI
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enableAOI: true,
|
||||
aoiConfig: {
|
||||
cellSize: 100, // 网格单元大小
|
||||
defaultViewRange: 500, // 默认视野范围
|
||||
enabled: true,
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### 添加观察者
|
||||
|
||||
每个需要接收同步数据的玩家都需要作为观察者添加:
|
||||
|
||||
```typescript
|
||||
// 玩家加入时添加观察者
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
// ... 设置组件
|
||||
|
||||
// 将玩家添加为 AOI 观察者
|
||||
networkPlugin.addAOIObserver(
|
||||
spawn.netId, // 网络 ID
|
||||
spawn.pos.x, // 初始 X 位置
|
||||
spawn.pos.y, // 初始 Y 位置
|
||||
600 // 视野范围(可选)
|
||||
);
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// 玩家离开时移除观察者
|
||||
networkPlugin.removeAOIObserver(playerNetId);
|
||||
```
|
||||
|
||||
### 更新观察者位置
|
||||
|
||||
当玩家移动时,需要更新其 AOI 位置:
|
||||
|
||||
```typescript
|
||||
// 在游戏循环或同步回调中更新
|
||||
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||
```
|
||||
|
||||
## AOI 配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `cellSize` | `number` | 100 | 网格单元大小 |
|
||||
| `defaultViewRange` | `number` | 500 | 默认视野范围 |
|
||||
| `enabled` | `boolean` | true | 是否启用 AOI |
|
||||
|
||||
### 网格大小建议
|
||||
|
||||
网格大小应根据游戏视野范围设置:
|
||||
|
||||
```typescript
|
||||
// 建议:cellSize = defaultViewRange / 3 到 / 5
|
||||
aoiConfig: {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500, // 网格大约是视野的 1/5
|
||||
}
|
||||
```
|
||||
|
||||
## 查询接口
|
||||
|
||||
### 获取可见实体
|
||||
|
||||
```typescript
|
||||
// 获取玩家能看到的所有实体
|
||||
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||
console.log('Visible entities:', visibleEntities);
|
||||
```
|
||||
|
||||
### 检查可见性
|
||||
|
||||
```typescript
|
||||
// 检查玩家是否能看到某个实体
|
||||
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||
// 目标在视野内
|
||||
}
|
||||
```
|
||||
|
||||
## 事件监听
|
||||
|
||||
AOI 系统会在实体进入/离开视野时触发事件:
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||
// 可以在这里发送实体的初始状态
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||
// 可以在这里清理资源
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 服务器端过滤
|
||||
|
||||
AOI 最常用于服务器端,过滤发送给每个客户端的同步数据:
|
||||
|
||||
```typescript
|
||||
// 服务器端示例
|
||||
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private aoiSystem = createNetworkAOISystem({
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
});
|
||||
|
||||
// 玩家加入
|
||||
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.addObserver(playerId, x, y);
|
||||
}
|
||||
|
||||
// 玩家移动
|
||||
onPlayerMove(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||
}
|
||||
|
||||
// 发送同步数据
|
||||
broadcastSync(allEntities: EntitySyncState[]) {
|
||||
for (const playerId of this.players) {
|
||||
// 使用 AOI 过滤
|
||||
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||
playerId,
|
||||
allEntities
|
||||
);
|
||||
|
||||
// 只发送可见实体
|
||||
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 游戏世界 │
|
||||
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||
│ │ │ │ E │ │ │ │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ E = 敌人实体 │
|
||||
│ │ │ P │ ● │ │ │ P = 玩家 │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ ● = 玩家视野中心 │
|
||||
│ │ │ │ E │ E │ │ ○ = 视野范围 │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||
│ │ │ │ │ │ E │ 玩家只能看到视野内的 E │
|
||||
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||
│ │
|
||||
│ 视野范围(圆形):包含 3 个敌人 │
|
||||
│ 网格优化:只检查视野覆盖的网格单元 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 网格优化
|
||||
|
||||
AOI 使用空间网格加速查询:
|
||||
|
||||
1. **添加实体**:根据位置计算所在网格
|
||||
2. **视野检测**:只检查视野范围覆盖的网格
|
||||
3. **移动更新**:跨网格时更新网格归属
|
||||
4. **事件触发**:检测进入/离开视野
|
||||
|
||||
## 动态视野范围
|
||||
|
||||
可以为不同类型的玩家设置不同的视野:
|
||||
|
||||
```typescript
|
||||
// 普通玩家
|
||||
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||
|
||||
// VIP 玩家(更大视野)
|
||||
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||
|
||||
// 运行时调整视野
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
if (aoiSystem) {
|
||||
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 服务器端使用
|
||||
|
||||
AOI 过滤应在服务器端进行,客户端不应信任自己的 AOI 判断:
|
||||
|
||||
```typescript
|
||||
// 服务器端过滤后再发送
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
sendToClient(playerId, filtered);
|
||||
```
|
||||
|
||||
### 2. 边界处理
|
||||
|
||||
在视野边缘添加缓冲区防止闪烁:
|
||||
|
||||
```typescript
|
||||
// 进入视野时立即添加
|
||||
// 离开视野时延迟移除(保持额外 1-2 秒)
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'exit') {
|
||||
setTimeout(() => {
|
||||
// 再次检查是否真的离开
|
||||
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||
removeFromClient(event.observerNetId, event.targetNetId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 大型实体
|
||||
|
||||
对于大型实体(如 Boss),可能需要特殊处理:
|
||||
|
||||
```typescript
|
||||
// Boss 总是对所有人可见
|
||||
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
|
||||
// 添加 Boss 实体
|
||||
const bossState = entities.find(e => e.netId === bossNetId);
|
||||
if (bossState && !filtered.includes(bossState)) {
|
||||
filtered.push(bossState);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 性能考虑
|
||||
|
||||
```typescript
|
||||
// 大规模游戏建议配置
|
||||
aoiConfig: {
|
||||
cellSize: 200, // 较大的网格减少网格数量
|
||||
defaultViewRange: 800, // 根据实际视野设置
|
||||
}
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
console.log('AOI enabled:', aoiSystem.enabled);
|
||||
console.log('Observer count:', aoiSystem.observerCount);
|
||||
|
||||
// 获取特定玩家的可见实体
|
||||
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||
console.log('Visible entities:', visible.length);
|
||||
}
|
||||
```
|
||||
855
docs/src/content/docs/modules/network/auth.md
Normal file
855
docs/src/content/docs/modules/network/auth.md
Normal file
@@ -0,0 +1,855 @@
|
||||
---
|
||||
title: "认证系统"
|
||||
description: "使用 JWT 和 Session 提供者为游戏服务器添加认证功能"
|
||||
---
|
||||
|
||||
`@esengine/server` 包内置了可插拔的认证系统,支持 JWT、会话认证和自定义提供者。
|
||||
|
||||
## 安装
|
||||
|
||||
认证功能已包含在 server 包中:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server jsonwebtoken
|
||||
```
|
||||
|
||||
> 注意:`jsonwebtoken` 是可选的 peer dependency,仅在使用 JWT 认证时需要。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### JWT 认证
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||
|
||||
// 创建 JWT 提供者
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600, // 1 小时
|
||||
})
|
||||
|
||||
// 用认证包装服务器
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url ?? '', 'http://localhost')
|
||||
return url.searchParams.get('token')
|
||||
},
|
||||
})
|
||||
|
||||
// 定义需要认证的房间
|
||||
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||
onJoin(player) {
|
||||
console.log(`${player.user?.name} 加入了游戏!`)
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## 认证提供者
|
||||
|
||||
### JWT 提供者
|
||||
|
||||
使用 JSON Web Tokens 实现无状态认证:
|
||||
|
||||
```typescript
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
// 必填:密钥
|
||||
secret: 'your-secret-key',
|
||||
|
||||
// 可选:算法(默认:HS256)
|
||||
algorithm: 'HS256',
|
||||
|
||||
// 可选:过期时间(秒,默认:3600)
|
||||
expiresIn: 3600,
|
||||
|
||||
// 可选:签发者(用于验证)
|
||||
issuer: 'my-game-server',
|
||||
|
||||
// 可选:受众(用于验证)
|
||||
audience: 'my-game-client',
|
||||
|
||||
// 可选:自定义用户提取
|
||||
getUser: async (payload) => {
|
||||
// 从数据库获取用户
|
||||
return await db.users.findById(payload.sub)
|
||||
},
|
||||
})
|
||||
|
||||
// 签发令牌(用于登录接口)
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.name,
|
||||
roles: ['player'],
|
||||
})
|
||||
|
||||
// 解码但不验证(用于调试)
|
||||
const payload = jwtProvider.decode(token)
|
||||
```
|
||||
|
||||
### 自定义提供者
|
||||
|
||||
你可以通过实现 `IAuthProvider` 接口来创建自定义认证提供者,以集成任何认证系统(如 OAuth、LDAP、自定义数据库认证等)。
|
||||
|
||||
#### IAuthProvider 接口
|
||||
|
||||
```typescript
|
||||
interface IAuthProvider<TUser = unknown, TCredentials = unknown> {
|
||||
/** 提供者名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 验证凭证 */
|
||||
verify(credentials: TCredentials): Promise<AuthResult<TUser>>;
|
||||
|
||||
/** 刷新令牌(可选) */
|
||||
refresh?(token: string): Promise<AuthResult<TUser>>;
|
||||
|
||||
/** 撤销令牌(可选) */
|
||||
revoke?(token: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
interface AuthResult<TUser> {
|
||||
success: boolean;
|
||||
user?: TUser;
|
||||
error?: string;
|
||||
errorCode?: AuthErrorCode;
|
||||
token?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'EXPIRED_TOKEN'
|
||||
| 'INVALID_TOKEN'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'ACCOUNT_DISABLED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INSUFFICIENT_PERMISSIONS';
|
||||
```
|
||||
|
||||
#### 自定义提供者示例
|
||||
|
||||
**示例 1:数据库密码认证**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface PasswordCredentials {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
class DatabaseAuthProvider implements IAuthProvider<User, PasswordCredentials> {
|
||||
readonly name = 'database'
|
||||
|
||||
async verify(credentials: PasswordCredentials): Promise<AuthResult<User>> {
|
||||
const { username, password } = credentials
|
||||
|
||||
// 从数据库查询用户
|
||||
const user = await db.users.findByUsername(username)
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户不存在',
|
||||
errorCode: 'USER_NOT_FOUND'
|
||||
}
|
||||
}
|
||||
|
||||
// 验证密码(使用 bcrypt 等库)
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: '密码错误',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查账号状态
|
||||
if (user.disabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: '账号已禁用',
|
||||
errorCode: 'ACCOUNT_DISABLED'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roles: user.roles
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**示例 2:OAuth/第三方认证**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface OAuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
provider: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface OAuthCredentials {
|
||||
provider: 'google' | 'github' | 'discord'
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
class OAuthProvider implements IAuthProvider<OAuthUser, OAuthCredentials> {
|
||||
readonly name = 'oauth'
|
||||
|
||||
async verify(credentials: OAuthCredentials): Promise<AuthResult<OAuthUser>> {
|
||||
const { provider, accessToken } = credentials
|
||||
|
||||
try {
|
||||
// 根据提供商验证 token
|
||||
const profile = await this.fetchUserProfile(provider, accessToken)
|
||||
|
||||
// 查找或创建本地用户
|
||||
let user = await db.users.findByOAuth(provider, profile.id)
|
||||
if (!user) {
|
||||
user = await db.users.create({
|
||||
oauthProvider: provider,
|
||||
oauthId: profile.id,
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
roles: ['player']
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
provider,
|
||||
roles: user.roles
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'OAuth 验证失败',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUserProfile(provider: string, token: string) {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => r.json())
|
||||
case 'github':
|
||||
return fetch('https://api.github.com/user', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => r.json())
|
||||
// 其他提供商...
|
||||
default:
|
||||
throw new Error(`不支持的提供商: ${provider}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**示例 3:API Key 认证**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface ApiUser {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
rateLimit: number
|
||||
}
|
||||
|
||||
class ApiKeyAuthProvider implements IAuthProvider<ApiUser, string> {
|
||||
readonly name = 'api-key'
|
||||
|
||||
private revokedKeys = new Set<string>()
|
||||
|
||||
async verify(apiKey: string): Promise<AuthResult<ApiUser>> {
|
||||
if (!apiKey || !apiKey.startsWith('sk_')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key 格式无效',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.revokedKeys.has(apiKey)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key 已被撤销',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库查询 API Key
|
||||
const keyData = await db.apiKeys.findByKey(apiKey)
|
||||
if (!keyData) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key 不存在',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查过期
|
||||
if (keyData.expiresAt && keyData.expiresAt < Date.now()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key 已过期',
|
||||
errorCode: 'EXPIRED_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: keyData.userId,
|
||||
name: keyData.name,
|
||||
roles: keyData.roles,
|
||||
rateLimit: keyData.rateLimit
|
||||
},
|
||||
expiresAt: keyData.expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(apiKey: string): Promise<boolean> {
|
||||
this.revokedKeys.add(apiKey)
|
||||
await db.apiKeys.revoke(apiKey)
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用自定义提供者
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
// 创建自定义提供者
|
||||
const dbAuthProvider = new DatabaseAuthProvider()
|
||||
|
||||
// 或使用 OAuth 提供者
|
||||
const oauthProvider = new OAuthProvider()
|
||||
|
||||
// 使用自定义提供者
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: dbAuthProvider, // 或 oauthProvider
|
||||
|
||||
// 从 WebSocket 连接请求中提取凭证
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
|
||||
// 对于数据库认证:从查询参数获取
|
||||
const username = url.searchParams.get('username')
|
||||
const password = url.searchParams.get('password')
|
||||
if (username && password) {
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
// 对于 OAuth:从 token 参数获取
|
||||
const provider = url.searchParams.get('provider')
|
||||
const accessToken = url.searchParams.get('access_token')
|
||||
if (provider && accessToken) {
|
||||
return { provider, accessToken }
|
||||
}
|
||||
|
||||
// 对于 API Key:从请求头获取
|
||||
const apiKey = req.headers['x-api-key']
|
||||
if (apiKey) {
|
||||
return apiKey as string
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
onAuthFailure: (conn, error) => {
|
||||
console.log(`认证失败: ${error.errorCode} - ${error.error}`)
|
||||
}
|
||||
})
|
||||
|
||||
await server.start()
|
||||
```
|
||||
|
||||
#### 组合多个提供者
|
||||
|
||||
你可以创建一个复合提供者来支持多种认证方式:
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface MultiAuthCredentials {
|
||||
type: 'jwt' | 'oauth' | 'apikey' | 'password'
|
||||
data: unknown
|
||||
}
|
||||
|
||||
class MultiAuthProvider implements IAuthProvider<User, MultiAuthCredentials> {
|
||||
readonly name = 'multi'
|
||||
|
||||
constructor(
|
||||
private jwtProvider: JwtAuthProvider<User>,
|
||||
private oauthProvider: OAuthProvider,
|
||||
private apiKeyProvider: ApiKeyAuthProvider,
|
||||
private dbProvider: DatabaseAuthProvider
|
||||
) {}
|
||||
|
||||
async verify(credentials: MultiAuthCredentials): Promise<AuthResult<User>> {
|
||||
switch (credentials.type) {
|
||||
case 'jwt':
|
||||
return this.jwtProvider.verify(credentials.data as string)
|
||||
case 'oauth':
|
||||
return this.oauthProvider.verify(credentials.data as OAuthCredentials)
|
||||
case 'apikey':
|
||||
return this.apiKeyProvider.verify(credentials.data as string)
|
||||
case 'password':
|
||||
return this.dbProvider.verify(credentials.data as PasswordCredentials)
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: '不支持的认证类型',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session 提供者
|
||||
|
||||
使用服务端会话实现有状态认证:
|
||||
|
||||
```typescript
|
||||
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||
|
||||
// 自定义存储实现
|
||||
const storage: ISessionStorage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return await redis.get(key)
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await redis.set(key, value)
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return await redis.del(key) > 0
|
||||
},
|
||||
}
|
||||
|
||||
const sessionProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 86400000, // 24 小时(毫秒)
|
||||
|
||||
// 可选:每次请求时验证用户
|
||||
validateUser: (user) => !user.banned,
|
||||
})
|
||||
|
||||
// 创建会话(用于登录接口)
|
||||
const sessionId = await sessionProvider.createSession(user, {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
})
|
||||
|
||||
// 撤销会话(用于登出)
|
||||
await sessionProvider.revoke(sessionId)
|
||||
```
|
||||
|
||||
## 服务器认证 Mixin
|
||||
|
||||
`withAuth` 函数用于包装服务器添加认证功能:
|
||||
|
||||
```typescript
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
const server = withAuth(baseServer, {
|
||||
// 必填:认证提供者
|
||||
provider: jwtProvider,
|
||||
|
||||
// 必填:从请求中提取凭证
|
||||
extractCredentials: (req) => {
|
||||
// 从查询字符串获取
|
||||
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||
|
||||
// 或从请求头获取
|
||||
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||
},
|
||||
|
||||
// 可选:处理认证失败
|
||||
onAuthFailed: (conn, error) => {
|
||||
console.log(`认证失败: ${error}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 访问认证上下文
|
||||
|
||||
认证后,可以从连接获取认证上下文:
|
||||
|
||||
```typescript
|
||||
import { getAuthContext } from '@esengine/server/auth'
|
||||
|
||||
server.onConnect = (conn) => {
|
||||
const auth = getAuthContext(conn)
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
console.log(`用户 ${auth.userId} 已连接`)
|
||||
console.log(`角色: ${auth.roles}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 房间认证 Mixin
|
||||
|
||||
`withRoomAuth` 函数为房间添加认证检查:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
// 要求认证才能加入
|
||||
requireAuth: true,
|
||||
|
||||
// 可选:要求特定角色
|
||||
allowedRoles: ['player', 'premium'],
|
||||
|
||||
// 可选:角色检查模式('any' 或 'all')
|
||||
roleCheckMode: 'any',
|
||||
}) {
|
||||
// player 拥有 .auth 和 .user 属性
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} 加入了`)
|
||||
console.log(`是否高级会员: ${player.auth.hasRole('premium')}`)
|
||||
}
|
||||
|
||||
// 可选:自定义认证验证
|
||||
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||
// 额外的验证逻辑
|
||||
if (player.auth.hasRole('banned')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name ?? '访客',
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPlayer 接口
|
||||
|
||||
认证房间中的玩家拥有额外属性:
|
||||
|
||||
```typescript
|
||||
interface AuthPlayer<TUser> extends Player {
|
||||
// 完整认证上下文
|
||||
readonly auth: IAuthContext<TUser>
|
||||
|
||||
// 用户信息(auth.user 的快捷方式)
|
||||
readonly user: TUser | null
|
||||
}
|
||||
```
|
||||
|
||||
### 房间认证辅助方法
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRoomAuth<User>(Room) {
|
||||
someMethod() {
|
||||
// 通过用户 ID 获取玩家
|
||||
const player = this.getPlayerByUserId('user-123')
|
||||
|
||||
// 获取拥有特定角色的所有玩家
|
||||
const admins = this.getPlayersByRole('admin')
|
||||
|
||||
// 获取带认证信息的玩家
|
||||
const authPlayer = this.getAuthPlayer(playerId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证装饰器
|
||||
|
||||
### @requireAuth
|
||||
|
||||
标记消息处理器需要认证:
|
||||
|
||||
```typescript
|
||||
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||
|
||||
class GameRoom extends withRoomAuth(Room) {
|
||||
@requireAuth()
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||
// 只有已认证玩家才能交易
|
||||
}
|
||||
|
||||
@requireAuth({ allowGuest: true })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: ChatData, player: AuthPlayer) {
|
||||
// 访客也可以聊天
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @requireRole
|
||||
|
||||
要求特定角色才能访问消息处理器:
|
||||
|
||||
```typescript
|
||||
class AdminRoom extends withRoomAuth(Room) {
|
||||
@requireRole('admin')
|
||||
@onMessage('Ban')
|
||||
handleBan(data: BanData, player: AuthPlayer) {
|
||||
// 只有管理员才能封禁
|
||||
}
|
||||
|
||||
@requireRole(['moderator', 'admin'])
|
||||
@onMessage('Mute')
|
||||
handleMute(data: MuteData, player: AuthPlayer) {
|
||||
// 版主或管理员可以禁言
|
||||
}
|
||||
|
||||
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
@onMessage('SpecialFeature')
|
||||
handleSpecial(data: any, player: AuthPlayer) {
|
||||
// 需要同时拥有 verified 和 premium 角色
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证上下文 API
|
||||
|
||||
认证上下文提供多种检查认证状态的方法:
|
||||
|
||||
```typescript
|
||||
interface IAuthContext<TUser> {
|
||||
// 认证状态
|
||||
readonly isAuthenticated: boolean
|
||||
readonly user: TUser | null
|
||||
readonly userId: string | null
|
||||
readonly roles: ReadonlyArray<string>
|
||||
readonly authenticatedAt: number | null
|
||||
readonly expiresAt: number | null
|
||||
|
||||
// 角色检查
|
||||
hasRole(role: string): boolean
|
||||
hasAnyRole(roles: string[]): boolean
|
||||
hasAllRoles(roles: string[]): boolean
|
||||
}
|
||||
```
|
||||
|
||||
`AuthContext` 类(实现类)还提供:
|
||||
|
||||
```typescript
|
||||
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||
// 从认证结果设置认证状态
|
||||
setAuthenticated(result: AuthResult<TUser>): void
|
||||
|
||||
// 清除认证状态
|
||||
clear(): void
|
||||
}
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
使用模拟认证提供者进行单元测试:
|
||||
|
||||
```typescript
|
||||
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||
|
||||
// 创建带预设用户的模拟提供者
|
||||
const mockProvider = createMockAuthProvider({
|
||||
users: [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
],
|
||||
autoCreate: true, // 为未知令牌创建用户
|
||||
})
|
||||
|
||||
// 在测试中使用
|
||||
const server = withAuth(testServer, {
|
||||
provider: mockProvider,
|
||||
extractCredentials: (req) => req.headers['x-token'],
|
||||
})
|
||||
|
||||
// 使用用户 ID 作为令牌进行验证
|
||||
const result = await mockProvider.verify('1')
|
||||
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
|
||||
// 动态添加/移除用户
|
||||
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||
mockProvider.removeUser('3')
|
||||
|
||||
// 撤销令牌
|
||||
await mockProvider.revoke('1')
|
||||
|
||||
// 重置到初始状态
|
||||
mockProvider.clear()
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
认证错误包含错误码用于程序化处理:
|
||||
|
||||
```typescript
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS' // 用户名/密码无效
|
||||
| 'INVALID_TOKEN' // 令牌格式错误或无效
|
||||
| 'EXPIRED_TOKEN' // 令牌已过期
|
||||
| 'USER_NOT_FOUND' // 用户查找失败
|
||||
| 'ACCOUNT_DISABLED' // 用户账号已禁用
|
||||
| 'RATE_LIMITED' // 请求过于频繁
|
||||
| 'INSUFFICIENT_PERMISSIONS' // 权限不足
|
||||
|
||||
// 在认证失败处理器中
|
||||
const server = withAuth(baseServer, {
|
||||
provider: jwtProvider,
|
||||
extractCredentials,
|
||||
onAuthFailed: (conn, error) => {
|
||||
switch (error.errorCode) {
|
||||
case 'EXPIRED_TOKEN':
|
||||
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||
break
|
||||
case 'INVALID_TOKEN':
|
||||
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||
break
|
||||
default:
|
||||
conn.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
以下是使用 JWT 认证的完整示例:
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
import {
|
||||
withAuth,
|
||||
withRoomAuth,
|
||||
createJwtAuthProvider,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
type AuthPlayer,
|
||||
} from '@esengine/server/auth'
|
||||
|
||||
// 类型定义
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// JWT 提供者
|
||||
const jwtProvider = createJwtAuthProvider<User>({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) ?? [],
|
||||
}),
|
||||
})
|
||||
|
||||
// 创建带认证的服务器
|
||||
const server = withAuth(
|
||||
await createServer({ port: 3000 }),
|
||||
{
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
return new URL(req.url ?? '', 'http://localhost')
|
||||
.searchParams.get('token')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// 带认证的游戏房间
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['player'],
|
||||
}) {
|
||||
onCreate() {
|
||||
console.log('游戏房间已创建')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} 加入了!`)
|
||||
this.broadcast('PlayerJoined', {
|
||||
id: player.id,
|
||||
name: player.user?.name,
|
||||
})
|
||||
}
|
||||
|
||||
@requireAuth()
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
// 处理移动
|
||||
}
|
||||
|
||||
@requireRole('admin')
|
||||
@onMessage('Kick')
|
||||
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
const target = this.getPlayer(data.playerId)
|
||||
if (target) {
|
||||
this.kick(target, '被管理员踢出')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **保护密钥安全**:永远不要硬编码 JWT 密钥,使用环境变量。
|
||||
|
||||
2. **设置合理的过期时间**:在安全性和用户体验之间平衡令牌 TTL。
|
||||
|
||||
3. **在关键操作上验证**:在敏感消息处理器上使用 `@requireAuth`。
|
||||
|
||||
4. **使用基于角色的访问控制**:为管理功能实现适当的角色层级。
|
||||
|
||||
5. **处理令牌刷新**:为长会话实现令牌刷新逻辑。
|
||||
|
||||
6. **记录认证事件**:跟踪登录尝试和失败以进行安全监控。
|
||||
|
||||
7. **测试认证流程**:使用 `MockAuthProvider` 测试认证场景。
|
||||
316
docs/src/content/docs/modules/network/delta.md
Normal file
316
docs/src/content/docs/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: "状态增量压缩"
|
||||
description: "减少网络带宽的增量同步"
|
||||
---
|
||||
|
||||
状态增量压缩通过只发送变化的字段来减少网络带宽。对于频繁同步的游戏状态,这可以显著降低数据传输量。
|
||||
|
||||
## StateDeltaCompressor
|
||||
|
||||
`StateDeltaCompressor` 类用于压缩和解压状态增量。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||
|
||||
// 创建压缩器
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.01, // 位置变化阈值
|
||||
rotationThreshold: 0.001, // 旋转变化阈值(弧度)
|
||||
velocityThreshold: 0.1, // 速度变化阈值
|
||||
fullSnapshotInterval: 60, // 完整快照间隔(帧数)
|
||||
});
|
||||
|
||||
// 压缩同步数据
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||
],
|
||||
};
|
||||
|
||||
const deltaData = compressor.compress(syncData);
|
||||
// deltaData 只包含变化的字段
|
||||
|
||||
// 解压增量数据
|
||||
const fullData = compressor.decompress(deltaData);
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `positionThreshold` | `number` | 0.01 | 位置变化阈值 |
|
||||
| `rotationThreshold` | `number` | 0.001 | 旋转变化阈值(弧度) |
|
||||
| `velocityThreshold` | `number` | 0.1 | 速度变化阈值 |
|
||||
| `fullSnapshotInterval` | `number` | 60 | 完整快照间隔(帧数) |
|
||||
|
||||
## 增量标志
|
||||
|
||||
使用位标志表示哪些字段发生了变化:
|
||||
|
||||
```typescript
|
||||
import { DeltaFlags } from '@esengine/network';
|
||||
|
||||
// 位标志定义
|
||||
DeltaFlags.NONE // 0 - 无变化
|
||||
DeltaFlags.POSITION // 1 - 位置变化
|
||||
DeltaFlags.ROTATION // 2 - 旋转变化
|
||||
DeltaFlags.VELOCITY // 4 - 速度变化
|
||||
DeltaFlags.ANGULAR_VELOCITY // 8 - 角速度变化
|
||||
DeltaFlags.CUSTOM // 16 - 自定义数据变化
|
||||
```
|
||||
|
||||
## 数据格式
|
||||
|
||||
### 完整状态
|
||||
|
||||
```typescript
|
||||
interface EntitySyncState {
|
||||
netId: number;
|
||||
pos?: { x: number; y: number };
|
||||
rot?: number;
|
||||
vel?: { x: number; y: number };
|
||||
angVel?: number;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### 增量状态
|
||||
|
||||
```typescript
|
||||
interface EntityDeltaState {
|
||||
netId: number;
|
||||
flags: number; // 变化标志位
|
||||
pos?: { x: number; y: number }; // 仅在 POSITION 标志时存在
|
||||
rot?: number; // 仅在 ROTATION 标志时存在
|
||||
vel?: { x: number; y: number }; // 仅在 VELOCITY 标志时存在
|
||||
angVel?: number; // 仅在 ANGULAR_VELOCITY 标志时存在
|
||||
custom?: Record<string, unknown>; // 仅在 CUSTOM 标志时存在
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
帧 1 (完整快照):
|
||||
Entity 1: pos=(100, 200), rot=0
|
||||
|
||||
帧 2 (增量):
|
||||
Entity 1: flags=POSITION, pos=(101, 200) // 只有 X 变化
|
||||
|
||||
帧 3 (增量):
|
||||
Entity 1: flags=0 // 无变化,不发送
|
||||
|
||||
帧 4 (增量):
|
||||
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||
|
||||
帧 60 (强制完整快照):
|
||||
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||
```
|
||||
|
||||
## 服务器端使用
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// 广播状态更新
|
||||
broadcastState(entities: EntitySyncState[]) {
|
||||
const syncData: SyncData = {
|
||||
frame: this.currentFrame,
|
||||
timestamp: Date.now(),
|
||||
entities,
|
||||
};
|
||||
|
||||
// 压缩数据
|
||||
const deltaData = this.compressor.compress(syncData);
|
||||
|
||||
// 发送增量数据
|
||||
this.broadcast('sync', deltaData);
|
||||
}
|
||||
|
||||
// 玩家离开时清理
|
||||
onPlayerLeave(netId: number) {
|
||||
this.compressor.removeEntity(netId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端使用
|
||||
|
||||
```typescript
|
||||
class GameClient {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// 接收增量数据
|
||||
onSyncReceived(deltaData: DeltaSyncData) {
|
||||
// 解压为完整状态
|
||||
const fullData = this.compressor.decompress(deltaData);
|
||||
|
||||
// 应用状态
|
||||
for (const entity of fullData.entities) {
|
||||
this.applyEntityState(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 带宽节省示例
|
||||
|
||||
假设每个实体有以下数据:
|
||||
|
||||
| 字段 | 大小(字节) |
|
||||
|------|------------|
|
||||
| netId | 4 |
|
||||
| pos.x | 8 |
|
||||
| pos.y | 8 |
|
||||
| rot | 8 |
|
||||
| vel.x | 8 |
|
||||
| vel.y | 8 |
|
||||
| angVel | 8 |
|
||||
| **总计** | **52** |
|
||||
|
||||
使用增量压缩:
|
||||
|
||||
| 场景 | 原始 | 压缩后 | 节省 |
|
||||
|------|------|--------|------|
|
||||
| 只有位置变化 | 52 | 4+1+16 = 21 | 60% |
|
||||
| 只有旋转变化 | 52 | 4+1+8 = 13 | 75% |
|
||||
| 静止不动 | 52 | 0 | 100% |
|
||||
| 位置+旋转变化 | 52 | 4+1+24 = 29 | 44% |
|
||||
|
||||
## 强制完整快照
|
||||
|
||||
某些情况下需要发送完整快照:
|
||||
|
||||
```typescript
|
||||
// 新玩家加入时
|
||||
compressor.forceFullSnapshot();
|
||||
const data = compressor.compress(syncData);
|
||||
// 这次会发送完整状态
|
||||
|
||||
// 重连时
|
||||
compressor.clear(); // 清除历史状态
|
||||
compressor.forceFullSnapshot();
|
||||
```
|
||||
|
||||
## 自定义数据
|
||||
|
||||
支持同步自定义游戏数据:
|
||||
|
||||
```typescript
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{
|
||||
netId: 1,
|
||||
pos: { x: 100, y: 200 },
|
||||
custom: {
|
||||
health: 80,
|
||||
mana: 50,
|
||||
buffs: ['speed', 'shield'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 自定义数据也会进行增量压缩
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 合理设置阈值
|
||||
|
||||
```typescript
|
||||
// 高精度游戏(如竞技游戏)
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.001,
|
||||
rotationThreshold: 0.0001,
|
||||
});
|
||||
|
||||
// 普通游戏
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.1,
|
||||
rotationThreshold: 0.01,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 调整完整快照间隔
|
||||
|
||||
```typescript
|
||||
// 高可靠性(网络不稳定)
|
||||
fullSnapshotInterval: 30, // 每 30 帧发送完整快照
|
||||
|
||||
// 低带宽优先
|
||||
fullSnapshotInterval: 120, // 每 120 帧发送完整快照
|
||||
```
|
||||
|
||||
### 3. 配合 AOI 使用
|
||||
|
||||
```typescript
|
||||
// 先用 AOI 过滤,再用增量压缩
|
||||
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
### 4. 处理实体移除
|
||||
|
||||
```typescript
|
||||
// 实体销毁时清理压缩器状态
|
||||
function onEntityDespawn(netId: number) {
|
||||
compressor.removeEntity(netId);
|
||||
}
|
||||
```
|
||||
|
||||
## 与其他功能配合
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 游戏状态 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ AOI 过滤 │ ← 只处理视野内实体
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ 增量压缩 │ ← 只发送变化的字段
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ 网络传输 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
const compressor = createStateDeltaCompressor();
|
||||
|
||||
// 检查压缩效果
|
||||
const original = syncData;
|
||||
const compressed = compressor.compress(original);
|
||||
|
||||
console.log('Original entities:', original.entities.length);
|
||||
console.log('Compressed entities:', compressed.entities.length);
|
||||
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||
|
||||
// 查看每个实体的变化
|
||||
for (const delta of compressed.entities) {
|
||||
console.log(`Entity ${delta.netId}:`, {
|
||||
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||
});
|
||||
}
|
||||
```
|
||||
441
docs/src/content/docs/modules/network/distributed.md
Normal file
441
docs/src/content/docs/modules/network/distributed.md
Normal file
@@ -0,0 +1,441 @@
|
||||
---
|
||||
title: "分布式房间"
|
||||
description: "使用 DistributedRoomManager 实现多服务器房间管理"
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
分布式房间支持允许多个服务器实例共享房间注册表,实现跨服务器玩家路由和故障转移。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Server A Server B Server C │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
|
||||
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IDistributedAdapter │ │
|
||||
│ │ (Redis / Memory) │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 单机模式(测试用)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DistributedRoomManager,
|
||||
MemoryAdapter,
|
||||
Room
|
||||
} from '@esengine/server';
|
||||
|
||||
// 定义房间类型
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
// 创建适配器和管理器
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000
|
||||
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
|
||||
|
||||
// 注册房间类型
|
||||
manager.define('game', GameRoom);
|
||||
|
||||
// 启动管理器
|
||||
await manager.start();
|
||||
|
||||
// 分布式加入/创建房间
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
if ('redirect' in result) {
|
||||
// 玩家应连接到其他服务器
|
||||
console.log(`重定向到: ${result.redirect}`);
|
||||
} else {
|
||||
// 玩家加入本地房间
|
||||
const { room, player } = result;
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### 多服务器模式(生产用)
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis({
|
||||
host: 'redis.example.com',
|
||||
port: 6379
|
||||
}),
|
||||
prefix: 'game:',
|
||||
serverTtl: 30,
|
||||
snapshotTtl: 86400
|
||||
});
|
||||
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: process.env.SERVER_ID,
|
||||
serverAddress: process.env.PUBLIC_IP,
|
||||
serverPort: 3000,
|
||||
heartbeatInterval: 5000,
|
||||
snapshotInterval: 30000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## DistributedRoomManager
|
||||
|
||||
### 配置选项
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `serverId` | `string` | 必填 | 服务器唯一标识 |
|
||||
| `serverAddress` | `string` | 必填 | 客户端连接的公开地址 |
|
||||
| `serverPort` | `number` | 必填 | 服务器端口 |
|
||||
| `heartbeatInterval` | `number` | `5000` | 心跳间隔(毫秒) |
|
||||
| `snapshotInterval` | `number` | `30000` | 状态快照间隔,0 禁用 |
|
||||
| `migrationTimeout` | `number` | `10000` | 房间迁移超时 |
|
||||
| `enableFailover` | `boolean` | `true` | 启用自动故障转移 |
|
||||
| `capacity` | `number` | `100` | 本服务器最大房间数 |
|
||||
|
||||
### 生命周期方法
|
||||
|
||||
#### start()
|
||||
|
||||
启动分布式房间管理器。连接适配器、注册服务器、启动心跳。
|
||||
|
||||
```typescript
|
||||
await manager.start();
|
||||
```
|
||||
|
||||
#### stop(graceful?)
|
||||
|
||||
停止管理器。如果 `graceful=true`,将服务器标记为 draining 并保存所有房间快照。
|
||||
|
||||
```typescript
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### 路由方法
|
||||
|
||||
#### joinOrCreateDistributed()
|
||||
|
||||
分布式感知的加入或创建房间。返回本地房间的 `{ room, player }` 或远程房间的 `{ redirect: string }`。
|
||||
|
||||
```typescript
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
|
||||
if ('redirect' in result) {
|
||||
// 客户端应重定向到其他服务器
|
||||
res.json({ redirect: result.redirect });
|
||||
} else {
|
||||
// 玩家加入了本地房间
|
||||
const { room, player } = result;
|
||||
}
|
||||
```
|
||||
|
||||
#### route()
|
||||
|
||||
将玩家路由到合适的房间/服务器。
|
||||
|
||||
```typescript
|
||||
const result = await manager.route({
|
||||
roomType: 'game',
|
||||
playerId: 'p1'
|
||||
});
|
||||
|
||||
switch (result.type) {
|
||||
case 'local': // 房间在本服务器
|
||||
break;
|
||||
case 'redirect': // 房间在其他服务器
|
||||
// result.serverAddress 包含目标服务器地址
|
||||
break;
|
||||
case 'create': // 没有可用房间,需要创建
|
||||
break;
|
||||
case 'unavailable': // 无法找到或创建房间
|
||||
// result.reason 包含错误信息
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 状态管理
|
||||
|
||||
#### saveSnapshot()
|
||||
|
||||
手动保存房间状态快照。
|
||||
|
||||
```typescript
|
||||
await manager.saveSnapshot(roomId);
|
||||
```
|
||||
|
||||
#### restoreFromSnapshot()
|
||||
|
||||
从保存的快照恢复房间。
|
||||
|
||||
```typescript
|
||||
const success = await manager.restoreFromSnapshot(roomId);
|
||||
```
|
||||
|
||||
### 查询方法
|
||||
|
||||
#### getServers()
|
||||
|
||||
获取所有在线服务器。
|
||||
|
||||
```typescript
|
||||
const servers = await manager.getServers();
|
||||
```
|
||||
|
||||
#### queryDistributedRooms()
|
||||
|
||||
查询所有服务器上的房间。
|
||||
|
||||
```typescript
|
||||
const rooms = await manager.queryDistributedRooms({
|
||||
roomType: 'game',
|
||||
hasSpace: true,
|
||||
notLocked: true
|
||||
});
|
||||
```
|
||||
|
||||
## IDistributedAdapter
|
||||
|
||||
分布式后端的接口。实现此接口以支持 Redis、消息队列等。
|
||||
|
||||
### 内置适配器
|
||||
|
||||
#### MemoryAdapter
|
||||
|
||||
用于测试和单机模式的内存实现。
|
||||
|
||||
```typescript
|
||||
const adapter = new MemoryAdapter({
|
||||
serverTtl: 15000, // 无心跳后服务器离线时间(毫秒)
|
||||
enableTtlCheck: true, // 启用自动 TTL 检查
|
||||
ttlCheckInterval: 5000 // TTL 检查间隔(毫秒)
|
||||
});
|
||||
```
|
||||
|
||||
#### RedisAdapter
|
||||
|
||||
用于生产环境多服务器部署的 Redis 实现。
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis('redis://localhost:6379'),
|
||||
prefix: 'game:', // 键前缀(默认: 'dist:')
|
||||
serverTtl: 30, // 服务器 TTL(秒,默认: 30)
|
||||
roomTtl: 0, // 房间 TTL,0 = 永不过期(默认: 0)
|
||||
snapshotTtl: 86400, // 快照 TTL(秒,默认: 24 小时)
|
||||
channel: 'game:events' // Pub/Sub 频道(默认: 'distributed:events')
|
||||
});
|
||||
```
|
||||
|
||||
**RedisAdapter 配置:**
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `factory` | `() => RedisClient` | 必填 | Redis 客户端工厂(惰性连接) |
|
||||
| `prefix` | `string` | `'dist:'` | 所有 Redis 键的前缀 |
|
||||
| `serverTtl` | `number` | `30` | 服务器 TTL(秒) |
|
||||
| `roomTtl` | `number` | `0` | 房间 TTL(秒),0 = 不过期 |
|
||||
| `snapshotTtl` | `number` | `86400` | 快照 TTL(秒) |
|
||||
| `channel` | `string` | `'distributed:events'` | Pub/Sub 频道名 |
|
||||
|
||||
**功能特性:**
|
||||
- 带自动心跳 TTL 的服务器注册
|
||||
- 跨服务器查找的房间注册
|
||||
- 可配置 TTL 的状态快照
|
||||
- 跨服务器事件的 Pub/Sub
|
||||
- 使用 Redis SET NX 的分布式锁
|
||||
|
||||
### 自定义适配器
|
||||
|
||||
```typescript
|
||||
import type { IDistributedAdapter } from '@esengine/server';
|
||||
|
||||
class MyAdapter implements IDistributedAdapter {
|
||||
// 生命周期
|
||||
async connect(): Promise<void> { }
|
||||
async disconnect(): Promise<void> { }
|
||||
isConnected(): boolean { return true; }
|
||||
|
||||
// 服务器注册
|
||||
async registerServer(server: ServerRegistration): Promise<void> { }
|
||||
async unregisterServer(serverId: string): Promise<void> { }
|
||||
async heartbeat(serverId: string): Promise<void> { }
|
||||
async getServers(): Promise<ServerRegistration[]> { return []; }
|
||||
|
||||
// 房间注册
|
||||
async registerRoom(room: RoomRegistration): Promise<void> { }
|
||||
async unregisterRoom(roomId: string): Promise<void> { }
|
||||
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
|
||||
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
|
||||
|
||||
// 状态快照
|
||||
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
|
||||
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
|
||||
|
||||
// 发布/订阅
|
||||
async publish(event: DistributedEvent): Promise<void> { }
|
||||
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
|
||||
|
||||
// 分布式锁
|
||||
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
|
||||
async releaseLock(key: string): Promise<void> { }
|
||||
}
|
||||
```
|
||||
|
||||
## 玩家路由流程
|
||||
|
||||
```
|
||||
客户端 服务器 A 服务器 B
|
||||
│ │ │
|
||||
│─── joinOrCreate ────────►│ │
|
||||
│ │ │
|
||||
│ │── findAvailableRoom() ───►│
|
||||
│ │◄──── 服务器 B 上有房间 ────│
|
||||
│ │ │
|
||||
│◄─── redirect: B:3001 ────│ │
|
||||
│ │ │
|
||||
│───────────────── 连接到服务器 B ────────────────────►│
|
||||
│ │ │
|
||||
│◄─────────────────────────────── 已加入 ─────────────│
|
||||
```
|
||||
|
||||
## 事件类型
|
||||
|
||||
分布式系统发布以下事件:
|
||||
|
||||
| 事件 | 描述 |
|
||||
|------|------|
|
||||
| `server:online` | 服务器上线 |
|
||||
| `server:offline` | 服务器离线 |
|
||||
| `server:draining` | 服务器正在排空 |
|
||||
| `room:created` | 房间已创建 |
|
||||
| `room:disposed` | 房间已销毁 |
|
||||
| `room:updated` | 房间信息已更新 |
|
||||
| `room:message` | 跨服务器房间消息 |
|
||||
| `room:migrated` | 房间已迁移到其他服务器 |
|
||||
| `player:joined` | 玩家加入房间 |
|
||||
| `player:left` | 玩家离开房间 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用唯一服务器 ID** - 使用主机名、容器 ID 或 UUID
|
||||
|
||||
2. **配置合适的心跳** - 在新鲜度和网络开销之间平衡
|
||||
|
||||
3. **为有状态房间启用快照** - 确保房间状态在服务器重启后存活
|
||||
|
||||
4. **优雅处理重定向** - 客户端应重新连接到目标服务器
|
||||
```typescript
|
||||
// 客户端处理重定向
|
||||
if (response.redirect) {
|
||||
await client.disconnect();
|
||||
await client.connect(response.redirect);
|
||||
await client.joinRoom(roomId);
|
||||
}
|
||||
```
|
||||
|
||||
5. **使用分布式锁** - 防止 joinOrCreate 中的竞态条件
|
||||
|
||||
## 使用 createServer 集成
|
||||
|
||||
最简单的使用方式是通过 `createServer` 的 `distributed` 配置:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server';
|
||||
import { RedisAdapter, Room } from '@esengine/server';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
distributed: {
|
||||
enabled: true,
|
||||
adapter: new RedisAdapter({ factory: () => new Redis() }),
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'ws://192.168.1.100',
|
||||
serverPort: 3000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}
|
||||
});
|
||||
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
当客户端调用 `JoinRoom` API 时,服务器会自动:
|
||||
1. 查找可用房间(本地或远程)
|
||||
2. 如果房间在其他服务器,发送 `$redirect` 消息给客户端
|
||||
3. 客户端收到重定向消息后连接到目标服务器
|
||||
|
||||
## 负载均衡
|
||||
|
||||
使用 `LoadBalancedRouter` 进行服务器选择:
|
||||
|
||||
```typescript
|
||||
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
|
||||
|
||||
// 使用工厂函数
|
||||
const router = createLoadBalancedRouter('least-players');
|
||||
|
||||
// 或直接创建
|
||||
const router = new LoadBalancedRouter({
|
||||
strategy: 'least-rooms', // 选择房间数最少的服务器
|
||||
preferLocal: true // 优先选择本地服务器
|
||||
});
|
||||
|
||||
// 可用策略
|
||||
// - 'round-robin': 轮询
|
||||
// - 'least-rooms': 最少房间数
|
||||
// - 'least-players': 最少玩家数
|
||||
// - 'random': 随机选择
|
||||
// - 'weighted': 权重(基于容量使用率)
|
||||
```
|
||||
|
||||
## 故障转移
|
||||
|
||||
当服务器离线时,启用 `enableFailover` 后系统会自动:
|
||||
|
||||
1. 检测到服务器离线(通过心跳超时)
|
||||
2. 查询该服务器上的所有房间
|
||||
3. 使用分布式锁防止多服务器同时恢复
|
||||
4. 从快照恢复房间状态
|
||||
5. 发布 `room:migrated` 事件通知其他服务器
|
||||
|
||||
```typescript
|
||||
// 确保定期保存快照
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000,
|
||||
snapshotInterval: 30000, // 每 30 秒保存快照
|
||||
enableFailover: true // 启用故障转移
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## 后续版本
|
||||
|
||||
- Redis Cluster 支持
|
||||
- 更多负载均衡策略(地理位置、延迟感知)
|
||||
326
docs/src/content/docs/modules/network/fixed-point.md
Normal file
326
docs/src/content/docs/modules/network/fixed-point.md
Normal file
@@ -0,0 +1,326 @@
|
||||
---
|
||||
title: "定点数"
|
||||
description: "用于帧同步的确定性定点数数学库"
|
||||
---
|
||||
|
||||
`@esengine/ecs-framework-math` 提供确定性定点数计算,专为**帧同步 (Lockstep)** 设计。定点数在所有平台上保证产生完全相同的计算结果。
|
||||
|
||||
## 为什么需要定点数?
|
||||
|
||||
浮点数在不同平台上可能产生不同的舍入结果:
|
||||
|
||||
```typescript
|
||||
// 浮点数:不同平台可能得到不同结果
|
||||
const a = 0.1 + 0.2; // 0.30000000000000004 (某些平台)
|
||||
// 0.3 (其他平台)
|
||||
|
||||
// 定点数:所有平台结果一致
|
||||
const x = Fixed32.from(0.1);
|
||||
const y = Fixed32.from(0.2);
|
||||
const z = x.add(y); // raw = 19661 (所有平台)
|
||||
```
|
||||
|
||||
| 特性 | 浮点数 | 定点数 |
|
||||
|------|--------|--------|
|
||||
| 跨平台一致性 | ❌ 可能不同 | ✅ 完全一致 |
|
||||
| 网络同步模式 | 状态同步 | 帧同步 (Lockstep) |
|
||||
| 适用游戏类型 | FPS、RPG | RTS、MOBA、格斗 |
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/ecs-framework-math
|
||||
```
|
||||
|
||||
## Fixed32 定点数
|
||||
|
||||
Q16.16 格式:16 位整数 + 16 位小数,范围 ±32767.99998。
|
||||
|
||||
### 创建定点数
|
||||
|
||||
```typescript
|
||||
import { Fixed32 } from '@esengine/ecs-framework-math';
|
||||
|
||||
// 从浮点数创建
|
||||
const speed = Fixed32.from(5.5);
|
||||
|
||||
// 从整数创建(无精度损失)
|
||||
const count = Fixed32.fromInt(10);
|
||||
|
||||
// 从原始值创建(网络接收后使用)
|
||||
const received = Fixed32.fromRaw(360448); // 等于 5.5
|
||||
|
||||
// 预定义常量
|
||||
Fixed32.ZERO // 0
|
||||
Fixed32.ONE // 1
|
||||
Fixed32.HALF // 0.5
|
||||
Fixed32.PI // π
|
||||
Fixed32.TWO_PI // 2π
|
||||
Fixed32.HALF_PI // π/2
|
||||
```
|
||||
|
||||
### 基本运算
|
||||
|
||||
```typescript
|
||||
const a = Fixed32.from(10);
|
||||
const b = Fixed32.from(3);
|
||||
|
||||
const sum = a.add(b); // 13
|
||||
const diff = a.sub(b); // 7
|
||||
const prod = a.mul(b); // 30
|
||||
const quot = a.div(b); // 3.333...
|
||||
const mod = a.mod(b); // 1
|
||||
const neg = a.neg(); // -10
|
||||
const abs = neg.abs(); // 10
|
||||
```
|
||||
|
||||
### 比较运算
|
||||
|
||||
```typescript
|
||||
const x = Fixed32.from(5);
|
||||
const y = Fixed32.from(3);
|
||||
|
||||
x.eq(y) // false - 等于
|
||||
x.ne(y) // true - 不等于
|
||||
x.lt(y) // false - 小于
|
||||
x.le(y) // false - 小于等于
|
||||
x.gt(y) // true - 大于
|
||||
x.ge(y) // true - 大于等于
|
||||
|
||||
x.isZero() // false
|
||||
x.isPositive() // true
|
||||
x.isNegative() // false
|
||||
```
|
||||
|
||||
### 数学函数
|
||||
|
||||
```typescript
|
||||
// 平方根(牛顿迭代法,确定性)
|
||||
const sqrt = Fixed32.sqrt(Fixed32.from(16)); // 4
|
||||
|
||||
// 取整
|
||||
Fixed32.floor(Fixed32.from(3.7)) // 3
|
||||
Fixed32.ceil(Fixed32.from(3.2)) // 4
|
||||
Fixed32.round(Fixed32.from(3.5)) // 4
|
||||
|
||||
// 范围限制
|
||||
Fixed32.clamp(value, min, max)
|
||||
|
||||
// 线性插值
|
||||
Fixed32.lerp(from, to, t)
|
||||
|
||||
// 最大/最小值
|
||||
Fixed32.min(a, b)
|
||||
Fixed32.max(a, b)
|
||||
```
|
||||
|
||||
### 类型转换
|
||||
|
||||
```typescript
|
||||
const value = Fixed32.from(3.14159);
|
||||
|
||||
// 转为浮点数(用于渲染)
|
||||
const float = value.toNumber(); // 3.14159
|
||||
|
||||
// 获取原始值(用于网络传输)
|
||||
const raw = value.toRaw(); // 205887
|
||||
|
||||
// 转为整数(向下取整)
|
||||
const int = value.toInt(); // 3
|
||||
```
|
||||
|
||||
## FixedVector2 定点数向量
|
||||
|
||||
不可变的 2D 向量类,所有运算返回新实例。
|
||||
|
||||
### 创建向量
|
||||
|
||||
```typescript
|
||||
import { FixedVector2, Fixed32 } from '@esengine/ecs-framework-math';
|
||||
|
||||
// 从浮点数创建
|
||||
const pos = FixedVector2.from(100, 200);
|
||||
|
||||
// 从原始值创建(网络接收后使用)
|
||||
const received = FixedVector2.fromRaw(6553600, 13107200);
|
||||
|
||||
// 从 Fixed32 创建
|
||||
const vec = new FixedVector2(Fixed32.from(10), Fixed32.from(20));
|
||||
|
||||
// 预定义常量
|
||||
FixedVector2.ZERO // (0, 0)
|
||||
FixedVector2.ONE // (1, 1)
|
||||
FixedVector2.RIGHT // (1, 0)
|
||||
FixedVector2.LEFT // (-1, 0)
|
||||
FixedVector2.UP // (0, 1)
|
||||
FixedVector2.DOWN // (0, -1)
|
||||
```
|
||||
|
||||
### 向量运算
|
||||
|
||||
```typescript
|
||||
const a = FixedVector2.from(3, 4);
|
||||
const b = FixedVector2.from(1, 2);
|
||||
|
||||
// 基本运算
|
||||
const sum = a.add(b); // (4, 6)
|
||||
const diff = a.sub(b); // (2, 2)
|
||||
const scaled = a.mul(Fixed32.from(2)); // (6, 8)
|
||||
const divided = a.div(Fixed32.from(2)); // (1.5, 2)
|
||||
|
||||
// 向量积
|
||||
const dot = a.dot(b); // 3*1 + 4*2 = 11
|
||||
const cross = a.cross(b); // 3*2 - 4*1 = 2
|
||||
|
||||
// 长度
|
||||
const lenSq = a.lengthSquared(); // 25
|
||||
const len = a.length(); // 5
|
||||
|
||||
// 归一化
|
||||
const norm = a.normalize(); // (0.6, 0.8)
|
||||
|
||||
// 距离
|
||||
const dist = a.distanceTo(b); // sqrt((3-1)² + (4-2)²)
|
||||
```
|
||||
|
||||
### 旋转和角度
|
||||
|
||||
```typescript
|
||||
import { FixedMath } from '@esengine/ecs-framework-math';
|
||||
|
||||
const vec = FixedVector2.from(1, 0);
|
||||
const angle = Fixed32.from(Math.PI / 2); // 90度
|
||||
|
||||
// 旋转向量
|
||||
const rotated = vec.rotate(angle); // (0, 1)
|
||||
|
||||
// 围绕点旋转
|
||||
const center = FixedVector2.from(5, 5);
|
||||
const around = vec.rotateAround(center, angle);
|
||||
|
||||
// 获取向量角度
|
||||
const vecAngle = vec.angle();
|
||||
|
||||
// 两向量夹角
|
||||
const between = vec.angleTo(other);
|
||||
|
||||
// 从角度创建单位向量
|
||||
const dir = FixedVector2.fromAngle(angle);
|
||||
|
||||
// 从极坐标创建
|
||||
const polar = FixedVector2.fromPolar(length, angle);
|
||||
```
|
||||
|
||||
### 类型转换
|
||||
|
||||
```typescript
|
||||
const pos = FixedVector2.from(100.5, 200.5);
|
||||
|
||||
// 转为浮点对象(用于渲染)
|
||||
const obj = pos.toObject(); // { x: 100.5, y: 200.5 }
|
||||
|
||||
// 转为数组
|
||||
const arr = pos.toArray(); // [100.5, 200.5]
|
||||
|
||||
// 获取原始值(用于网络传输)
|
||||
const raw = pos.toRawObject(); // { x: 6586368, y: 13140992 }
|
||||
```
|
||||
|
||||
## FixedMath 三角函数
|
||||
|
||||
使用查找表实现确定性三角函数。
|
||||
|
||||
```typescript
|
||||
import { FixedMath, Fixed32 } from '@esengine/ecs-framework-math';
|
||||
|
||||
const angle = Fixed32.from(Math.PI / 6); // 30度
|
||||
|
||||
// 三角函数
|
||||
const sin = FixedMath.sin(angle); // 0.5
|
||||
const cos = FixedMath.cos(angle); // 0.866
|
||||
const tan = FixedMath.tan(angle); // 0.577
|
||||
|
||||
// 反三角函数
|
||||
const atan = FixedMath.atan2(y, x);
|
||||
const asin = FixedMath.asin(value);
|
||||
const acos = FixedMath.acos(value);
|
||||
|
||||
// 角度规范化到 [-π, π]
|
||||
const normalized = FixedMath.normalizeAngle(angle);
|
||||
|
||||
// 角度差(最短路径)
|
||||
const delta = FixedMath.angleDelta(from, to);
|
||||
|
||||
// 角度插值(处理 360° 环绕)
|
||||
const lerped = FixedMath.lerpAngle(from, to, t);
|
||||
|
||||
// 弧度/角度转换
|
||||
const deg = FixedMath.radToDeg(rad);
|
||||
const rad = FixedMath.degToRad(deg);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 全程使用定点数计算
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:所有游戏逻辑使用定点数
|
||||
function calculateDamage(baseDamage: Fixed32, multiplier: Fixed32): Fixed32 {
|
||||
return baseDamage.mul(multiplier);
|
||||
}
|
||||
|
||||
// ❌ 错误:混用浮点数
|
||||
function calculateDamage(baseDamage: number, multiplier: number): number {
|
||||
return baseDamage * multiplier; // 可能不一致
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 只在渲染时转换为浮点数
|
||||
|
||||
```typescript
|
||||
// 游戏逻辑层
|
||||
const position: FixedVector2 = calculatePosition(input);
|
||||
|
||||
// 渲染层
|
||||
const { x, y } = position.toObject();
|
||||
sprite.position.set(x, y);
|
||||
```
|
||||
|
||||
### 3. 使用原始值进行网络传输
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:传输整数原始值
|
||||
const raw = position.toRawObject();
|
||||
send(JSON.stringify(raw));
|
||||
|
||||
// ❌ 错误:传输浮点数
|
||||
const float = position.toObject();
|
||||
send(JSON.stringify(float)); // 可能丢失精度
|
||||
```
|
||||
|
||||
### 4. 使用 FixedMath 进行三角运算
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用查找表
|
||||
const direction = FixedVector2.fromAngle(FixedMath.atan2(dy, dx));
|
||||
|
||||
// ❌ 错误:使用 Math 库
|
||||
const angle = Math.atan2(dy.toNumber(), dx.toNumber()); // 不确定
|
||||
```
|
||||
|
||||
## API 导出
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Fixed32,
|
||||
FixedVector2,
|
||||
FixedMath,
|
||||
type IFixed32,
|
||||
type IFixedVector2
|
||||
} from '@esengine/ecs-framework-math';
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [状态同步](/modules/network/sync) - 定点数快照缓冲区
|
||||
- [客户端预测](/modules/network/prediction) - 定点数客户端预测
|
||||
679
docs/src/content/docs/modules/network/http.md
Normal file
679
docs/src/content/docs/modules/network/http.md
Normal file
@@ -0,0 +1,679 @@
|
||||
---
|
||||
title: "HTTP 路由"
|
||||
description: "HTTP REST API 路由功能,支持与 WebSocket 共用端口"
|
||||
---
|
||||
|
||||
`@esengine/server` 内置了轻量级的 HTTP 路由功能,可以与 WebSocket 服务共用同一端口,方便实现 REST API。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 内联路由定义
|
||||
|
||||
最简单的方式是在创建服务器时直接定义 HTTP 路由:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
http: {
|
||||
'/api/health': (req, res) => {
|
||||
res.json({ status: 'ok', time: Date.now() })
|
||||
},
|
||||
'/api/users': {
|
||||
GET: (req, res) => {
|
||||
res.json({ users: [] })
|
||||
},
|
||||
POST: async (req, res) => {
|
||||
const body = req.body as { name: string }
|
||||
res.status(201).json({ id: '1', name: body.name })
|
||||
}
|
||||
}
|
||||
},
|
||||
cors: true // 启用 CORS
|
||||
})
|
||||
|
||||
await server.start()
|
||||
```
|
||||
|
||||
### 文件路由
|
||||
|
||||
对于较大的项目,推荐使用文件路由。创建 `src/http` 目录,每个文件对应一个路由:
|
||||
|
||||
```typescript
|
||||
// src/http/login.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
interface LoginBody {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default defineHttp<LoginBody>({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
const { username, password } = req.body as LoginBody
|
||||
|
||||
// 验证用户...
|
||||
if (username === 'admin' && password === '123456') {
|
||||
res.json({ token: 'jwt-token-here', userId: 'user-1' })
|
||||
} else {
|
||||
res.error(401, '用户名或密码错误')
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http', // HTTP 路由目录
|
||||
httpPrefix: '/api', // 路由前缀
|
||||
cors: true
|
||||
})
|
||||
|
||||
await server.start()
|
||||
// 路由: POST /api/login
|
||||
```
|
||||
|
||||
## defineHttp 定义
|
||||
|
||||
`defineHttp` 用于定义类型安全的 HTTP 处理器:
|
||||
|
||||
```typescript
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
interface CreateUserBody {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default defineHttp<CreateUserBody>({
|
||||
// HTTP 方法(默认 POST)
|
||||
method: 'POST',
|
||||
|
||||
// 处理函数
|
||||
handler(req, res) {
|
||||
const body = req.body as CreateUserBody
|
||||
// 处理请求...
|
||||
res.status(201).json({ id: 'new-user-id' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 支持的 HTTP 方法
|
||||
|
||||
```typescript
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
|
||||
```
|
||||
|
||||
## HttpRequest 对象
|
||||
|
||||
HTTP 请求对象包含以下属性:
|
||||
|
||||
```typescript
|
||||
interface HttpRequest {
|
||||
/** 原始 Node.js IncomingMessage */
|
||||
raw: IncomingMessage
|
||||
|
||||
/** HTTP 方法 */
|
||||
method: string
|
||||
|
||||
/** 请求路径 */
|
||||
path: string
|
||||
|
||||
/** 路由参数(从 URL 路径提取,如 /users/:id) */
|
||||
params: Record<string, string>
|
||||
|
||||
/** 查询参数 */
|
||||
query: Record<string, string>
|
||||
|
||||
/** 请求头 */
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
|
||||
/** 解析后的请求体 */
|
||||
body: unknown
|
||||
|
||||
/** 客户端 IP */
|
||||
ip: string
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// 获取查询参数
|
||||
const page = parseInt(req.query.page ?? '1')
|
||||
const limit = parseInt(req.query.limit ?? '10')
|
||||
|
||||
// 获取请求头
|
||||
const authHeader = req.headers.authorization
|
||||
|
||||
// 获取客户端 IP
|
||||
console.log('Request from:', req.ip)
|
||||
|
||||
res.json({ page, limit })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 请求体解析
|
||||
|
||||
请求体会根据 `Content-Type` 自动解析:
|
||||
|
||||
- `application/json` - 解析为 JSON 对象
|
||||
- `application/x-www-form-urlencoded` - 解析为键值对对象
|
||||
- 其他 - 保持原始字符串
|
||||
|
||||
```typescript
|
||||
export default defineHttp<{ name: string; age: number }>({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
// body 已自动解析
|
||||
const { name, age } = req.body as { name: string; age: number }
|
||||
res.json({ received: { name, age } })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## HttpResponse 对象
|
||||
|
||||
HTTP 响应对象提供链式 API:
|
||||
|
||||
```typescript
|
||||
interface HttpResponse {
|
||||
/** 原始 Node.js ServerResponse */
|
||||
raw: ServerResponse
|
||||
|
||||
/** 设置状态码 */
|
||||
status(code: number): HttpResponse
|
||||
|
||||
/** 设置响应头 */
|
||||
header(name: string, value: string): HttpResponse
|
||||
|
||||
/** 发送 JSON 响应 */
|
||||
json(data: unknown): void
|
||||
|
||||
/** 发送文本响应 */
|
||||
text(data: string): void
|
||||
|
||||
/** 发送错误响应 */
|
||||
error(code: number, message: string): void
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
// 设置状态码和自定义头
|
||||
res
|
||||
.status(201)
|
||||
.header('X-Custom-Header', 'value')
|
||||
.json({ created: true })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// 发送错误响应
|
||||
res.error(404, '资源不存在')
|
||||
// 等价于: res.status(404).json({ error: '资源不存在' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// 发送纯文本
|
||||
res.text('Hello, World!')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 文件路由规范
|
||||
|
||||
### 命名转换
|
||||
|
||||
文件名会自动转换为路由路径:
|
||||
|
||||
| 文件路径 | 路由路径(prefix=/api) |
|
||||
|---------|----------------------|
|
||||
| `login.ts` | `/api/login` |
|
||||
| `users/profile.ts` | `/api/users/profile` |
|
||||
| `users/[id].ts` | `/api/users/:id` |
|
||||
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
|
||||
|
||||
### 动态路由参数
|
||||
|
||||
使用 `[param]` 语法定义动态参数:
|
||||
|
||||
```typescript
|
||||
// src/http/users/[id].ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// 直接从 params 获取路由参数
|
||||
const { id } = req.params
|
||||
res.json({ userId: id })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
多个参数的情况:
|
||||
|
||||
```typescript
|
||||
// src/http/users/[userId]/posts/[postId].ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
const { userId, postId } = req.params
|
||||
res.json({ userId, postId })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 跳过规则
|
||||
|
||||
以下文件会被自动跳过:
|
||||
|
||||
- 以 `_` 开头的文件(如 `_helper.ts`)
|
||||
- `index.ts` / `index.js` 文件
|
||||
- 非 `.ts` / `.js` / `.mts` / `.mjs` 文件
|
||||
|
||||
### 目录结构示例
|
||||
|
||||
```
|
||||
src/
|
||||
└── http/
|
||||
├── _utils.ts # 跳过(下划线开头)
|
||||
├── index.ts # 跳过(index 文件)
|
||||
├── health.ts # GET /api/health
|
||||
├── login.ts # POST /api/login
|
||||
├── register.ts # POST /api/register
|
||||
└── users/
|
||||
├── index.ts # 跳过
|
||||
├── list.ts # GET /api/users/list
|
||||
└── [id].ts # GET /api/users/:id
|
||||
```
|
||||
|
||||
## CORS 配置
|
||||
|
||||
### 快速启用
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
cors: true // 使用默认配置
|
||||
})
|
||||
```
|
||||
|
||||
### 自定义配置
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
cors: {
|
||||
// 允许的来源
|
||||
origin: ['http://localhost:5173', 'https://myapp.com'],
|
||||
// 或使用通配符
|
||||
// origin: '*',
|
||||
// origin: true, // 反射请求来源
|
||||
|
||||
// 允许的 HTTP 方法
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
|
||||
// 允许的请求头
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
|
||||
// 是否允许携带凭证(cookies)
|
||||
credentials: true,
|
||||
|
||||
// 预检请求缓存时间(秒)
|
||||
maxAge: 86400
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### CorsOptions 类型
|
||||
|
||||
```typescript
|
||||
interface CorsOptions {
|
||||
/** 允许的来源:字符串、字符串数组、true(反射)或 '*' */
|
||||
origin?: string | string[] | boolean
|
||||
|
||||
/** 允许的 HTTP 方法 */
|
||||
methods?: string[]
|
||||
|
||||
/** 允许的请求头 */
|
||||
allowedHeaders?: string[]
|
||||
|
||||
/** 是否允许携带凭证 */
|
||||
credentials?: boolean
|
||||
|
||||
/** 预检请求缓存时间(秒) */
|
||||
maxAge?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 路由合并
|
||||
|
||||
文件路由和内联路由可以同时使用,内联路由优先级更高:
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http',
|
||||
httpPrefix: '/api',
|
||||
|
||||
// 内联路由会与文件路由合并
|
||||
http: {
|
||||
'/health': (req, res) => res.json({ status: 'ok' }),
|
||||
'/api/special': (req, res) => res.json({ special: true })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 与 WebSocket 共用端口
|
||||
|
||||
HTTP 路由与 WebSocket 服务自动共用同一端口:
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
// WebSocket 相关配置
|
||||
apiDir: './src/api',
|
||||
msgDir: './src/msg',
|
||||
|
||||
// HTTP 相关配置
|
||||
httpDir: './src/http',
|
||||
httpPrefix: '/api',
|
||||
cors: true
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
// 同一端口 3000:
|
||||
// - WebSocket: ws://localhost:3000
|
||||
// - HTTP API: http://localhost:3000/api/*
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 游戏服务器登录 API
|
||||
|
||||
```typescript
|
||||
// src/http/auth/login.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string
|
||||
userId: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600
|
||||
})
|
||||
|
||||
export default defineHttp<LoginRequest>({
|
||||
method: 'POST',
|
||||
async handler(req, res) {
|
||||
const { username, password } = req.body as LoginRequest
|
||||
|
||||
// 验证用户
|
||||
const user = await db.users.findByUsername(username)
|
||||
if (!user || !await verifyPassword(password, user.passwordHash)) {
|
||||
res.error(401, '用户名或密码错误')
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 JWT
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.username,
|
||||
roles: user.roles
|
||||
})
|
||||
|
||||
const response: LoginResponse = {
|
||||
token,
|
||||
userId: user.id,
|
||||
expiresAt: Date.now() + 3600 * 1000
|
||||
}
|
||||
|
||||
res.json(response)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 游戏数据查询 API
|
||||
|
||||
```typescript
|
||||
// src/http/game/leaderboard.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
async handler(req, res) {
|
||||
const limit = parseInt(req.query.limit ?? '10')
|
||||
const offset = parseInt(req.query.offset ?? '0')
|
||||
|
||||
const players = await db.players.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
|
||||
res.json({
|
||||
data: players,
|
||||
pagination: { limit, offset }
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 中间件
|
||||
|
||||
### 中间件类型
|
||||
|
||||
中间件是在路由处理前后执行的函数:
|
||||
|
||||
```typescript
|
||||
type HttpMiddleware = (
|
||||
req: HttpRequest,
|
||||
res: HttpResponse,
|
||||
next: () => Promise<void>
|
||||
) => void | Promise<void>
|
||||
```
|
||||
|
||||
### 内置中间件
|
||||
|
||||
```typescript
|
||||
import {
|
||||
requestLogger,
|
||||
bodyLimit,
|
||||
responseTime,
|
||||
requestId,
|
||||
securityHeaders
|
||||
} from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
http: { /* ... */ },
|
||||
// 全局中间件通过 createHttpRouter 配置
|
||||
})
|
||||
```
|
||||
|
||||
#### requestLogger - 请求日志
|
||||
|
||||
```typescript
|
||||
import { requestLogger } from '@esengine/server'
|
||||
|
||||
// 记录请求和响应时间
|
||||
requestLogger()
|
||||
|
||||
// 同时记录请求体
|
||||
requestLogger({ logBody: true })
|
||||
```
|
||||
|
||||
#### bodyLimit - 请求体大小限制
|
||||
|
||||
```typescript
|
||||
import { bodyLimit } from '@esengine/server'
|
||||
|
||||
// 限制请求体为 1MB
|
||||
bodyLimit(1024 * 1024)
|
||||
```
|
||||
|
||||
#### responseTime - 响应时间头
|
||||
|
||||
```typescript
|
||||
import { responseTime } from '@esengine/server'
|
||||
|
||||
// 自动添加 X-Response-Time 响应头
|
||||
responseTime()
|
||||
```
|
||||
|
||||
#### requestId - 请求 ID
|
||||
|
||||
```typescript
|
||||
import { requestId } from '@esengine/server'
|
||||
|
||||
// 自动生成并添加 X-Request-ID 响应头
|
||||
requestId()
|
||||
|
||||
// 自定义头名称
|
||||
requestId('X-Trace-ID')
|
||||
```
|
||||
|
||||
#### securityHeaders - 安全头
|
||||
|
||||
```typescript
|
||||
import { securityHeaders } from '@esengine/server'
|
||||
|
||||
// 添加常用安全响应头
|
||||
securityHeaders()
|
||||
|
||||
// 自定义配置
|
||||
securityHeaders({
|
||||
hidePoweredBy: true,
|
||||
frameOptions: 'DENY',
|
||||
noSniff: true
|
||||
})
|
||||
```
|
||||
|
||||
### 自定义中间件
|
||||
|
||||
```typescript
|
||||
import type { HttpMiddleware } from '@esengine/server'
|
||||
|
||||
// 认证中间件
|
||||
const authMiddleware: HttpMiddleware = async (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
res.error(401, 'Unauthorized')
|
||||
return // 不调用 next(),终止请求
|
||||
}
|
||||
|
||||
// 验证 token...
|
||||
(req as any).userId = 'decoded-user-id'
|
||||
|
||||
await next() // 继续执行后续中间件和处理器
|
||||
}
|
||||
```
|
||||
|
||||
### 使用中间件
|
||||
|
||||
#### 使用 createHttpRouter
|
||||
|
||||
```typescript
|
||||
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
|
||||
|
||||
const router = createHttpRouter({
|
||||
'/api/users': (req, res) => res.json([]),
|
||||
'/api/admin': {
|
||||
GET: {
|
||||
handler: (req, res) => res.json({ admin: true }),
|
||||
middlewares: [adminAuthMiddleware] // 路由级中间件
|
||||
}
|
||||
}
|
||||
}, {
|
||||
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // 全局中间件
|
||||
timeout: 30000 // 全局超时 30 秒
|
||||
})
|
||||
```
|
||||
|
||||
## 请求超时
|
||||
|
||||
### 全局超时
|
||||
|
||||
```typescript
|
||||
import { createHttpRouter } from '@esengine/server'
|
||||
|
||||
const router = createHttpRouter({
|
||||
'/api/data': async (req, res) => {
|
||||
// 如果处理超过 30 秒,自动返回 408 Request Timeout
|
||||
await someSlowOperation()
|
||||
res.json({ data: 'result' })
|
||||
}
|
||||
}, {
|
||||
timeout: 30000 // 30 秒
|
||||
})
|
||||
```
|
||||
|
||||
### 路由级超时
|
||||
|
||||
```typescript
|
||||
const router = createHttpRouter({
|
||||
'/api/quick': (req, res) => res.json({ fast: true }),
|
||||
|
||||
'/api/slow': {
|
||||
POST: {
|
||||
handler: async (req, res) => {
|
||||
await verySlowOperation()
|
||||
res.json({ done: true })
|
||||
},
|
||||
timeout: 120000 // 这个路由允许 2 分钟
|
||||
}
|
||||
}
|
||||
}, {
|
||||
timeout: 10000 // 全局 10 秒(被路由级覆盖)
|
||||
})
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用 defineHttp** - 获得更好的类型提示和代码组织
|
||||
2. **统一错误处理** - 使用 `res.error()` 返回一致的错误格式
|
||||
3. **启用 CORS** - 前后端分离时必须配置
|
||||
4. **目录组织** - 按功能模块组织 HTTP 路由文件
|
||||
5. **验证输入** - 始终验证 `req.body` 和 `req.query` 的内容
|
||||
6. **状态码规范** - 遵循 HTTP 状态码规范(200、201、400、401、404、500 等)
|
||||
7. **使用中间件** - 通过中间件实现认证、日志、限流等横切关注点
|
||||
8. **设置超时** - 避免慢请求阻塞服务器
|
||||
@@ -147,7 +147,11 @@ service.on('chat', (data) => {
|
||||
|
||||
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
|
||||
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
|
||||
- [状态同步](/modules/network/sync/) - 插值、预测和快照
|
||||
- [分布式房间](/modules/network/distributed/) - 多服务器房间管理和玩家路由
|
||||
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
|
||||
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
|
||||
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
|
||||
- [增量压缩](/modules/network/delta/) - 状态增量同步
|
||||
- [API 参考](/modules/network/api/) - 完整 API 文档
|
||||
|
||||
## 服务令牌
|
||||
@@ -159,10 +163,14 @@ import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from '@esengine/network';
|
||||
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
396
docs/src/content/docs/modules/network/prediction.md
Normal file
396
docs/src/content/docs/modules/network/prediction.md
Normal file
@@ -0,0 +1,396 @@
|
||||
---
|
||||
title: "客户端预测"
|
||||
description: "本地输入预测和服务器校正"
|
||||
---
|
||||
|
||||
客户端预测是网络游戏中用于减少输入延迟的关键技术。通过在本地立即应用玩家输入,同时等待服务器确认,可以让游戏感觉更加流畅响应。
|
||||
|
||||
## NetworkPredictionSystem
|
||||
|
||||
`NetworkPredictionSystem` 是专门处理本地玩家预测的 ECS 系统。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enablePrediction: true,
|
||||
predictionConfig: {
|
||||
moveSpeed: 200, // 移动速度(单位/秒)
|
||||
maxUnacknowledgedInputs: 60, // 最大未确认输入数
|
||||
reconciliationThreshold: 0.5, // 校正阈值
|
||||
reconciliationSpeed: 10, // 校正速度
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### 设置本地玩家
|
||||
|
||||
当本地玩家实体生成后,需要设置其网络 ID:
|
||||
|
||||
```typescript
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
|
||||
identity.bIsLocalPlayer = identity.bHasAuthority;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
|
||||
// 设置本地玩家用于预测
|
||||
if (identity.bIsLocalPlayer) {
|
||||
networkPlugin.setLocalPlayerNetId(spawn.netId);
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
```
|
||||
|
||||
### 发送输入
|
||||
|
||||
```typescript
|
||||
// 在游戏循环中发送移动输入
|
||||
function onUpdate() {
|
||||
const moveX = Input.getAxis('horizontal');
|
||||
const moveY = Input.getAxis('vertical');
|
||||
|
||||
if (moveX !== 0 || moveY !== 0) {
|
||||
networkPlugin.sendMoveInput(moveX, moveY);
|
||||
}
|
||||
|
||||
// 发送动作输入
|
||||
if (Input.isPressed('attack')) {
|
||||
networkPlugin.sendActionInput('attack');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 预测配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `moveSpeed` | `number` | 200 | 移动速度(单位/秒) |
|
||||
| `enabled` | `boolean` | true | 是否启用预测 |
|
||||
| `maxUnacknowledgedInputs` | `number` | 60 | 最大未确认输入数 |
|
||||
| `reconciliationThreshold` | `number` | 0.5 | 触发校正的位置差异阈值 |
|
||||
| `reconciliationSpeed` | `number` | 10 | 校正平滑速度 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
客户端 服务器
|
||||
│ │
|
||||
├─ 1. 捕获输入 (seq=1) │
|
||||
├─ 2. 本地预测移动 │
|
||||
├─ 3. 发送输入到服务器 ──────────────►
|
||||
│ │
|
||||
├─ 4. 继续捕获输入 (seq=2,3...) │
|
||||
├─ 5. 继续本地预测 │
|
||||
│ │
|
||||
│ ├─ 6. 处理输入 (seq=1)
|
||||
│ │
|
||||
◄──────── 7. 返回状态 (ackSeq=1) ────
|
||||
│ │
|
||||
├─ 8. 比较预测和服务器状态 │
|
||||
├─ 9. 重放 seq=2,3... 的输入 │
|
||||
├─ 10. 平滑校正到正确位置 │
|
||||
│ │
|
||||
```
|
||||
|
||||
### 步骤详解
|
||||
|
||||
1. **输入捕获**:捕获玩家输入并分配序列号
|
||||
2. **本地预测**:立即应用输入到本地状态
|
||||
3. **发送输入**:将输入发送到服务器
|
||||
4. **缓存输入**:保存输入用于后续校正
|
||||
5. **接收确认**:服务器返回权威状态和已确认序列号
|
||||
6. **状态比较**:比较预测状态和服务器状态
|
||||
7. **输入重放**:使用缓存的未确认输入重新计算状态
|
||||
8. **平滑校正**:平滑插值到正确位置
|
||||
|
||||
## 底层 API
|
||||
|
||||
如果需要更细粒度的控制,可以直接使用 `ClientPrediction` 类:
|
||||
|
||||
```typescript
|
||||
import { createClientPrediction, type IPredictor } from '@esengine/network';
|
||||
|
||||
// 定义状态类型
|
||||
interface PlayerState {
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
// 定义输入类型
|
||||
interface PlayerInput {
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
// 定义预测器
|
||||
const predictor: IPredictor<PlayerState, PlayerInput> = {
|
||||
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * dt,
|
||||
y: state.y + input.dy * MOVE_SPEED * dt,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 创建客户端预测
|
||||
const prediction = createClientPrediction(predictor, {
|
||||
maxUnacknowledgedInputs: 60,
|
||||
reconciliationThreshold: 0.5,
|
||||
reconciliationSpeed: 10,
|
||||
});
|
||||
|
||||
// 记录输入并获取预测状态
|
||||
const input = { dx: 1, dy: 0 };
|
||||
const predictedState = prediction.recordInput(input, currentState, deltaTime);
|
||||
|
||||
// 获取要发送的输入
|
||||
const inputToSend = prediction.getInputToSend();
|
||||
|
||||
// 与服务器状态校正
|
||||
prediction.reconcile(
|
||||
serverState,
|
||||
serverAckSeq,
|
||||
(state) => ({ x: state.x, y: state.y }),
|
||||
deltaTime
|
||||
);
|
||||
|
||||
// 获取校正偏移
|
||||
const offset = prediction.correctionOffset;
|
||||
```
|
||||
|
||||
## 启用/禁用预测
|
||||
|
||||
```typescript
|
||||
// 运行时切换预测
|
||||
networkPlugin.setPredictionEnabled(false);
|
||||
|
||||
// 检查预测状态
|
||||
if (networkPlugin.isPredictionEnabled) {
|
||||
console.log('Prediction is active');
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 合理设置校正阈值
|
||||
|
||||
```typescript
|
||||
// 动作游戏:较低阈值,更精确
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 0.1,
|
||||
}
|
||||
|
||||
// 休闲游戏:较高阈值,更平滑
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 1.0,
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 预测仅用于本地玩家
|
||||
|
||||
远程玩家应使用插值而非预测:
|
||||
|
||||
```typescript
|
||||
const identity = entity.getComponent(NetworkIdentity);
|
||||
|
||||
if (identity.bIsLocalPlayer) {
|
||||
// 使用预测系统
|
||||
} else {
|
||||
// 使用 NetworkSyncSystem 的插值
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 处理高延迟
|
||||
|
||||
```typescript
|
||||
// 高延迟网络增加缓冲
|
||||
predictionConfig: {
|
||||
maxUnacknowledgedInputs: 120, // 增加缓冲
|
||||
reconciliationSpeed: 5, // 减慢校正速度
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 确定性预测
|
||||
|
||||
确保客户端和服务器使用相同的物理计算:
|
||||
|
||||
```typescript
|
||||
// 使用固定时间步长
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
|
||||
// 使用固定时间步长而非实际 deltaTime
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
|
||||
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
// 获取预测系统实例
|
||||
const predictionSystem = networkPlugin.predictionSystem;
|
||||
|
||||
if (predictionSystem) {
|
||||
console.log('Pending inputs:', predictionSystem.pendingInputCount);
|
||||
console.log('Current sequence:', predictionSystem.inputSequence);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 定点数客户端预测(帧同步)
|
||||
|
||||
用于**帧同步 (Lockstep)** 的确定性客户端预测。
|
||||
|
||||
> 定点数基础知识请参考 [定点数文档](/modules/network/fixed-point)
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import {
|
||||
FixedClientPrediction,
|
||||
createFixedClientPrediction,
|
||||
type IFixedPredictor,
|
||||
type IFixedStatePositionExtractor
|
||||
} from '@esengine/network';
|
||||
import { Fixed32, FixedVector2 } from '@esengine/ecs-framework-math';
|
||||
|
||||
// 定义游戏状态
|
||||
interface GameState {
|
||||
position: FixedVector2;
|
||||
velocity: FixedVector2;
|
||||
}
|
||||
|
||||
// 实现预测器(必须使用定点数运算)
|
||||
const predictor: IFixedPredictor<GameState, PlayerInput> = {
|
||||
predict(state: GameState, input: PlayerInput, deltaTime: Fixed32): GameState {
|
||||
const speed = Fixed32.from(100);
|
||||
const inputVec = FixedVector2.from(input.dx, input.dy);
|
||||
const velocity = inputVec.normalize().mul(speed);
|
||||
const displacement = velocity.mul(deltaTime);
|
||||
|
||||
return {
|
||||
position: state.position.add(displacement),
|
||||
velocity
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 创建预测器
|
||||
const prediction = createFixedClientPrediction(predictor, {
|
||||
maxUnacknowledgedInputs: 60,
|
||||
fixedDeltaTime: Fixed32.from(1 / 60),
|
||||
reconciliationThreshold: Fixed32.from(0.001),
|
||||
enableSmoothReconciliation: false // 帧同步通常关闭
|
||||
});
|
||||
```
|
||||
|
||||
### 记录输入
|
||||
|
||||
```typescript
|
||||
function onUpdate(input: PlayerInput, currentState: GameState) {
|
||||
// 记录输入并获得预测状态
|
||||
const predicted = prediction.recordInput(input, currentState);
|
||||
|
||||
// 渲染预测状态
|
||||
const pos = predicted.position.toObject();
|
||||
sprite.position.set(pos.x, pos.y);
|
||||
|
||||
// 发送输入
|
||||
socket.send(JSON.stringify({
|
||||
frame: prediction.currentFrame,
|
||||
input
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### 服务器校正
|
||||
|
||||
```typescript
|
||||
// 位置提取器
|
||||
const posExtractor: IFixedStatePositionExtractor<GameState> = {
|
||||
getPosition(state: GameState): FixedVector2 {
|
||||
return state.position;
|
||||
}
|
||||
};
|
||||
|
||||
// 收到服务器状态
|
||||
function onServerState(serverState: GameState, serverFrame: number) {
|
||||
const reconciled = prediction.reconcile(
|
||||
serverState,
|
||||
serverFrame,
|
||||
posExtractor
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 回滚重播
|
||||
|
||||
```typescript
|
||||
// 发现不同步时回滚
|
||||
const correctedState = prediction.rollbackAndResimulate(
|
||||
serverFrame,
|
||||
authoritativeState
|
||||
);
|
||||
|
||||
// 查看历史状态
|
||||
const historicalState = prediction.getStateAtFrame(100);
|
||||
```
|
||||
|
||||
### 预设移动预测器
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createFixedMovementPredictor,
|
||||
createFixedMovementPositionExtractor,
|
||||
type IFixedMovementInput,
|
||||
type IFixedMovementState
|
||||
} from '@esengine/network';
|
||||
|
||||
// 创建移动预测器(速度 100 单位/秒)
|
||||
const movePredictor = createFixedMovementPredictor(Fixed32.from(100));
|
||||
const posExtractor = createFixedMovementPositionExtractor();
|
||||
|
||||
const prediction = createFixedClientPrediction<IFixedMovementState, IFixedMovementInput>(
|
||||
movePredictor,
|
||||
{ fixedDeltaTime: Fixed32.from(1 / 60) }
|
||||
);
|
||||
|
||||
// 输入格式
|
||||
const input: IFixedMovementInput = { dx: 1, dy: 0 };
|
||||
```
|
||||
|
||||
### API 导出
|
||||
|
||||
```typescript
|
||||
import {
|
||||
FixedClientPrediction,
|
||||
createFixedClientPrediction,
|
||||
createFixedMovementPredictor,
|
||||
createFixedMovementPositionExtractor,
|
||||
type IFixedInputSnapshot,
|
||||
type IFixedPredictedState,
|
||||
type IFixedPredictor,
|
||||
type IFixedStatePositionExtractor,
|
||||
type FixedClientPredictionConfig,
|
||||
type IFixedMovementInput,
|
||||
type IFixedMovementState
|
||||
} from '@esengine/network';
|
||||
```
|
||||
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: "速率限制"
|
||||
description: "使用可配置的速率限制保护你的游戏服务器免受滥用"
|
||||
---
|
||||
|
||||
`@esengine/server` 包含可插拔的速率限制系统,用于防止 DDoS 攻击、消息洪水和其他滥用行为。
|
||||
|
||||
## 安装
|
||||
|
||||
速率限制包含在 server 包中:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage } from '@esengine/server'
|
||||
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20,
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
}) {
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: Player) {
|
||||
// 受速率限制保护(默认 10 msg/s)
|
||||
}
|
||||
|
||||
@rateLimit({ messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) {
|
||||
// 交易使用更严格的限制
|
||||
}
|
||||
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
// 心跳不限制
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 速率限制策略
|
||||
|
||||
### 令牌桶(默认)
|
||||
|
||||
令牌桶算法允许突发流量,同时保持长期速率限制。令牌以固定速率添加,每个请求消耗令牌。
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'token-bucket',
|
||||
messagesPerSecond: 10, // 补充速率
|
||||
burstSize: 20, // 桶容量
|
||||
}) { }
|
||||
```
|
||||
|
||||
**工作原理:**
|
||||
```
|
||||
配置: rate=10/s, burstSize=20
|
||||
|
||||
[0s] 桶满: 20 令牌
|
||||
[0s] 收到 15 条消息 → 允许,剩余 5
|
||||
[0.5s] 补充 5 令牌 → 10 令牌
|
||||
[0.5s] 收到 8 条消息 → 允许,剩余 2
|
||||
[0.6s] 补充 1 令牌 → 3 令牌
|
||||
[0.6s] 收到 5 条消息 → 允许 3,拒绝 2
|
||||
```
|
||||
|
||||
**最适合:** 大多数通用场景,平衡突发容忍度与保护。
|
||||
|
||||
### 滑动窗口
|
||||
|
||||
滑动窗口算法精确跟踪时间窗口内的请求。比固定窗口更准确,但内存使用稍多。
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'sliding-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**最适合:** 需要精确限流且不需要突发容忍的场景。
|
||||
|
||||
### 固定窗口
|
||||
|
||||
固定窗口算法将时间划分为固定间隔,并计算每个间隔内的请求数。简单且内存高效,但在窗口边界允许 2 倍突发。
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'fixed-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**最适合:** 简单场景,可接受边界突发。
|
||||
|
||||
## 配置
|
||||
|
||||
### 房间配置
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
// 每秒允许的消息数(默认: 10)
|
||||
messagesPerSecond: 10,
|
||||
|
||||
// 突发容量 / 桶大小(默认: 20)
|
||||
burstSize: 20,
|
||||
|
||||
// 策略: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// 被限流时的回调
|
||||
onLimited: (player, messageType, result) => {
|
||||
player.send('RateLimited', {
|
||||
type: messageType,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// 限流时断开连接(默认: false)
|
||||
disconnectOnLimit: false,
|
||||
|
||||
// 连续 N 次限流后断开(0 = 永不)
|
||||
maxConsecutiveLimits: 10,
|
||||
|
||||
// 自定义键函数(默认: player.id)
|
||||
getKey: (player) => player.id,
|
||||
|
||||
// 清理间隔(毫秒,默认: 60000)
|
||||
cleanupInterval: 60000,
|
||||
}) { }
|
||||
```
|
||||
|
||||
### 单消息配置
|
||||
|
||||
使用装饰器为特定消息配置速率限制:
|
||||
|
||||
```typescript
|
||||
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
// 此消息使用自定义速率限制
|
||||
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) { }
|
||||
|
||||
// 此消息消耗 5 个令牌
|
||||
@rateLimit({ cost: 5 })
|
||||
@onMessage('ExpensiveAction')
|
||||
handleExpensive(data: any, player: Player) { }
|
||||
|
||||
// 豁免速率限制
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) { }
|
||||
|
||||
// 替代方案:显式指定消息类型
|
||||
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||
@onMessage('SpecialAction')
|
||||
handleSpecial(data: any, player: Player) { }
|
||||
}
|
||||
```
|
||||
|
||||
## 与认证系统组合
|
||||
|
||||
速率限制可与认证系统无缝配合:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth } from '@esengine/server/auth'
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
// 同时应用两个 mixin
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth(Room, { requireAuth: true }),
|
||||
{ messagesPerSecond: 10 }
|
||||
) {
|
||||
onJoin(player: AuthPlayer) {
|
||||
console.log(`${player.user?.name} 已加入,受速率限制保护`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 速率限制结果
|
||||
|
||||
当消息被限流时,回调会收到结果对象:
|
||||
|
||||
```typescript
|
||||
interface RateLimitResult {
|
||||
// 是否允许请求
|
||||
allowed: boolean
|
||||
|
||||
// 剩余配额
|
||||
remaining: number
|
||||
|
||||
// 配额重置时间(时间戳)
|
||||
resetAt: number
|
||||
|
||||
// 重试等待时间(毫秒)
|
||||
retryAfter?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 访问速率限制上下文
|
||||
|
||||
你可以访问任何玩家的速率限制上下文:
|
||||
|
||||
```typescript
|
||||
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
someMethod(player: Player) {
|
||||
const context = this.getRateLimitContext(player)
|
||||
|
||||
// 检查但不消费
|
||||
const status = context?.check()
|
||||
console.log(`剩余: ${status?.remaining}`)
|
||||
|
||||
// 获取连续限流次数
|
||||
console.log(`连续限流: ${context?.consecutiveLimitCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 或使用独立函数
|
||||
const context = getPlayerRateLimitContext(player)
|
||||
```
|
||||
|
||||
## 自定义策略
|
||||
|
||||
你可以直接使用策略进行自定义实现:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createTokenBucketStrategy,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
// 直接创建策略
|
||||
const strategy = createTokenBucketStrategy({
|
||||
rate: 10, // 每秒令牌数
|
||||
capacity: 20, // 最大令牌数
|
||||
})
|
||||
|
||||
// 检查并消费
|
||||
const result = strategy.consume('player-123')
|
||||
if (result.allowed) {
|
||||
// 处理消息
|
||||
} else {
|
||||
// 被限流,等待 result.retryAfter 毫秒
|
||||
}
|
||||
|
||||
// 检查但不消费
|
||||
const status = strategy.getStatus('player-123')
|
||||
|
||||
// 重置某个键
|
||||
strategy.reset('player-123')
|
||||
|
||||
// 清理过期记录
|
||||
strategy.cleanup()
|
||||
```
|
||||
|
||||
## 速率限制上下文
|
||||
|
||||
`RateLimitContext` 类管理单个玩家的速率限制:
|
||||
|
||||
```typescript
|
||||
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||
|
||||
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||
const context = new RateLimitContext('player-123', strategy)
|
||||
|
||||
// 检查但不消费
|
||||
context.check()
|
||||
|
||||
// 消费配额
|
||||
context.consume()
|
||||
|
||||
// 带消耗量消费
|
||||
context.consume(undefined, 5)
|
||||
|
||||
// 为特定消息类型消费
|
||||
context.consume('Trade')
|
||||
|
||||
// 设置单消息策略
|
||||
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||
|
||||
// 重置
|
||||
context.reset()
|
||||
|
||||
// 获取连续限流次数
|
||||
console.log(context.consecutiveLimitCount)
|
||||
```
|
||||
|
||||
## 房间生命周期钩子
|
||||
|
||||
你可以重写 `onRateLimited` 钩子进行自定义处理:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
// 记录事件
|
||||
console.log(`玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||
|
||||
// 发送自定义错误
|
||||
player.send('SystemMessage', {
|
||||
type: 'warning',
|
||||
message: `请慢一点!${result.retryAfter}ms 后重试`,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **从令牌桶开始**:对于游戏来说是最灵活的算法。
|
||||
|
||||
2. **设置合适的限制**:考虑你的游戏机制:
|
||||
- 移动消息:较高限制(20-60/s)
|
||||
- 聊天消息:较低限制(1-5/s)
|
||||
- 交易/购买:非常低的限制(0.5-1/s)
|
||||
|
||||
3. **使用突发容量**:允许短暂突发以获得响应式体验:
|
||||
```typescript
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30, // 允许 3 秒的突发
|
||||
```
|
||||
|
||||
4. **豁免关键消息**:不要限制心跳或系统消息:
|
||||
```typescript
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() { }
|
||||
```
|
||||
|
||||
5. **与认证结合**:对已认证用户按用户 ID 限流:
|
||||
```typescript
|
||||
getKey: (player) => player.auth?.userId ?? player.id
|
||||
```
|
||||
|
||||
6. **监控和调整**:记录限流事件以调整限制:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
metrics.increment('rate_limit', { messageType: type })
|
||||
}
|
||||
```
|
||||
|
||||
7. **优雅降级**:发送信息性错误而不是直接断开:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
message: '请求过于频繁',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage, type Player } from '@esengine/server'
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
import {
|
||||
withRateLimit,
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
type RateLimitResult,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
premium: boolean
|
||||
}
|
||||
|
||||
// 组合认证和速率限制
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||
{
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30,
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// 使用用户 ID 进行限流
|
||||
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||
|
||||
// 处理限流
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
messageType: type,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// 连续 20 次限流后断开
|
||||
maxConsecutiveLimits: 20,
|
||||
}
|
||||
) {
|
||||
onCreate() {
|
||||
console.log('房间已创建,具有认证 + 速率限制保护')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||
}
|
||||
|
||||
// 高频移动(默认速率限制)
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||
}
|
||||
|
||||
// 低频交易(严格限制)
|
||||
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||
// 处理交易...
|
||||
}
|
||||
|
||||
// 聊天使用中等限制
|
||||
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
|
||||
// 系统消息 - 不限制
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
player.send('Pong', { time: Date.now() })
|
||||
}
|
||||
|
||||
// 自定义限流处理
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
console.warn(`[限流] 玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||
}
|
||||
}
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user