Compare commits

...

59 Commits

Author SHA1 Message Date
github-actions[bot]
0f5aa633d8 chore: release packages (#413)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-31 18:12:40 +08:00
YHH
85171a0a5c fix(database): include dist directory in npm package (#412)
* fix(database): include dist directory in npm package

* fix(ci): add database packages to release build
2025-12-31 18:10:40 +08:00
github-actions[bot]
35d81880a7 chore: release packages (#411)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-31 16:33:05 +08:00
YHH
71022abc99 feat(database): add database layer architecture (#410)
- Add @esengine/database-drivers for MongoDB/Redis connection management
- Add @esengine/database for Repository pattern with CRUD, pagination, soft delete
- Refactor @esengine/transaction MongoStorage to use shared connection
- Add comprehensive documentation in Chinese and English
2025-12-31 16:26:53 +08:00
github-actions[bot]
87f71e2251 chore: release packages (#409)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-31 14:32:18 +08:00
YHH
b9ea8d14cf feat(behavior-tree): add action() and condition() methods to BehaviorTreeBuilder (#408)
- Add action(implementationType, name?, config?) for custom action executors
- Add condition(implementationType, name?, config?) for custom condition executors
- Update documentation (EN and CN) with usage examples
- Add test script to package.json
2025-12-31 14:30:31 +08:00
yhh
10d0fb1d5c fix(rapier2d): fix external config path mismatch in tsup 2025-12-31 13:25:30 +08:00
github-actions[bot]
71e111415f chore: release packages (#407)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-31 12:18:18 +08:00
YHH
0de45279e6 fix(behavior-tree): export NodeExecutorMetadata as value instead of type (#406) 2025-12-31 12:16:17 +08:00
github-actions[bot]
cc6f12d470 chore: release packages (#405)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-31 10:11:24 +08:00
YHH
902c0a1074 chore: add changeset for HTTP file routing (#404) 2025-12-31 10:06:40 +08:00
yhh
d3e489aad3 feat(server): add HTTP file-based routing support
- Add file-based HTTP routing with httpDir and httpPrefix config options
- Create defineHttp<TBody>() helper for type-safe route definitions
- Support dynamic routes with [param].ts file naming convention
- Add CORS support for cross-origin requests
- Allow merging file routes with inline http config
- RPC server now supports attaching to existing HTTP server via server option
- Add comprehensive documentation for HTTP routing
2025-12-31 09:53:12 +08:00
yhh
12051d987f docs(network): add custom authentication provider documentation
- Add IAuthProvider interface documentation
- Add database password authentication example
- Add OAuth/third-party authentication example
- Add API Key authentication example
- Add guide for using and combining multiple providers
2025-12-30 22:46:40 +08:00
yhh
b38fe5ebf4 docs(editor): improve editor-app build documentation and add build:rapier2d script
- Add `pnpm build:rapier2d` command to automate Rapier2D WASM build process
- Fix gen-src.mjs path to correctly locate thirdparty/rapier.js
- Update init.ts to work with new wasm-pack web target (auto-initialization)
- Fix behavior-tree-editor build config for asset-system dependency
- Update README_CN.md and README.md with simplified build instructions
2025-12-30 22:33:06 +08:00
yhh
f01ce1e320 chore: update lawn-mower-demo submodule (airstrike sync fix) 2025-12-30 21:21:51 +08:00
github-actions[bot]
094133a71a chore: release packages (#403)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-30 20:55:04 +08:00
YHH
3e5b7783be fix(ecs): resolve ESM require is not defined error (#402)
- Add RuntimeConfig module as standalone runtime environment storage
- Core.runtimeEnvironment and Scene.runtimeEnvironment now read from RuntimeConfig
- Remove require() call in Scene.ts to fix Node.js ESM compatibility

Fixes ReferenceError: require is not defined when using scene.isServer in ESM environment
2025-12-30 20:52:29 +08:00
github-actions[bot]
ebcb4d00a8 chore: release packages (#401)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-30 20:35:23 +08:00
YHH
d2af9caae9 feat(behavior-tree): add pure BehaviorTreePlugin for Cocos/Laya integration (#400)
- Add BehaviorTreePlugin class that only depends on @esengine/ecs-framework
- Implement IPlugin interface with install(), uninstall(), setupScene() methods
- Remove esengine/ subdirectory that incorrectly depended on engine-core
- Update package documentation with correct usage examples
2025-12-30 20:31:52 +08:00
yhh
bb696c6a60 chore: update lawn-mower-demo submodule to 2.7.0 2025-12-30 18:56:44 +08:00
github-actions[bot]
ffd35a71cd chore: release packages (#399)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-30 18:08:38 +08:00
YHH
1f3a76aabe feat(ecs): 添加运行时环境区分机制 | add runtime environment detection (#398)
- Core 新增静态属性 runtimeEnvironment,支持 'server' | 'client' | 'standalone'
- Core 新增 isServer / isClient 静态只读属性
- ICoreConfig 新增 runtimeEnvironment 配置项
- Scene 新增 isServer / isClient 只读属性(默认从 Core 继承,可通过 config 覆盖)
- 新增 @ServerOnly() / @ClientOnly() / @NotServer() / @NotClient() 方法装饰器
- 更新中英文文档

用于网络游戏中区分服务端权威逻辑和客户端逻辑
2025-12-30 17:56:06 +08:00
github-actions[bot]
ddc7d1f726 chore: release packages (#397)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-30 16:59:49 +08:00
YHH
04b08f3f07 fix(ecs): add entity field to COMPONENT_ADDED event (#396)
Fix missing entity field in COMPONENT_ADDED event payload that caused
ECSRoom's @NetworkEntity auto-broadcast to fail with 'Cannot read
properties of undefined'
2025-12-30 16:57:11 +08:00
yhh
d9969d0b08 Merge branch 'master' of https://github.com/esengine/esengine 2025-12-30 16:23:54 +08:00
YHH
bdbbf8a80a feat(ecs): 添加 @NetworkEntity 装饰器,支持自动广播实体生成/销毁 (#395)
* docs: add editor-app README with setup instructions

* docs: add separate EN/CN editor setup guides

* feat(ecs): add @NetworkEntity decorator for auto spawn/despawn broadcasting

- Add @NetworkEntity decorator to mark components for automatic network broadcasting
- ECSRoom now auto-broadcasts spawn on component:added event
- ECSRoom now auto-broadcasts despawn on entity:destroyed event
- Entity.destroy() emits entity:destroyed event via ECSEventType
- Entity active state changes emit ENTITY_ENABLED/ENTITY_DISABLED events
- Add enableAutoNetworkEntity config option to ECSRoom (default true)
- Update documentation for both Chinese and English
2025-12-30 16:19:01 +08:00
yhh
1368473c71 Merge remote master 2025-12-30 12:29:24 +08:00
YHH
b28169b186 fix(editor): fix build errors and refactor behavior-tree architecture (#394)
* docs: add editor-app README with setup instructions

* docs: add separate EN/CN editor setup guides

* fix(editor): fix build errors and refactor behavior-tree architecture

- Fix fairygui-editor tsconfig extends path and add missing tsconfig.build.json
- Refactor behavior-tree-editor to not depend on asset-system in runtime
  - Create local BehaviorTreeRuntimeModule for pure runtime logic
  - Move asset loader registration to editor module install()
  - Add BehaviorTreeLoader for asset system integration
- Fix rapier2d WASM loader to not pass arguments to init()
- Add WASM base64 loader config to rapier2d tsup.config
- Update README documentation and simplify setup steps
2025-12-30 11:13:26 +08:00
yhh
e2598b2292 docs: add separate EN/CN editor setup guides 2025-12-30 10:02:53 +08:00
yhh
2e3889abed docs: add editor-app README with setup instructions 2025-12-30 09:54:41 +08:00
github-actions[bot]
d21caa974e chore: release packages (#393)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-30 09:41:17 +08:00
YHH
a08a84b7db fix(sync): use GlobalComponentRegistry for network sync decoding (#392)
- Decoder.ts now uses GlobalComponentRegistry.getComponentType() instead of local registry
- @sync decorator uses getComponentTypeName() to get @ECSComponent decorator name
- @ECSComponent decorator updates SYNC_METADATA.typeId when defined
- Removed deprecated registerSyncComponent/autoRegisterSyncComponent functions
- Updated ComponentSync.ts in network package to use GlobalComponentRegistry
- Updated tests to use correct @ECSComponent type names

This ensures that components decorated with @ECSComponent are automatically
available for network sync decoding without any manual registration.
2025-12-30 09:39:17 +08:00
github-actions[bot]
449bd420a6 chore: release packages (#391)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 21:10:36 +08:00
YHH
1f297ac769 feat(ecs): ECS 网络状态同步系统 | add ECS network state synchronization (#390)
## @esengine/ecs-framework

新增 @sync 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:

- `sync` 装饰器标记需要同步的字段
- `ChangeTracker` 组件变更追踪
- 二进制编解码器 (BinaryWriter/BinaryReader)
- `encodeSnapshot`/`decodeSnapshot` 批量编解码
- `encodeSpawn`/`decodeSpawn` 实体生成编解码
- `encodeDespawn`/`processDespawn` 实体销毁编解码

将以下方法标记为 @internal,用户应通过 Core.update() 驱动更新:
- Scene.update()
- SceneManager.update()
- WorldManager.updateAll()

## @esengine/network

- 新增 ComponentSyncSystem 基于 @sync 自动同步组件状态
- 将 ecs-framework 从 devDependencies 移到 peerDependencies

## @esengine/server

新增 ECSRoom,带有 ECS World 支持的房间基类:

- 每个 ECSRoom 在 Core.worldManager 中创建独立的 World
- Core.update() 统一更新 Time 和所有 World
- onTick() 只处理状态同步逻辑
- 自动创建/销毁玩家实体
- 增量状态广播
2025-12-29 21:08:34 +08:00
github-actions[bot]
4cf868a769 chore: release packages (#389)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 17:14:53 +08:00
YHH
afdeb00b4d feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system (#388)
* feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system

- 新增令牌桶策略 (TokenBucketStrategy) - 推荐用于一般场景
- 新增滑动窗口策略 (SlidingWindowStrategy) - 精确跟踪
- 新增固定窗口策略 (FixedWindowStrategy) - 简单高效
- 新增房间速率限制 mixin (withRateLimit)
- 新增速率限制装饰器 (@rateLimit, @noRateLimit)
- 新增按消息类型限流装饰器 (@rateLimitMessage, @noRateLimitMessage)
- 支持与认证系统组合使用
- 添加中英文文档
- 导出路径: @esengine/server/ratelimit

* docs: 更新 README 添加新模块 | update README with new modules

- 添加程序化生成 (procgen) 模块
- 添加 RPC 框架模块
- 添加游戏服务器 (server) 模块
- 添加事务系统 (transaction) 模块
- 添加世界流送 (world-streaming) 模块
- 更新网络模块描述
- 更新项目结构目录
2025-12-29 17:12:54 +08:00
github-actions[bot]
764ce67742 chore: release packages (#387)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 16:12:03 +08:00
YHH
61a13baca2 feat(server): 添加可插拔认证系统 | add pluggable authentication system (#386)
* feat(server): 添加可插拔认证系统 | add pluggable authentication system

- 新增 JWT 认证提供者 (createJwtAuthProvider)
- 新增 Session 认证提供者 (createSessionAuthProvider)
- 新增服务器认证 mixin (withAuth)
- 新增房间认证 mixin (withRoomAuth)
- 新增认证装饰器 (@requireAuth, @requireRole)
- 新增测试工具 (MockAuthProvider)
- 新增中英文文档
- 导出路径: @esengine/server/auth, @esengine/server/auth/testing

* fix(server): 使用加密安全的随机数生成 session ID | use crypto-secure random for session ID
2025-12-29 16:10:09 +08:00
github-actions[bot]
1cfa64aa0f chore: release packages (#385)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 15:04:07 +08:00
YHH
3b978384c7 feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)
## Server Testing Utils
- Add TestServer, TestClient, MockRoom for unit testing
- Export testing utilities from @esengine/server/testing

## Transaction Storage (BREAKING)
- Simplify RedisStorage/MongoStorage to factory pattern only
- Remove DI client injection option
- Add lazy connection and Symbol.asyncDispose support
- Add 161 unit tests with full coverage

## Pathfinding Tests
- Add 150 unit tests covering all components
- BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother

## Docs
- Update storage.md for new factory pattern API
2025-12-29 15:02:13 +08:00
YHH
10c3891abd docs: 添加缺失的侧边栏导航项 | add missing sidebar items (#383)
- RPC: 添加 server, client, codec 页面
- Network: 添加 prediction, aoi, delta 页面
- Transaction: 添加完整模块导航
- Changelog: 添加 transaction, rpc 链接
2025-12-29 11:29:42 +08:00
github-actions[bot]
18af48a0fc chore: release packages (#382)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 11:08:46 +08:00
YHH
d4cef828e1 feat(transaction): 添加游戏事务系统 | add game transaction system (#381)
- TransactionManager/TransactionContext 事务管理
- MemoryStorage/RedisStorage/MongoStorage 存储实现
- CurrencyOperation/InventoryOperation/TradeOperation 内置操作
- SagaOrchestrator 分布式 Saga 编排
- withTransactions() Room 集成
- 完整中英文文档
2025-12-29 10:54:00 +08:00
github-actions[bot]
2d46ccf896 chore: release packages (#380)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 10:44:48 +08:00
YHH
fb8bde6485 feat(network): 网络模块增强 - 预测、AOI、增量压缩 (#379)
- 添加 NetworkPredictionSystem 客户端预测系统
- 添加 NetworkAOISystem 兴趣区域管理
- 添加 StateDeltaCompressor 状态增量压缩
- 添加断线重连和状态恢复
- 增强协议支持时间戳、序列号、速度
- 添加中英文文档
2025-12-29 10:42:48 +08:00
yhh
30437dc5d5 docs: add world-streaming to sidebar navigation 2025-12-28 19:50:44 +08:00
YHH
9f84c2f870 chore: bump pathfinding and world-streaming to 1.1.0 (#378) 2025-12-28 19:36:34 +08:00
github-actions[bot]
e9ea52d9b3 chore: release packages (#377)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 19:26:41 +08:00
YHH
0662b07445 chore: update pathfinding, add rpc/world-streaming docs, refactor world-streaming location (#376) 2025-12-28 19:18:28 +08:00
github-actions[bot]
838cda91aa chore: release packages (#375)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 14:11:27 +08:00
YHH
a000cc07d7 feat(rpc): export RpcClient from main entry point (#374) 2025-12-28 14:09:16 +08:00
github-actions[bot]
1316d7de49 chore: release packages (#373)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 13:49:34 +08:00
YHH
9c41181875 fix(server): expose id property on ServerConnection type (#372) 2025-12-28 13:47:27 +08:00
github-actions[bot]
9f3f9a547a chore: release packages (#371)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 13:32:44 +08:00
YHH
18df9d1cda fix(server): allow define() to be called before start() (#370) 2025-12-28 13:29:17 +08:00
github-actions[bot]
9a4b3388e0 chore: release packages (#369)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 12:51:20 +08:00
YHH
66d5dc27f7 fix(server): 修复发布缺少 dist | fix missing dist (#368)
* fix(server): 修复发布缺少 dist | fix missing dist in publish

* ci: 添加 server 和 create-esengine-server 构建 | add server packages to build

* fix: 添加 create-esengine-server 到 changeset
2025-12-28 12:46:50 +08:00
github-actions[bot]
8a3e54cb45 chore: release packages (#367)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 12:25:51 +08:00
YHH
b6f1235239 feat(server): 添加游戏服务器框架 | add game server framework (#366)
**@esengine/server** - 游戏服务器框架 | Game server framework
- 文件路由系统 | File-based routing
- Room 生命周期 (onCreate, onJoin, onLeave, onTick, onDispose)
- @onMessage 装饰器 | Message handler decorator
- 玩家管理与断线处理 | Player management with auto-disconnect
- 内置 JoinRoom/LeaveRoom API | Built-in room APIs
- defineApi/defineMsg 类型安全辅助函数 | Type-safe helpers

**create-esengine-server** - CLI 脚手架工具 | CLI scaffolding
- 生成 shared/server/client 项目结构 | Project structure
- 类型安全的协议定义 | Type-safe protocol definitions
- 包含 GameRoom 示例 | Example implementation

**其他 | Other**
- 删除旧的 network-server 包 | Remove old network-server
- 更新服务器文档 | Update server documentation
2025-12-28 12:23:55 +08:00
319 changed files with 47567 additions and 1928 deletions

View File

@@ -56,7 +56,12 @@ jobs:
pnpm --filter "@esengine/network-protocols" build
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
- name: Create Release Pull Request or Publish
id: changesets

View File

@@ -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

View File

@@ -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)
## 社区

View File

@@ -255,6 +255,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 +267,37 @@ 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: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State 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: '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 +322,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' } },
],
},

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View 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

View 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

View File

@@ -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

View 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);
}
```

View 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.

View 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),
});
}
```

View File

@@ -147,7 +147,10 @@ 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
- [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 +162,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

View File

@@ -0,0 +1,254 @@
---
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);
}
```

View 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}`)
}
}
```

View File

@@ -1,76 +1,552 @@
---
title: "Server Side"
description: "GameServer and Room management"
description: "Build game servers with @esengine/server"
---
## GameServer
## Quick Start
GameServer is the core server-side class managing WebSocket connections and rooms.
Create a new game server project using the CLI:
### Basic Usage
```bash
# Using npm
npm create esengine-server my-game-server
# Using pnpm
pnpm create esengine-server my-game-server
# Using yarn
yarn create esengine-server my-game-server
```
Generated project structure:
```
my-game-server/
├── src/
│ ├── shared/ # Shared protocol (client & server)
│ │ ├── protocol.ts # Type definitions
│ │ └── index.ts
│ ├── server/ # Server code
│ │ ├── main.ts # Entry point
│ │ └── rooms/
│ │ └── GameRoom.ts # Game room
│ └── client/ # Client example
│ └── index.ts
├── package.json
└── tsconfig.json
```
Start the server:
```bash
# Development mode (hot reload)
npm run dev
# Production mode
npm run start
```
## createServer
Create a game server instance:
```typescript
import { GameServer } from '@esengine/network-server';
import { createServer } from '@esengine/server'
import { GameRoom } from './rooms/GameRoom.js'
const server = new GameServer({
const server = await createServer({
port: 3000,
roomConfig: {
maxPlayers: 16,
tickRate: 20
onConnect(conn) {
console.log('Client connected:', conn.id)
},
onDisconnect(conn) {
console.log('Client disconnected:', conn.id)
},
})
// Register room type
server.define('game', GameRoom)
// Start server
await server.start()
```
### Configuration Options
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `port` | `number` | `3000` | WebSocket port |
| `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.
### File-based Routing
Create route files in the `httpDir` directory, automatically mapped to HTTP endpoints:
```
src/http/
├── login.ts → POST /api/login
├── register.ts → POST /api/register
├── health.ts → GET /api/health (set method: 'GET')
└── users/
└── [id].ts → POST /api/users/:id (dynamic route)
```
### Define Routes
Use `defineHttp` to define type-safe route handlers:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST', // Default POST, options: GET/PUT/DELETE/PATCH
handler(req, res) {
const { username, password } = req.body
// Validate credentials...
if (!isValid(username, password)) {
res.error(401, 'Invalid credentials')
return
}
// Generate token...
res.json({ token: '...', userId: '...' })
}
});
})
```
### Request Object (HttpRequest)
```typescript
interface HttpRequest {
raw: IncomingMessage // Node.js raw request
method: string // Request method
path: string // Request path
query: Record<string, string> // Query parameters
headers: Record<string, string | string[] | undefined>
body: unknown // Parsed JSON body
ip: string // Client IP
}
```
### Response Object (HttpResponse)
```typescript
interface HttpResponse {
raw: ServerResponse // Node.js raw response
status(code: number): HttpResponse // Set status code (chainable)
header(name: string, value: string): HttpResponse // Set header (chainable)
json(data: unknown): void // Send JSON
text(data: string): void // Send text
error(code: number, message: string): void // Send error
}
```
### Usage Example
```typescript
// Complete login server example
import { createServer, defineHttp } from '@esengine/server'
import { createJwtAuthProvider, withAuth } from '@esengine/server/auth'
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600 * 24,
})
const server = await createServer({
port: 8080,
httpDir: 'src/http',
httpPrefix: '/api',
cors: true,
})
// Wrap with auth (WebSocket connections validate token)
const authServer = withAuth(server, {
provider: jwtProvider,
extractCredentials: (req) => {
const url = new URL(req.url, 'http://localhost')
return url.searchParams.get('token')
},
})
await authServer.start()
// HTTP: http://localhost:8080/api/*
// WebSocket: ws://localhost:8080?token=xxx
```
### Inline Routes
Routes can also be defined directly in configuration (merged with file routes, inline takes priority):
```typescript
const server = await createServer({
port: 8080,
http: {
'/health': {
GET: (req, res) => res.json({ status: 'ok' }),
},
'/webhook': async (req, res) => {
// Accepts all methods
await handleWebhook(req.body)
res.json({ received: true })
},
},
})
```
## Room System
Room is the base class for game rooms, managing players and game state.
### Define a Room
```typescript
import { Room, Player, onMessage } from '@esengine/server'
import type { MsgMove, MsgChat } from '../../shared/index.js'
interface PlayerData {
name: string
x: number
y: number
}
export class GameRoom extends Room<{ players: any[] }, PlayerData> {
// Configuration
maxPlayers = 8
tickRate = 20
autoDispose = true
// Room state
state = {
players: [],
}
// Lifecycle
onCreate() {
console.log(`Room ${this.id} created`)
}
onJoin(player: Player<PlayerData>) {
player.data.name = 'Player_' + player.id.slice(-4)
player.data.x = Math.random() * 800
player.data.y = Math.random() * 600
this.broadcast('Joined', {
playerId: player.id,
playerName: player.data.name,
})
}
onLeave(player: Player<PlayerData>) {
this.broadcast('Left', { playerId: player.id })
}
onTick(dt: number) {
// State synchronization
this.broadcast('Sync', { players: this.state.players })
}
onDispose() {
console.log(`Room ${this.id} disposed`)
}
// Message handlers
@onMessage('Move')
handleMove(data: MsgMove, player: Player<PlayerData>) {
player.data.x = data.x
player.data.y = data.y
}
@onMessage('Chat')
handleChat(data: MsgChat, player: Player<PlayerData>) {
this.broadcast('Chat', {
from: player.data.name,
text: data.text,
})
}
}
```
### Room Configuration
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `maxPlayers` | `number` | `10` | Maximum players |
| `tickRate` | `number` | `20` | Tick rate (Hz) |
| `autoDispose` | `boolean` | `true` | Auto-dispose empty rooms |
### Room API
```typescript
class Room<TState, TPlayerData> {
readonly id: string // Room ID
readonly players: Player[] // All players
readonly playerCount: number // Player count
readonly isLocked: boolean // Lock status
state: TState // Room state
// Broadcast to all players
broadcast<T>(type: string, data: T): void
// Broadcast to all except one
broadcastExcept<T>(type: string, data: T, except: Player): void
// Get player by ID
getPlayer(id: string): Player | undefined
// Kick a player
kick(player: Player, reason?: string): void
// Lock/unlock room
lock(): void
unlock(): void
// Dispose room
dispose(): void
}
```
### Lifecycle Methods
| Method | Trigger | Purpose |
|--------|---------|---------|
| `onCreate()` | Room created | Initialize game state |
| `onJoin(player)` | Player joins | Welcome message, assign position |
| `onLeave(player)` | Player leaves | Cleanup player data |
| `onTick(dt)` | Every frame | Game logic, state sync |
| `onDispose()` | Before disposal | Save data, cleanup resources |
## Player Class
Player represents a connected player in a room.
```typescript
class Player<TData = Record<string, unknown>> {
readonly id: string // Player ID
readonly roomId: string // Room ID
data: TData // Custom data
// Send message to this player
send<T>(type: string, data: T): void
// Leave room
leave(): void
}
```
## @onMessage Decorator
Use decorators to simplify message handling:
```typescript
import { Room, Player, onMessage } from '@esengine/server'
class GameRoom extends Room {
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: Player) {
// Handle movement
}
@onMessage('Attack')
handleAttack(data: { targetId: string }, player: Player) {
// Handle attack
}
}
```
## Protocol Definition
Define shared types in `src/shared/protocol.ts`:
```typescript
// API request/response
export interface JoinRoomReq {
roomType: string
playerName: string
}
export interface JoinRoomRes {
roomId: string
playerId: string
}
// Game messages
export interface MsgMove {
x: number
y: number
}
export interface MsgChat {
text: string
}
// Server broadcasts
export interface BroadcastSync {
players: PlayerState[]
}
export interface PlayerState {
id: string
name: string
x: number
y: number
}
```
## Client Connection
```typescript
import { connect } from '@esengine/rpc/client'
const client = await connect('ws://localhost:3000')
// Join room
const { roomId, playerId } = await client.call('JoinRoom', {
roomType: 'game',
playerName: 'Alice',
})
// Listen for broadcasts
client.onMessage('Sync', (data) => {
console.log('State:', data.players)
})
client.onMessage('Joined', (data) => {
console.log('Player joined:', data.playerName)
})
// Send message
client.send('RoomMessage', {
type: 'Move',
payload: { x: 100, y: 200 },
})
```
## 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();
await server.stop();
```
### Configuration
| Property | Type | Description |
|----------|------|-------------|
| `port` | `number` | WebSocket port |
| `roomConfig.maxPlayers` | `number` | Max players per room |
| `roomConfig.tickRate` | `number` | Sync rate (Hz) |
## Room
### Define ECSRoom
```typescript
class Room {
readonly id: string;
readonly playerCount: number;
readonly isFull: boolean;
import { ECSRoom, Player } from '@esengine/server/ecs';
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
addPlayer(name: string, connection: Connection): IPlayer | null;
removePlayer(clientId: number): void;
getPlayer(clientId: number): IPlayer | undefined;
handleInput(clientId: number, input: IPlayerInput): void;
destroy(): void;
// 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;
}
}
```
## Protocol Types
### ECSRoom API
```typescript
interface MsgSync {
time: number;
entities: IEntityState[];
}
abstract class ECSRoom<TState, TPlayerData> extends Room<TState, TPlayerData> {
protected readonly world: World; // ECS World
protected readonly scene: Scene; // Main scene
interface MsgSpawn {
netId: number;
ownerId: number;
prefab: string;
pos: Vec2;
rot: number;
}
// 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;
interface MsgDespawn {
netId: number;
// 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**: Choose based on game type (20-60 Hz for action games)
2. **Room size control**: Set reasonable `maxPlayers` based on server capacity
3. **State validation**: Server should validate client inputs to prevent cheating
1. **Set Appropriate Tick Rate**
- Turn-based games: 5-10 Hz
- Casual games: 10-20 Hz
- Action games: 20-60 Hz
2. **Use Shared Protocol**
- Define all types in `shared/` directory
- Import from here in both client and server
3. **State Validation**
- Server should validate all client inputs
- Never trust client-sent data
4. **Disconnect Handling**
- Implement reconnection logic
- Use `onLeave` to save player state
5. **Room Lifecycle**
- Use `autoDispose` to clean up empty rooms
- Save important data in `onDispose`

View File

@@ -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:

View 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 });
}
}
```

View 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'
```

View 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();
```

View 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}`);
}
```

View 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 });
}
```

View 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) });
}
```

View 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,
}));
```

View 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
}
```

View File

@@ -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();
```

View 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;
}
}
```

View 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

View File

@@ -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;
}
}
```

View File

@@ -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
}
});
```

View File

@@ -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

View File

@@ -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) - 了解不同类型的系统基类

View File

@@ -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);
```
## 注册执行器
### 自动注册

View 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/) - 依赖注入集成

View 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 })
```

View 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` | 发生错误 |

View 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/) - 查询条件语法

View 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$' } }
]
}
})
```

View 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
}
```

View 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[]>
}
}
```

View File

@@ -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 模式数据操作 |
## 安装

View File

@@ -0,0 +1,283 @@
---
title: "兴趣区域管理 (AOI)"
description: "基于视野范围的网络实体过滤"
---
AOIArea 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);
}
```

View 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
}
}
}
}
```
**示例 2OAuth/第三方认证**
```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}`)
}
}
}
```
**示例 3API 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` 测试认证场景。

View 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),
});
}
```

View File

@@ -147,7 +147,10 @@ service.on('chat', (data) => {
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
- [状态同步](/modules/network/sync/) - 插值、预测和快照
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
- [增量压缩](/modules/network/delta/) - 状态增量同步
- [API 参考](/modules/network/api/) - 完整 API 文档
## 服务令牌
@@ -159,10 +162,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);
```
## 蓝图节点

View File

@@ -0,0 +1,254 @@
---
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);
}
```

View 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} 上被限流`)
}
}
```

View File

@@ -1,164 +1,21 @@
---
title: "服务器端"
description: "GameServer 和 Room 管理"
description: "使用 @esengine/server 构建游戏服务器"
---
## GameServer
## 快速开始
GameServer 是服务器端的核心类,管理 WebSocket 连接和房间。
### 基本用法
```typescript
import { GameServer } from '@esengine/network-server';
const server = new GameServer({
port: 3000,
roomConfig: {
maxPlayers: 16,
tickRate: 20
}
});
// 启动服务器
await server.start();
console.log('Server started on ws://localhost:3000');
// 停止服务器
await server.stop();
```
### 配置选项
| 属性 | 类型 | 描述 |
|------|------|------|
| `port` | `number` | WebSocket 端口 |
| `roomConfig.maxPlayers` | `number` | 房间最大玩家数 |
| `roomConfig.tickRate` | `number` | 同步频率 (Hz) |
### 房间管理
```typescript
// 获取或创建房间
const room = server.getOrCreateRoom('room-id');
// 获取已存在的房间
const existingRoom = server.getRoom('room-id');
// 销毁房间
server.destroyRoom('room-id');
```
## Room
Room 类管理单个游戏房间的玩家和状态。
### API
```typescript
class Room {
readonly id: string;
readonly playerCount: number;
readonly isFull: boolean;
// 添加玩家
addPlayer(name: string, connection: Connection): IPlayer | null;
// 移除玩家
removePlayer(clientId: number): void;
// 获取玩家
getPlayer(clientId: number): IPlayer | undefined;
// 处理输入
handleInput(clientId: number, input: IPlayerInput): void;
// 销毁房间
destroy(): void;
}
```
### 玩家接口
```typescript
interface IPlayer {
clientId: number; // 客户端 ID
name: string; // 玩家名称
connection: Connection; // 连接对象
netId: number; // 网络实体 ID
}
```
## 协议类型
### 消息类型
```typescript
// 状态同步消息
interface MsgSync {
time: number;
entities: IEntityState[];
}
// 实体状态
interface IEntityState {
netId: number;
pos?: Vec2;
rot?: number;
}
// 生成消息
interface MsgSpawn {
netId: number;
ownerId: number;
prefab: string;
pos: Vec2;
rot: number;
}
// 销毁消息
interface MsgDespawn {
netId: number;
}
// 输入消息
interface MsgInput {
input: IPlayerInput;
}
// 玩家输入
interface IPlayerInput {
seq?: number;
moveDir?: Vec2;
actions?: string[];
}
```
### API 类型
```typescript
// 加入请求
interface ReqJoin {
playerName: string;
roomId?: string;
}
// 加入响应
interface ResJoin {
clientId: number;
roomId: string;
playerCount: number;
}
```
## 使用 CLI 创建服务端
推荐使用 ESEngine CLI 快速创建完整的游戏服务端:
使用 CLI 创建新的游戏服务器项目:
```bash
mkdir my-game-server && cd my-game-server
npm init -y
npx @esengine/cli init -p nodejs
# 使用 npm
npm create esengine-server my-game-server
# 使用 pnpm
pnpm create esengine-server my-game-server
# 使用 yarn
yarn create esengine-server my-game-server
```
生成的项目结构:
@@ -166,24 +23,20 @@ npx @esengine/cli init -p nodejs
```
my-game-server/
├── src/
│ ├── index.ts # 入口文件
│ ├── server/
│ │ └── GameServer.ts # 网络服务器配置
── game/
├── Game.ts # ECS 游戏主类
── scenes/
└── MainScene.ts # 主场景
├── components/ # ECS 组件
│ ├── PositionComponent.ts
│ │ └── VelocityComponent.ts
│ └── systems/ # ECS 系统
│ └── MovementSystem.ts
├── tsconfig.json
│ ├── shared/ # 共享协议(客户端服务端通用)
│ ├── protocol.ts # 类型定义
│ │ └── index.ts
── server/ # 服务端代码
├── main.ts # 入口
── rooms/
└── GameRoom.ts # 游戏房间
└── client/ # 客户端示例
└── index.ts
├── package.json
└── README.md
└── tsconfig.json
```
启动服务
启动服务
```bash
# 开发模式(热重载)
@@ -193,15 +46,507 @@ npm run dev
npm run start
```
## createServer
创建游戏服务器实例:
```typescript
import { createServer } from '@esengine/server'
import { GameRoom } from './rooms/GameRoom.js'
const server = await createServer({
port: 3000,
onConnect(conn) {
console.log('Client connected:', conn.id)
},
onDisconnect(conn) {
console.log('Client disconnected:', conn.id)
},
})
// 注册房间类型
server.define('game', GameRoom)
// 启动服务器
await server.start()
```
### 配置选项
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `port` | `number` | `3000` | WebSocket 端口 |
| `tickRate` | `number` | `20` | 全局 Tick 频率 (Hz) |
| `apiDir` | `string` | `'src/api'` | API 处理器目录 |
| `msgDir` | `string` | `'src/msg'` | 消息处理器目录 |
| `httpDir` | `string` | `'src/http'` | HTTP 路由目录 |
| `httpPrefix` | `string` | `'/api'` | HTTP 路由前缀 |
| `cors` | `boolean \| CorsOptions` | - | CORS 配置 |
| `onStart` | `(port) => void` | - | 启动回调 |
| `onConnect` | `(conn) => void` | - | 连接回调 |
| `onDisconnect` | `(conn) => void` | - | 断开回调 |
## HTTP 路由
支持 HTTP API 与 WebSocket 共用端口,适用于登录、注册等场景。
### 文件路由
`httpDir` 目录下创建路由文件,自动映射为 HTTP 端点:
```
src/http/
├── login.ts → POST /api/login
├── register.ts → POST /api/register
├── health.ts → GET /api/health (需设置 method: 'GET')
└── users/
└── [id].ts → POST /api/users/:id (动态路由)
```
### 定义路由
使用 `defineHttp` 定义类型安全的路由处理器:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST', // 默认 POST可选 GET/PUT/DELETE/PATCH
handler(req, res) {
const { username, password } = req.body
// 验证凭证...
if (!isValid(username, password)) {
res.error(401, 'Invalid credentials')
return
}
// 生成 token...
res.json({ token: '...', userId: '...' })
}
})
```
### 请求对象 (HttpRequest)
```typescript
interface HttpRequest {
raw: IncomingMessage // Node.js 原始请求
method: string // 请求方法
path: string // 请求路径
query: Record<string, string> // 查询参数
headers: Record<string, string | string[] | undefined>
body: unknown // 解析后的 JSON 请求体
ip: string // 客户端 IP
}
```
### 响应对象 (HttpResponse)
```typescript
interface HttpResponse {
raw: ServerResponse // Node.js 原始响应
status(code: number): HttpResponse // 设置状态码(链式)
header(name: string, value: string): HttpResponse // 设置头(链式)
json(data: unknown): void // 发送 JSON
text(data: string): void // 发送文本
error(code: number, message: string): void // 发送错误
}
```
### 使用示例
```typescript
// 完整的登录服务器示例
import { createServer, defineHttp } from '@esengine/server'
import { createJwtAuthProvider, withAuth } from '@esengine/server/auth'
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600 * 24,
})
const server = await createServer({
port: 8080,
httpDir: 'src/http',
httpPrefix: '/api',
cors: true,
})
// 包装认证WebSocket 连接验证 token
const authServer = withAuth(server, {
provider: jwtProvider,
extractCredentials: (req) => {
const url = new URL(req.url, 'http://localhost')
return url.searchParams.get('token')
},
})
await authServer.start()
// HTTP: http://localhost:8080/api/*
// WebSocket: ws://localhost:8080?token=xxx
```
### 内联路由
也可以直接在配置中定义路由(与文件路由合并,内联优先):
```typescript
const server = await createServer({
port: 8080,
http: {
'/health': {
GET: (req, res) => res.json({ status: 'ok' }),
},
'/webhook': async (req, res) => {
// 接受所有方法
await handleWebhook(req.body)
res.json({ received: true })
},
},
})
```
## Room 系统
Room 是游戏房间的基类,管理玩家和游戏状态。
### 定义房间
```typescript
import { Room, Player, onMessage } from '@esengine/server'
import type { MsgMove, MsgChat } from '../../shared/index.js'
interface PlayerData {
name: string
x: number
y: number
}
export class GameRoom extends Room<{ players: any[] }, PlayerData> {
// 配置
maxPlayers = 8
tickRate = 20
autoDispose = true
// 房间状态
state = {
players: [],
}
// 生命周期
onCreate() {
console.log(`Room ${this.id} created`)
}
onJoin(player: Player<PlayerData>) {
player.data.name = 'Player_' + player.id.slice(-4)
player.data.x = Math.random() * 800
player.data.y = Math.random() * 600
this.broadcast('Joined', {
playerId: player.id,
playerName: player.data.name,
})
}
onLeave(player: Player<PlayerData>) {
this.broadcast('Left', { playerId: player.id })
}
onTick(dt: number) {
// 状态同步
this.broadcast('Sync', { players: this.state.players })
}
onDispose() {
console.log(`Room ${this.id} disposed`)
}
// 消息处理
@onMessage('Move')
handleMove(data: MsgMove, player: Player<PlayerData>) {
player.data.x = data.x
player.data.y = data.y
}
@onMessage('Chat')
handleChat(data: MsgChat, player: Player<PlayerData>) {
this.broadcast('Chat', {
from: player.data.name,
text: data.text,
})
}
}
```
### Room 配置
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `maxPlayers` | `number` | `10` | 最大玩家数 |
| `tickRate` | `number` | `20` | Tick 频率 (Hz) |
| `autoDispose` | `boolean` | `true` | 空房间自动销毁 |
### Room API
```typescript
class Room<TState, TPlayerData> {
readonly id: string // 房间 ID
readonly players: Player[] // 所有玩家
readonly playerCount: number // 玩家数量
readonly isLocked: boolean // 是否锁定
state: TState // 房间状态
// 广播消息给所有玩家
broadcast<T>(type: string, data: T): void
// 广播消息给除某玩家外的所有人
broadcastExcept<T>(type: string, data: T, except: Player): void
// 获取玩家
getPlayer(id: string): Player | undefined
// 踢出玩家
kick(player: Player, reason?: string): void
// 锁定/解锁房间
lock(): void
unlock(): void
// 销毁房间
dispose(): void
}
```
### 生命周期方法
| 方法 | 触发时机 | 用途 |
|------|----------|------|
| `onCreate()` | 房间创建时 | 初始化游戏状态 |
| `onJoin(player)` | 玩家加入时 | 欢迎消息、分配位置 |
| `onLeave(player)` | 玩家离开时 | 清理玩家数据 |
| `onTick(dt)` | 每帧调用 | 游戏逻辑、状态同步 |
| `onDispose()` | 房间销毁前 | 保存数据、清理资源 |
## Player 类
Player 代表房间中的一个玩家连接。
```typescript
class Player<TData = Record<string, unknown>> {
readonly id: string // 玩家 ID
readonly roomId: string // 所在房间 ID
data: TData // 自定义数据
// 发送消息给此玩家
send<T>(type: string, data: T): void
// 离开房间
leave(): void
}
```
## @onMessage 装饰器
使用装饰器简化消息处理:
```typescript
import { Room, Player, onMessage } from '@esengine/server'
class GameRoom extends Room {
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: Player) {
// 处理移动
}
@onMessage('Attack')
handleAttack(data: { targetId: string }, player: Player) {
// 处理攻击
}
}
```
## 协议定义
`src/shared/protocol.ts` 中定义客户端和服务端共享的类型:
```typescript
// API 请求/响应
export interface JoinRoomReq {
roomType: string
playerName: string
}
export interface JoinRoomRes {
roomId: string
playerId: string
}
// 游戏消息
export interface MsgMove {
x: number
y: number
}
export interface MsgChat {
text: string
}
// 服务端广播
export interface BroadcastSync {
players: PlayerState[]
}
export interface PlayerState {
id: string
name: string
x: number
y: number
}
```
## 客户端连接
```typescript
import { connect } from '@esengine/rpc/client'
const client = await connect('ws://localhost:3000')
// 加入房间
const { roomId, playerId } = await client.call('JoinRoom', {
roomType: 'game',
playerName: 'Alice',
})
// 监听广播
client.onMessage('Sync', (data) => {
console.log('State:', data.players)
})
client.onMessage('Joined', (data) => {
console.log('Player joined:', data.playerName)
})
// 发送消息
client.send('RoomMessage', {
type: 'Move',
payload: { x: 100, y: 200 },
})
```
## ECSRoom
`ECSRoom` 是带有 ECS World 支持的房间基类,适用于需要 ECS 架构的游戏。
### 服务端启动
```typescript
import { Core } from '@esengine/ecs-framework';
import { createServer } from '@esengine/server';
import { GameRoom } from './rooms/GameRoom.js';
// 初始化 Core
Core.create();
// 全局游戏循环
setInterval(() => Core.update(1/60), 16);
// 创建服务器
const server = await createServer({ port: 3000 });
server.define('game', GameRoom);
await server.start();
```
### 定义 ECSRoom
```typescript
import { ECSRoom, Player } from '@esengine/server/ecs';
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;
}
// 定义房间
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; // 主场景
// 场景管理
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;
// 状态同步
protected sendFullState(player: Player): void;
protected broadcastSpawn(entity: Entity, prefabType?: string): void;
protected broadcastDelta(): void;
}
```
### @sync 装饰器
标记需要网络同步的组件字段:
| 类型 | 描述 | 字节数 |
|------|------|--------|
| `"boolean"` | 布尔值 | 1 |
| `"int8"` / `"uint8"` | 8位整数 | 1 |
| `"int16"` / `"uint16"` | 16位整数 | 2 |
| `"int32"` / `"uint32"` | 32位整数 | 4 |
| `"float32"` | 32位浮点 | 4 |
| `"float64"` | 64位浮点 | 8 |
| `"string"` | 字符串 | 变长 |
## 最佳实践
1. **合理设置同步频率**:根据游戏类型选择合适的 `tickRate`
1. **合理设置 Tick 频率**
- 回合制游戏5-10 Hz
- 休闲游戏10-20 Hz
- 动作游戏20-60 Hz
2. **房间大小控制**:根据服务器性能设置合理的 `maxPlayers`
2. **使用共享协议**
-`shared/` 目录定义所有类型
- 客户端和服务端都从这里导入
3. **连接管理**:监听玩家连接/断开事件,处理异常情况
3. **状态验证**
- 服务器应验证客户端输入
- 不信任客户端发送的任何数据
4. **状态验证**:服务器应验证客户端输入,防止作弊
4. **断线处理**
- 实现断线重连逻辑
- 使用 `onLeave` 保存玩家状态
5. **房间生命周期**
- 使用 `autoDispose` 自动清理空房间
-`onDispose` 中保存重要数据

View File

@@ -1,8 +1,176 @@
---
title: "状态同步"
description: "插值、预测和快照缓冲区"
description: "组件同步、插值、预测和快照缓冲区"
---
## @NetworkEntity 装饰器
`@NetworkEntity` 装饰器用于标记需要自动广播生成/销毁的组件。当包含此组件的实体被创建或销毁时ECSRoom 会自动广播相应的消息给所有客户端。
### 基本用法
```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;
}
```
当添加此组件到实体时ECSRoom 会自动广播 spawn 消息:
```typescript
// 服务端
const entity = scene.createEntity('Enemy');
entity.addComponent(new EnemyComponent()); // 自动广播 spawn
// 销毁时自动广播 despawn
entity.destroy(); // 自动广播 despawn
```
### 配置选项
```typescript
@NetworkEntity('Bullet', {
autoSpawn: true, // 自动广播生成(默认 true
autoDespawn: false // 禁用自动广播销毁
})
class BulletComponent extends Component { }
```
| 选项 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `autoSpawn` | `boolean` | `true` | 添加组件时自动广播 spawn |
| `autoDespawn` | `boolean` | `true` | 销毁实体时自动广播 despawn |
### 初始化顺序
使用 `@NetworkEntity` 时,应在添加组件**之前**初始化数据:
```typescript
// ✅ 正确:先初始化,再添加
const comp = new PlayerComponent();
comp.playerId = player.id;
comp.x = 100;
comp.y = 200;
entity.addComponent(comp); // spawn 时数据已正确
// ❌ 错误:先添加,再初始化
const comp = entity.addComponent(new PlayerComponent());
comp.playerId = player.id; // spawn 时数据是默认值
```
### 简化 GameRoom
使用 `@NetworkEntity`GameRoom 变得更加简洁:
```typescript
// 无需手动回调
class GameRoom extends ECSRoom {
private setupSystems(): void {
// 敌人生成系统(自动广播 spawn
this.addSystem(new EnemySpawnSystem());
// 敌人 AI 系统
const enemyAI = new EnemyAISystem();
enemyAI.onDeath((enemy) => {
enemy.destroy(); // 自动广播 despawn
});
this.addSystem(enemyAI);
}
}
```
### ECSRoom 配置
可以在 ECSRoom 中禁用自动网络实体功能:
```typescript
class GameRoom extends ECSRoom {
constructor() {
super({
enableAutoNetworkEntity: false // 禁用自动广播
});
}
}
```
## 组件同步系统
基于 `@sync` 装饰器的 ECS 组件状态同步。
### 定义同步组件
```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;
// 不带 @sync 的字段不会同步
localData: any;
}
```
### 服务端编码
```typescript
import { ComponentSyncSystem } from '@esengine/network';
const syncSystem = new ComponentSyncSystem({}, true);
scene.addSystem(syncSystem);
// 编码所有实体(首次连接)
const fullData = syncSystem.encodeAllEntities(true);
sendToClient(fullData);
// 编码增量(只发送变更)
const deltaData = syncSystem.encodeDelta();
if (deltaData) {
broadcast(deltaData);
}
```
### 客户端解码
```typescript
const syncSystem = new ComponentSyncSystem();
scene.addSystem(syncSystem);
// 注册组件类型
syncSystem.registerComponent(PlayerComponent);
// 监听同步事件
syncSystem.addSyncListener((event) => {
if (event.type === 'entitySpawned') {
console.log('New entity:', event.entityId);
}
});
// 应用状态
syncSystem.applySnapshot(data);
```
### 同步类型
| 类型 | 描述 | 字节数 |
|------|------|--------|
| `"boolean"` | 布尔值 | 1 |
| `"int8"` / `"uint8"` | 8位整数 | 1 |
| `"int16"` / `"uint16"` | 16位整数 | 2 |
| `"int32"` / `"uint32"` | 32位整数 | 4 |
| `"float32"` | 32位浮点 | 4 |
| `"float64"` | 64位浮点 | 8 |
| `"string"` | 字符串 | 变长 |
## 快照缓冲区
用于存储服务器状态快照并进行插值:

View File

@@ -0,0 +1,251 @@
---
title: "RPC 客户端 API"
description: "RpcClient 连接 RPC 服务器"
---
`RpcClient` 类提供类型安全的 WebSocket 客户端,用于 RPC 通信。
## 基础用法
```typescript
import { RpcClient } from '@esengine/rpc/client';
import { gameProtocol } from './protocol';
const client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
onConnect: () => console.log('已连接'),
onDisconnect: (reason) => console.log('已断开:', reason),
onError: (error) => console.error('错误:', error),
});
await client.connect();
```
## 构造选项
```typescript
interface RpcClientOptions {
// 序列化编解码器(默认: json()
codec?: Codec;
// API 调用超时,毫秒(默认: 30000
timeout?: number;
// 断开后自动重连(默认: true
autoReconnect?: boolean;
// 重连间隔,毫秒(默认: 3000
reconnectInterval?: number;
// 自定义 WebSocket 工厂(用于微信小游戏等)
webSocketFactory?: (url: string) => WebSocketAdapter;
// 回调函数
onConnect?: () => void;
onDisconnect?: (reason?: string) => void;
onError?: (error: Error) => void;
}
```
## 连接管理
### 连接
```typescript
// connect 返回 Promise
await client.connect();
// 或链式调用
client.connect().then(() => {
console.log('已就绪');
});
```
### 检查状态
```typescript
// 连接状态: 'connecting' | 'open' | 'closing' | 'closed'
console.log(client.status);
// 便捷布尔值
if (client.isConnected) {
// 可以安全调用 API
}
```
### 断开连接
```typescript
// 手动断开(禁用自动重连)
client.disconnect();
```
## 调用 API
API 使用请求-响应模式,完全类型安全:
```typescript
// 定义协议
const protocol = rpc.define({
api: {
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
getProfile: rpc.api<{ userId: string }, { name: string; level: number }>(),
},
msg: {}
});
// 调用时类型自动推断
const { userId, token } = await client.call('login', { username: 'player1' });
const profile = await client.call('getProfile', { userId });
```
### 错误处理
```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('请求超时');
break;
case ErrorCode.CONNECTION_CLOSED:
console.log('未连接');
break;
case ErrorCode.NOT_FOUND:
console.log('API 不存在');
break;
default:
console.log('服务器错误:', error.message);
}
}
}
```
## 发送消息
消息是发送即忘模式(无响应):
```typescript
// 向服务器发送消息
client.send('playerMove', { x: 100, y: 200 });
client.send('chat', { text: 'Hello!' });
```
## 接收消息
监听服务器推送的消息:
```typescript
// 订阅消息
client.on('newMessage', (data) => {
console.log(`${data.from}: ${data.text}`);
});
client.on('playerJoined', (data) => {
console.log(`${data.name} 加入游戏`);
});
// 取消特定处理器
const handler = (data) => console.log(data);
client.on('event', handler);
client.off('event', handler);
// 取消某消息的所有处理器
client.off('event');
// 一次性监听
client.once('gameStart', (data) => {
console.log('游戏开始!');
});
```
## 自定义 WebSocket平台适配器
用于微信小游戏等平台:
```typescript
// 微信小游戏适配器
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,
});
```
## 便捷函数
```typescript
import { connect } from '@esengine/rpc/client';
// 一次调用完成连接
const client = await connect(protocol, 'ws://localhost:3000', {
onConnect: () => console.log('已连接'),
});
const result = await client.call('join', { name: 'Alice' });
```
## 完整示例
```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 错误:', e),
});
// 设置消息处理器
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('登录为', userId);
}
private onDisconnected() {
console.log('已断开,将自动重连...');
}
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 });
}
}
```

View File

@@ -0,0 +1,160 @@
---
title: "RPC 编解码器"
description: "RPC 通信的序列化编解码器"
---
编解码器负责 RPC 消息的序列化和反序列化。内置两种编解码器。
## 内置编解码器
### JSON 编解码器(默认)
人类可读,兼容性好:
```typescript
import { json } from '@esengine/rpc/codec';
const client = new RpcClient(protocol, url, {
codec: json(),
});
```
**优点:**
- 人类可读(方便调试)
- 无额外依赖
- 浏览器普遍支持
**缺点:**
- 消息体积较大
- 序列化速度较慢
### MessagePack 编解码器
二进制格式,更高效:
```typescript
import { msgpack } from '@esengine/rpc/codec';
const client = new RpcClient(protocol, url, {
codec: msgpack(),
});
```
**优点:**
- 消息体积更小约小30-50%
- 序列化速度更快
- 原生支持二进制数据
**缺点:**
- 不可读
- 需要 msgpack 库
## 编解码器接口
```typescript
interface Codec {
/**
* 将数据包编码为传输格式
*/
encode(packet: unknown): string | Uint8Array;
/**
* 将传输格式解码为数据包
*/
decode(data: string | Uint8Array): unknown;
}
```
## 自定义编解码器
为特殊需求创建自己的编解码器:
```typescript
import type { Codec } from '@esengine/rpc/codec';
// 示例:压缩 JSON 编解码器
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));
},
});
// 使用自定义编解码器
const client = new RpcClient(protocol, url, {
codec: compressedJson(),
});
```
## Protocol Buffers 编解码器
对于生产级游戏,考虑使用 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);
},
});
```
## 客户端与服务器匹配
客户端和服务器必须使用相同的编解码器:
```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: { /* ... */ },
});
```
## 性能对比
| 编解码器 | 编码速度 | 解码速度 | 体积 |
|----------|----------|----------|------|
| JSON | 中等 | 中等 | 大 |
| MessagePack | 快 | 快 | 小 |
| Protobuf | 最快 | 最快 | 最小 |
对于大多数游戏MessagePack 提供了良好的平衡。对于高性能需求使用 Protobuf。
## 文本编码工具
为自定义编解码器提供工具函数:
```typescript
import { textEncode, textDecode } from '@esengine/rpc/codec';
// 在所有平台上工作浏览器、Node.js、微信
const bytes = textEncode('Hello'); // Uint8Array
const text = textDecode(bytes); // 'Hello'
```

View File

@@ -0,0 +1,350 @@
---
title: "RPC 服务器 API"
description: "RpcServer 处理客户端连接"
---
`serve` 函数创建类型安全的 RPC 服务器,处理客户端连接和 API 调用。
## 基础用法
```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}${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(`服务器启动于端口 ${port}`),
});
await server.start();
```
## 服务器选项
```typescript
interface ServeOptions<P, TConnData> {
// 必需
port: number;
api: ApiHandlers<P, TConnData>;
// 可选
msg?: MsgHandlers<P, TConnData>;
codec?: Codec;
createConnData?: () => TConnData;
// 回调
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 处理器
每个 API 处理器接收输入和连接上下文:
```typescript
const server = serve(protocol, {
port: 3000,
api: {
// 同步处理器
ping: (input, conn) => {
return { pong: true, time: Date.now() };
},
// 异步处理器
getProfile: async (input, conn) => {
const user = await database.findUser(input.userId);
return { name: user.name, level: user.level };
},
// 访问连接上下文
getMyInfo: (input, conn) => {
return {
connectionId: conn.id,
ip: conn.ip,
data: conn.data,
};
},
},
});
```
### 抛出错误
```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, '用户不存在');
}
if (!await verifyPassword(input.password, user.hash)) {
throw new RpcError('AUTH_FAILED', '密码错误');
}
return { userId: user.id, token: generateToken() };
},
},
});
```
## 消息处理器
处理客户端发送的消息:
```typescript
const server = serve(protocol, {
port: 3000,
api: { /* ... */ },
msg: {
playerMove: (data, conn) => {
// 更新玩家位置
const player = players.get(conn.id);
if (player) {
player.x = data.x;
player.y = data.y;
}
// 广播给其他玩家
server.broadcast('playerMoved', {
playerId: conn.id,
x: data.x,
y: data.y,
}, { exclude: conn });
},
chat: async (data, conn) => {
// 异步处理器也可以
await logChat(conn.id, data.text);
},
},
});
```
## 连接上下文
`Connection` 对象提供客户端信息:
```typescript
interface Connection<TData> {
// 唯一连接 ID
readonly id: string;
// 客户端 IP 地址
readonly ip: string;
// 连接状态
readonly isOpen: boolean;
// 附加到此连接的自定义数据
data: TData;
// 关闭连接
close(reason?: string): void;
}
```
### 自定义连接数据
存储每连接的状态:
```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) => {
// 在连接上存储数据
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} 离开房间 ${conn.data.room}`);
},
});
```
## 发送消息
### 发送给单个连接
```typescript
server.send(conn, 'notification', { text: 'Hello!' });
```
### 广播给所有人
```typescript
// 给所有人
server.broadcast('announcement', { text: '服务器将在5分钟后重启' });
// 排除发送者
server.broadcast('playerMoved', { id: conn.id, x, y }, { exclude: conn });
// 排除多个
server.broadcast('gameEvent', data, { exclude: [conn1, conn2] });
```
### 发送给特定群组
```typescript
// 自定义广播
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: '房间内消息!' });
```
## 服务器生命周期
```typescript
const server = serve(protocol, { /* ... */ });
// 启动
await server.start();
console.log('服务器运行中');
// 访问连接列表
console.log(`${server.connections.length} 个客户端已连接`);
// 停止(关闭所有连接)
await server.stop();
console.log('服务器已停止');
```
## 完整示例
```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;
// 通知其他玩家
server.broadcast('playerJoined', {
id: player.id,
name: player.name,
}, { exclude: conn });
// 发送当前状态给新玩家
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(`客户端已连接: ${conn.id} 来自 ${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} 已断开`);
}
},
onError: (error, conn) => {
console.error(`来自 ${conn?.id} 的错误:`, error);
},
onStart: (port) => {
console.log(`游戏服务器运行于 ws://localhost:${port}`);
},
});
server.start();
```

View File

@@ -0,0 +1,261 @@
---
title: "核心概念"
description: "事务系统的核心概念事务上下文、事务管理器、Saga 模式"
---
## 事务状态
事务有以下几种状态:
```typescript
type TransactionState =
| 'pending' // 等待执行
| 'executing' // 执行中
| 'committed' // 已提交
| 'rolledback' // 已回滚
| 'failed' // 失败
```
## TransactionContext
事务上下文封装了事务的状态、操作和执行逻辑。
### 创建事务
```typescript
import { TransactionManager } from '@esengine/transaction';
const manager = new TransactionManager();
// 方式 1使用 begin() 手动管理
const tx = manager.begin({ timeout: 5000 });
tx.addOperation(op1);
tx.addOperation(op2);
const result = await tx.execute();
// 方式 2使用 run() 自动管理
const result = await manager.run((tx) => {
tx.addOperation(op1);
tx.addOperation(op2);
});
```
### 链式添加操作
```typescript
const result = await manager.run((tx) => {
tx.addOperation(new CurrencyOperation({ ... }))
.addOperation(new InventoryOperation({ ... }))
.addOperation(new InventoryOperation({ ... }));
});
```
### 上下文数据
操作之间可以通过上下文共享数据:
```typescript
class CustomOperation extends BaseOperation<MyData, MyResult> {
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
// 读取之前操作设置的数据
const previousResult = ctx.get<number>('previousValue');
// 设置数据供后续操作使用
ctx.set('myResult', { value: 123 });
return this.success({ ... });
}
}
```
## TransactionManager
事务管理器负责创建、执行和恢复事务。
### 配置选项
```typescript
interface TransactionManagerConfig {
storage?: ITransactionStorage; // 存储实例
defaultTimeout?: number; // 默认超时(毫秒)
serverId?: string; // 服务器 ID分布式用
autoRecover?: boolean; // 自动恢复未完成事务
}
const manager = new TransactionManager({
storage: new RedisStorage({ client: redis }),
defaultTimeout: 10000,
serverId: 'server-1',
autoRecover: true,
});
```
### 分布式锁
```typescript
// 获取锁
const token = await manager.acquireLock('player:123:inventory', 10000);
if (token) {
try {
// 执行操作
await doSomething();
} finally {
// 释放锁
await manager.releaseLock('player:123:inventory', token);
}
}
// 或使用 withLock 简化
await manager.withLock('player:123:inventory', async () => {
await doSomething();
}, 10000);
```
### 事务恢复
服务器重启时恢复未完成的事务:
```typescript
const manager = new TransactionManager({
storage: new RedisStorage({ client: redis }),
serverId: 'server-1',
});
// 恢复未完成的事务
const recoveredCount = await manager.recover();
console.log(`Recovered ${recoveredCount} transactions`);
```
## Saga 模式
事务系统采用 Saga 模式,每个操作必须实现 `execute``compensate` 方法:
```typescript
interface ITransactionOperation<TData, TResult> {
readonly name: string;
readonly data: TData;
// 验证前置条件
validate(ctx: ITransactionContext): Promise<boolean>;
// 正向执行
execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>;
// 补偿操作(回滚)
compensate(ctx: ITransactionContext): Promise<void>;
}
```
### 执行流程
```
开始事务
┌─────────────────────┐
│ validate(op1) │──失败──► 返回失败
└─────────────────────┘
│成功
┌─────────────────────┐
│ execute(op1) │──失败──┐
└─────────────────────┘ │
│成功 │
▼ │
┌─────────────────────┐ │
│ validate(op2) │──失败──┤
└─────────────────────┘ │
│成功 │
▼ │
┌─────────────────────┐ │
│ execute(op2) │──失败──┤
└─────────────────────┘ │
│成功 ▼
▼ ┌─────────────────────┐
提交事务 │ compensate(op1) │
└─────────────────────┘
返回失败(已回滚)
```
### 自定义操作
```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> {
// 验证物品存在且可升级
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) {
// 从存储获取物品
}
private async saveItem(ctx: ITransactionContext, item: any) {
// 保存物品到存储
}
}
```
## 事务结果
```typescript
interface TransactionResult<T = unknown> {
success: boolean; // 是否成功
transactionId: string; // 事务 ID
results: OperationResult[]; // 各操作结果
data?: T; // 最终数据
error?: string; // 错误信息
duration: number; // 执行时间(毫秒)
}
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}`);
}
```

View File

@@ -0,0 +1,355 @@
---
title: "分布式事务"
description: "Saga 编排器和跨服务器事务支持"
---
## Saga 编排器
`SagaOrchestrator` 用于编排跨服务器的分布式事务。
### 基本用法
```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) => {
// 调用游戏服务器 API 扣除货币
const response = await gameServerApi.deductCurrency(data);
return { success: response.ok };
},
compensate: async (data) => {
// 调用游戏服务器 API 恢复货币
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);
}
```
### 配置选项
```typescript
interface SagaOrchestratorConfig {
storage?: ITransactionStorage; // 存储实例
timeout?: number; // 超时时间(毫秒)
serverId?: string; // 编排器服务器 ID
}
```
### Saga 步骤
```typescript
interface SagaStep<T = unknown> {
name: string; // 步骤名称
serverId?: string; // 目标服务器 ID
data: T; // 步骤数据
execute: (data: T) => Promise<OperationResult>; // 执行函数
compensate: (data: T) => Promise<void>; // 补偿函数
}
```
### Saga 结果
```typescript
interface SagaResult {
success: boolean; // 是否成功
sagaId: string; // Saga ID
completedSteps: string[]; // 已完成的步骤
failedStep?: string; // 失败的步骤
error?: string; // 错误信息
duration: number; // 执行时间(毫秒)
}
```
## 执行流程
```
开始 Saga
┌─────────────────────┐
│ Step 1: execute │──失败──┐
└─────────────────────┘ │
│成功 │
▼ │
┌─────────────────────┐ │
│ Step 2: execute │──失败──┤
└─────────────────────┘ │
│成功 │
▼ │
┌─────────────────────┐ │
│ Step 3: execute │──失败──┤
└─────────────────────┘ │
│成功 ▼
▼ ┌─────────────────────┐
Saga 完成 │ Step 2: compensate │
└─────────────────────┘
┌─────────────────────┐
│ Step 1: compensate │
└─────────────────────┘
Saga 失败(已补偿)
```
## Saga 日志
编排器会记录详细的执行日志:
```typescript
interface SagaLog {
id: string; // Saga ID
state: SagaLogState; // 状态
steps: SagaStepLog[]; // 步骤日志
createdAt: number; // 创建时间
updatedAt: number; // 更新时间
metadata?: Record<string, unknown>;
}
type SagaLogState =
| 'pending' // 等待执行
| 'running' // 执行中
| 'completed' // 已完成
| 'compensating' // 补偿中
| 'compensated' // 已补偿
| 'failed' // 失败
interface SagaStepLog {
name: string; // 步骤名称
serverId?: string; // 服务器 ID
state: SagaStepState; // 状态
startedAt?: number; // 开始时间
completedAt?: number; // 完成时间
error?: string; // 错误信息
}
type SagaStepState =
| 'pending' // 等待执行
| 'executing' // 执行中
| 'completed' // 已完成
| 'compensating' // 补偿中
| 'compensated' // 已补偿
| 'failed' // 失败
```
### 查询 Saga 日志
```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}`);
}
}
```
## 跨服务器事务示例
### 场景:跨服购买
玩家在游戏服务器购买物品,货币在账户服务器,物品在背包服务器。
```typescript
const orchestrator = new SagaOrchestrator({
storage: redisStorage,
serverId: 'purchase-orchestrator',
});
async function crossServerPurchase(
playerId: string,
itemId: string,
price: number
): Promise<SagaResult> {
return orchestrator.execute([
// 步骤 1在账户服务器扣款
{
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);
},
},
// 步骤 2在背包服务器添加物品
{
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);
},
},
// 步骤 3记录购买日志
{
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);
},
},
]);
}
```
### 场景:跨服交易
两个玩家在不同服务器上进行交易。
```typescript
async function crossServerTrade(
playerA: { id: string; server: string; items: string[] },
playerB: { id: string; server: string; items: string[] }
): Promise<SagaResult> {
const steps: SagaStep[] = [];
// 移除 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);
},
});
}
// 添加物品到 B
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);
},
});
}
// 类似地处理 B 的物品...
return orchestrator.execute(steps);
}
```
## 恢复未完成的 Saga
服务器重启后恢复未完成的 Saga
```typescript
const orchestrator = new SagaOrchestrator({
storage: redisStorage,
serverId: 'my-orchestrator',
});
// 恢复未完成的 Saga会执行补偿
const recoveredCount = await orchestrator.recover();
console.log(`Recovered ${recoveredCount} sagas`);
```
## 最佳实践
### 1. 幂等性
确保所有操作都是幂等的:
```typescript
{
execute: async (data) => {
// 使用唯一 ID 确保幂等
const result = await service.process(data.requestId, data);
return { success: result.ok };
},
compensate: async (data) => {
// 补偿也要幂等
await service.rollback(data.requestId);
},
}
```
### 2. 超时处理
设置合适的超时时间:
```typescript
const orchestrator = new SagaOrchestrator({
timeout: 60000, // 跨服务器操作需要更长超时
});
```
### 3. 监控和告警
记录 Saga 执行结果:
```typescript
const result = await orchestrator.execute(steps);
if (!result.success) {
// 发送告警
alertService.send({
type: 'saga_failed',
sagaId: result.sagaId,
failedStep: result.failedStep,
error: result.error,
});
// 记录详细日志
const log = await orchestrator.getSagaLog(result.sagaId);
logger.error('Saga failed', { log });
}
```

View File

@@ -0,0 +1,238 @@
---
title: "事务系统 (Transaction)"
description: "游戏事务处理系统,支持商店购买、玩家交易、分布式事务"
---
`@esengine/transaction` 提供完整的游戏事务处理能力,基于 Saga 模式实现,支持商店购买、玩家交易、多步骤任务等场景,并提供 Redis/MongoDB 分布式事务支持。
## 概述
事务系统解决游戏中常见的数据一致性问题:
| 场景 | 问题 | 解决方案 |
|------|------|----------|
| 商店购买 | 扣款成功但物品未发放 | 原子事务,失败自动回滚 |
| 玩家交易 | 一方物品转移另一方未收到 | Saga 补偿机制 |
| 跨服操作 | 多服务器数据不一致 | 分布式锁 + 事务日志 |
## 安装
```bash
npm install @esengine/transaction
```
可选依赖(根据存储需求安装):
```bash
npm install ioredis # Redis 存储
npm install mongodb # MongoDB 存储
```
## 架构
```
┌─────────────────────────────────────────────────────────────────┐
│ Transaction Layer │
├─────────────────────────────────────────────────────────────────┤
│ TransactionManager - 事务管理器,协调事务生命周期 │
│ TransactionContext - 事务上下文,封装操作和状态 │
│ SagaOrchestrator - 分布式 Saga 编排器 │
├─────────────────────────────────────────────────────────────────┤
│ Storage Layer │
├─────────────────────────────────────────────────────────────────┤
│ MemoryStorage - 内存存储(开发/测试) │
│ RedisStorage - Redis分布式锁 + 缓存) │
│ MongoStorage - MongoDB持久化日志
├─────────────────────────────────────────────────────────────────┤
│ Operation Layer │
├─────────────────────────────────────────────────────────────────┤
│ CurrencyOperation - 货币操作 │
│ InventoryOperation - 背包操作 │
│ TradeOperation - 交易操作 │
└─────────────────────────────────────────────────────────────────┘
```
## 快速开始
### 基础用法
```typescript
import {
TransactionManager,
MemoryStorage,
CurrencyOperation,
InventoryOperation,
} from '@esengine/transaction';
// 创建事务管理器
const manager = new TransactionManager({
storage: new MemoryStorage(),
defaultTimeout: 10000,
});
// 执行事务
const result = await manager.run((tx) => {
// 扣除金币
tx.addOperation(new CurrencyOperation({
type: 'deduct',
playerId: 'player1',
currency: 'gold',
amount: 100,
}));
// 添加物品
tx.addOperation(new InventoryOperation({
type: 'add',
playerId: 'player1',
itemId: 'sword_001',
quantity: 1,
}));
});
if (result.success) {
console.log('购买成功!');
} else {
console.log('购买失败:', result.error);
}
```
### 玩家交易
```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 });
```
### 使用 Redis 存储
```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 });
```
### 使用 MongoDB 存储
```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 集成
```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 });
}
}
}
```
## 文档导航
- [核心概念](/modules/transaction/core/) - 事务上下文、管理器、Saga 模式
- [存储层](/modules/transaction/storage/) - MemoryStorage、RedisStorage、MongoStorage
- [操作类](/modules/transaction/operations/) - 货币、背包、交易操作
- [分布式事务](/modules/transaction/distributed/) - Saga 编排器、跨服务器事务
- [API 参考](/modules/transaction/api/) - 完整 API 文档
## 服务令牌
用于依赖注入:
```typescript
import {
TransactionManagerToken,
TransactionStorageToken,
} from '@esengine/transaction';
const manager = services.get(TransactionManagerToken);
```
## 最佳实践
### 1. 操作粒度
```typescript
// ✅ 好:细粒度操作,便于回滚
tx.addOperation(new CurrencyOperation({ type: 'deduct', ... }));
tx.addOperation(new InventoryOperation({ type: 'add', ... }));
// ❌ 差:粗粒度操作,难以部分回滚
tx.addOperation(new ComplexPurchaseOperation({ ... }));
```
### 2. 超时设置
```typescript
// 简单操作:短超时
await manager.run(tx => { ... }, { timeout: 5000 });
// 复杂交易:长超时
await manager.run(tx => { ... }, { timeout: 30000 });
// 跨服务器:更长超时
await manager.run(tx => { ... }, { timeout: 60000, distributed: true });
```
### 3. 错误处理
```typescript
const result = await manager.run((tx) => { ... });
if (!result.success) {
// 记录日志
logger.error('Transaction failed', {
transactionId: result.transactionId,
error: result.error,
duration: result.duration,
});
// 通知用户
player.send('error', { message: getErrorMessage(result.error) });
}
```

View File

@@ -0,0 +1,313 @@
---
title: "操作类"
description: "内置的事务操作:货币、背包、交易"
---
## BaseOperation
所有操作类的基类,提供通用的实现模板。
```typescript
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
class MyOperation extends BaseOperation<MyData, MyResult> {
readonly name = 'myOperation';
async validate(ctx: ITransactionContext): Promise<boolean> {
// 验证前置条件
return true;
}
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
// 执行操作
return this.success({ result: 'ok' });
// 或
return this.failure('Something went wrong', 'ERROR_CODE');
}
async compensate(ctx: ITransactionContext): Promise<void> {
// 回滚操作
}
}
```
## CurrencyOperation
处理货币的增加和扣除。
### 扣除货币
```typescript
import { CurrencyOperation } from '@esengine/transaction';
tx.addOperation(new CurrencyOperation({
type: 'deduct',
playerId: 'player1',
currency: 'gold',
amount: 100,
reason: 'purchase_item',
}));
```
### 增加货币
```typescript
tx.addOperation(new CurrencyOperation({
type: 'add',
playerId: 'player1',
currency: 'diamond',
amount: 50,
reason: 'daily_reward',
}));
```
### 操作数据
```typescript
interface CurrencyOperationData {
type: 'add' | 'deduct'; // 操作类型
playerId: string; // 玩家 ID
currency: string; // 货币类型
amount: number; // 数量
reason?: string; // 原因/来源
}
```
### 操作结果
```typescript
interface CurrencyOperationResult {
beforeBalance: number; // 操作前余额
afterBalance: number; // 操作后余额
}
```
### 自定义数据提供者
```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> {
// 从数据库获取余额
return await db.getCurrency(playerId, currency);
}
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
// 保存到数据库
await db.setCurrency(playerId, currency, amount);
}
}
// 使用自定义提供者
const op = new CurrencyOperation({ ... });
op.setProvider(new MyCurrencyProvider());
tx.addOperation(op);
```
## InventoryOperation
处理物品的添加、移除和更新。
### 添加物品
```typescript
import { InventoryOperation } from '@esengine/transaction';
tx.addOperation(new InventoryOperation({
type: 'add',
playerId: 'player1',
itemId: 'sword_001',
quantity: 1,
properties: { enchant: 'fire' },
}));
```
### 移除物品
```typescript
tx.addOperation(new InventoryOperation({
type: 'remove',
playerId: 'player1',
itemId: 'potion_hp',
quantity: 5,
}));
```
### 更新物品
```typescript
tx.addOperation(new InventoryOperation({
type: 'update',
playerId: 'player1',
itemId: 'sword_001',
quantity: 1, // 可选,不传则保持原数量
properties: { enchant: 'lightning', level: 5 },
}));
```
### 操作数据
```typescript
interface InventoryOperationData {
type: 'add' | 'remove' | 'update'; // 操作类型
playerId: string; // 玩家 ID
itemId: string; // 物品 ID
quantity: number; // 数量
properties?: Record<string, unknown>; // 物品属性
reason?: string; // 原因/来源
}
```
### 操作结果
```typescript
interface InventoryOperationResult {
beforeItem?: ItemData; // 操作前物品
afterItem?: ItemData; // 操作后物品
}
interface ItemData {
itemId: string;
quantity: number;
properties?: Record<string, unknown>;
}
```
### 自定义数据提供者
```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
处理玩家之间的物品和货币交换。
### 基本用法
```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',
}));
```
### 操作数据
```typescript
interface TradeOperationData {
tradeId: string; // 交易 ID
partyA: TradeParty; // 交易发起方
partyB: TradeParty; // 交易接收方
reason?: string; // 原因/备注
}
interface TradeParty {
playerId: string; // 玩家 ID
items?: TradeItem[]; // 给出的物品
currencies?: TradeCurrency[]; // 给出的货币
}
interface TradeItem {
itemId: string;
quantity: number;
}
interface TradeCurrency {
currency: string;
amount: number;
}
```
### 执行流程
TradeOperation 内部会生成以下子操作序列:
```
1. 移除 partyA 的物品
2. 添加 partyB 的物品(来自 partyA
3. 扣除 partyA 的货币
4. 增加 partyB 的货币(来自 partyA
5. 移除 partyB 的物品
6. 添加 partyA 的物品(来自 partyB
7. 扣除 partyB 的货币
8. 增加 partyA 的货币(来自 partyB
```
任何一步失败都会回滚之前的所有操作。
### 使用自定义提供者
```typescript
const op = new TradeOperation({ ... });
op.setProvider({
currencyProvider: new MyCurrencyProvider(),
inventoryProvider: new MyInventoryProvider(),
});
tx.addOperation(op);
```
## 创建工厂函数
每个操作类都提供工厂函数:
```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,
}));
```

View File

@@ -0,0 +1,238 @@
---
title: "存储层"
description: "事务存储接口和实现MemoryStorage、RedisStorage、MongoStorage"
---
## 存储接口
所有存储实现都需要实现 `ITransactionStorage` 接口:
```typescript
interface ITransactionStorage {
// 生命周期
close?(): Promise<void>;
// 分布式锁
acquireLock(key: string, ttl: number): Promise<string | null>;
releaseLock(key: string, token: string): Promise<boolean>;
// 事务日志
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>;
// 数据操作
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
delete(key: string): Promise<boolean>;
}
```
## MemoryStorage
内存存储,适用于开发和测试环境。
```typescript
import { MemoryStorage } from '@esengine/transaction';
const storage = new MemoryStorage({
maxTransactions: 1000, // 最大事务日志数量
});
const manager = new TransactionManager({ storage });
```
### 特点
- ✅ 无需外部依赖
- ✅ 快速,适合开发调试
- ❌ 数据仅保存在内存中
- ❌ 不支持真正的分布式锁
- ❌ 服务重启后数据丢失
### 测试辅助
```typescript
// 清空所有数据
storage.clear();
// 获取事务数量
console.log(storage.transactionCount);
```
## RedisStorage
Redis 存储,适用于生产环境的分布式系统。使用工厂模式实现惰性连接。
```typescript
import Redis from 'ioredis';
import { RedisStorage } from '@esengine/transaction';
// 工厂模式:惰性连接,首次操作时才创建连接
const storage = new RedisStorage({
factory: () => new Redis('redis://localhost:6379'),
prefix: 'tx:', // 键前缀
transactionTTL: 86400, // 事务日志过期时间(秒)
});
const manager = new TransactionManager({ storage });
// 使用后关闭连接
await storage.close();
// 或使用 await using 自动关闭 (TypeScript 5.2+)
await using storage = new RedisStorage({
factory: () => new Redis('redis://localhost:6379')
});
// 作用域结束时自动关闭
```
### 特点
- ✅ 高性能分布式锁
- ✅ 快速读写
- ✅ 支持 TTL 自动过期
- ✅ 适合高并发场景
- ❌ 需要 Redis 服务器
### 分布式锁实现
使用 Redis `SET NX EX` 实现分布式锁:
```typescript
// 获取锁(原子操作)
SET tx:lock:player:123 <token> NX EX 10
// 释放锁Lua 脚本保证原子性)
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
```
### 键结构
```
tx:lock:{key} - 分布式锁
tx:tx:{id} - 事务日志
tx:server:{id}:txs - 服务器事务索引
tx:data:{key} - 业务数据
```
## MongoStorage
MongoDB 存储,适用于需要持久化和复杂查询的场景。使用 `@esengine/database-drivers` 的共享连接。
```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, {
transactionCollection: 'transactions', // 事务日志集合(可选)
dataCollection: 'transaction_data', // 业务数据集合(可选)
lockCollection: 'transaction_locks', // 锁集合(可选)
});
// 创建索引(首次运行时执行)
await storage.ensureIndexes();
const manager = new TransactionManager({ storage });
// 关闭存储(不会关闭共享连接)
await storage.close();
// 共享连接可继续用于其他模块
const userRepo = new UserRepository(mongo); // @esengine/database
// 最后关闭共享连接
await mongo.disconnect();
```
### 特点
- ✅ 持久化存储
- ✅ 支持复杂查询
- ✅ 事务日志可追溯
- ✅ 适合需要审计的场景
- ❌ 相比 Redis 性能略低
- ❌ 需要 MongoDB 服务器
### 索引结构
```javascript
// transactions 集合
{ state: 1 }
{ 'metadata.serverId': 1 }
{ createdAt: 1 }
// transaction_locks 集合
{ expireAt: 1 } // TTL 索引
// transaction_data 集合
{ expireAt: 1 } // TTL 索引
```
### 分布式锁实现
使用 MongoDB 唯一索引实现分布式锁:
```typescript
// 获取锁
db.transaction_locks.insertOne({
_id: 'player:123',
token: '<token>',
expireAt: new Date(Date.now() + 10000)
});
// 如果键已存在,检查是否过期
db.transaction_locks.updateOne(
{ _id: 'player:123', expireAt: { $lt: new Date() } },
{ $set: { token: '<token>', expireAt: new Date(Date.now() + 10000) } }
);
```
## 存储选择指南
| 场景 | 推荐存储 | 理由 |
|------|----------|------|
| 开发/测试 | MemoryStorage | 无依赖,快速启动 |
| 单机生产 | RedisStorage | 高性能,简单 |
| 分布式系统 | RedisStorage | 真正的分布式锁 |
| 需要审计 | MongoStorage | 持久化日志 |
| 混合需求 | Redis + Mongo | Redis 做锁Mongo 做日志 |
## 自定义存储
实现 `ITransactionStorage` 接口创建自定义存储:
```typescript
import { ITransactionStorage, TransactionLog, TransactionState } from '@esengine/transaction';
class MyCustomStorage implements ITransactionStorage {
async acquireLock(key: string, ttl: number): Promise<string | null> {
// 实现分布式锁获取逻辑
}
async releaseLock(key: string, token: string): Promise<boolean> {
// 实现分布式锁释放逻辑
}
async saveTransaction(tx: TransactionLog): Promise<void> {
// 保存事务日志
}
// ... 实现其他方法
}
```

View File

@@ -0,0 +1,167 @@
---
title: "区块管理器 API"
description: "ChunkManager 负责区块生命周期、加载队列和空间查询"
---
`ChunkManager` 是管理区块生命周期的核心服务,包括加载、卸载和空间查询。
## 基础用法
```typescript
import { ChunkManager } from '@esengine/world-streaming';
// 创建 512 单位大小的区块管理器
const chunkManager = new ChunkManager(512);
chunkManager.setScene(scene);
// 设置数据提供器
chunkManager.setDataProvider(myProvider);
// 设置事件回调
chunkManager.setEvents({
onChunkLoaded: (coord, entities) => {
console.log(`区块 (${coord.x}, ${coord.y}) 已加载,包含 ${entities.length} 个实体`);
},
onChunkUnloaded: (coord) => {
console.log(`区块 (${coord.x}, ${coord.y}) 已卸载`);
},
onChunkLoadFailed: (coord, error) => {
console.error(`加载区块 (${coord.x}, ${coord.y}) 失败:`, error);
}
});
```
## 加载与卸载
### 请求加载
```typescript
import { EChunkPriority } from '@esengine/world-streaming';
// 按优先级请求加载
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);
```
### 优先级说明
| 优先级 | 值 | 说明 |
|--------|------|------|
| `Immediate` | 0 | 当前区块(玩家所在) |
| `High` | 1 | 相邻区块 |
| `Normal` | 2 | 附近区块 |
| `Low` | 3 | 远处可见区块 |
| `Prefetch` | 4 | 移动方向预加载 |
### 请求卸载
```typescript
// 请求卸载,延迟 3 秒
chunkManager.requestUnload({ x: 5, y: 5 }, 3000);
// 取消待卸载请求(玩家返回了)
chunkManager.cancelUnload({ x: 5, y: 5 });
```
### 处理队列
```typescript
// 在更新循环或系统中
await chunkManager.processLoads(2); // 每帧最多加载 2 个区块
chunkManager.processUnloads(1); // 每帧最多卸载 1 个区块
```
## 空间查询
### 坐标转换
```typescript
// 世界坐标转区块坐标
const coord = chunkManager.worldToChunk(1500, 2300);
// 结果: { x: 2, y: 4 }512单位区块
// 获取区块世界边界
const bounds = chunkManager.getChunkBounds({ x: 2, y: 4 });
// 结果: { minX: 1024, minY: 2048, maxX: 1536, maxY: 2560 }
```
### 区块查询
```typescript
// 检查区块是否已加载
if (chunkManager.isChunkLoaded({ x: 0, y: 0 })) {
const chunk = chunkManager.getChunk({ x: 0, y: 0 });
console.log('实体数量:', chunk.entities.length);
}
// 获取半径内未加载的区块
const missing = chunkManager.getMissingChunks({ x: 0, y: 0 }, 2);
for (const coord of missing) {
chunkManager.requestLoad(coord);
}
// 获取超出范围的区块(用于卸载)
const outside = chunkManager.getChunksOutsideRadius({ x: 0, y: 0 }, 4);
for (const coord of outside) {
chunkManager.requestUnload(coord, 3000);
}
// 遍历所有已加载区块
chunkManager.forEachChunk((info, coord) => {
console.log(`区块 (${coord.x}, ${coord.y}): ${info.state}`);
});
```
## 统计信息
```typescript
console.log('已加载区块:', chunkManager.loadedChunkCount);
console.log('待加载:', chunkManager.pendingLoadCount);
console.log('待卸载:', chunkManager.pendingUnloadCount);
console.log('区块大小:', chunkManager.chunkSize);
```
## 区块状态
```typescript
import { EChunkState } from '@esengine/world-streaming';
// 区块生命周期状态
EChunkState.Unloaded // 未加载
EChunkState.Loading // 加载中
EChunkState.Loaded // 已加载
EChunkState.Unloading // 卸载中
EChunkState.Failed // 加载失败
```
## 数据提供器接口
```typescript
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
class MyChunkProvider implements IChunkDataProvider {
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
// 从数据库、文件或程序化生成加载
const data = await fetchChunkFromServer(coord);
return data;
}
async saveChunkData(data: IChunkData): Promise<void> {
// 保存修改过的区块
await saveChunkToServer(data);
}
}
```
## 清理
```typescript
// 卸载所有区块
chunkManager.clear();
// 完全释放(实现 IService 接口)
chunkManager.dispose();
```

View File

@@ -0,0 +1,330 @@
---
title: "示例"
description: "世界流式加载实践示例"
---
## 无限程序化世界
无限大世界的程序化资源生成示例。
```typescript
import {
ChunkManager,
ChunkStreamingSystem,
ChunkLoaderComponent,
StreamingAnchorComponent
} from '@esengine/world-streaming';
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
// 程序化世界生成器
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 = [];
// 每区块生成 5-15 个资源
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> {
// 程序化生成 - 无需持久化
}
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;
}
}
}
// 设置
const chunkManager = new ChunkManager(512);
chunkManager.setScene(scene);
chunkManager.setDataProvider(new WorldGenerator(12345));
const streamingSystem = new ChunkStreamingSystem();
streamingSystem.setChunkManager(chunkManager);
scene.addSystem(streamingSystem);
```
## MMO 服务端区块
带数据库持久化的 MMO 服务端区块管理。
```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}`;
// 检查缓存
if (this.cache.has(key)) {
return this.cache.get(key)!;
}
// 从数据库加载
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;
}
// 生成新区块
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 {
// 新区块的程序化生成
return { coord, entities: [], version: 1 };
}
}
// 服务端按玩家加载区块
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>();
// 加载玩家周围的区块
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);
}
}
}
// 记录玩家已加载的区块
this.playerChunks.set(playerId, newChunks);
}
}
```
## 瓦片地图世界
瓦片地图与区块流式加载集成。
```typescript
import { TilemapComponent } from '@esengine/tilemap';
class TilemapChunkProvider implements IChunkDataProvider {
private tilemapData: number[][]; // 完整瓦片地图
private tileSize = 32;
private chunkTiles = 16; // 每区块 16x16 瓦片
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
const startTileX = coord.x * this.chunkTiles;
const startTileY = coord.y * this.chunkTiles;
// 提取此区块的瓦片
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; // 超出边界 = 空
}
return this.tilemapData[y]?.[x] ?? 0;
}
}
// 瓦片地图自定义序列化器
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);
}
}
}
```
## 动态加载事件
响应区块加载用于游戏逻辑。
```typescript
chunkManager.setEvents({
onChunkLoaded: (coord, entities) => {
// 启用物理
for (const entity of entities) {
const collider = entity.getComponent(ColliderComponent);
collider?.enable();
}
// 为已加载区块生成 NPC
npcManager.spawnForChunk(coord);
// 更新战争迷雾
fogOfWar.revealChunk(coord);
// 通知客户端(服务端)
broadcast('ChunkLoaded', { coord, entityCount: entities.length });
},
onChunkUnloaded: (coord) => {
// 保存 NPC 状态
npcManager.saveAndRemoveForChunk(coord);
// 更新迷雾
fogOfWar.hideChunk(coord);
// 通知客户端
broadcast('ChunkUnloaded', { coord });
},
onChunkLoadFailed: (coord, error) => {
console.error(`加载区块 ${coord.x},${coord.y} 失败:`, error);
// 延迟后重试
setTimeout(() => {
chunkManager.requestLoad(coord);
}, 5000);
}
});
```
## 性能优化
```typescript
// 根据设备性能调整
function configureForDevice(loader: ChunkLoaderComponent) {
const memory = navigator.deviceMemory ?? 4;
const cores = navigator.hardwareConcurrency ?? 4;
if (memory <= 2 || cores <= 2) {
// 低端设备
loader.loadRadius = 1;
loader.unloadRadius = 2;
loader.maxLoadsPerFrame = 1;
loader.bEnablePrefetch = false;
} else if (memory <= 4) {
// 中端设备
loader.loadRadius = 2;
loader.unloadRadius = 3;
loader.maxLoadsPerFrame = 2;
} else {
// 高端设备
loader.loadRadius = 3;
loader.unloadRadius = 5;
loader.maxLoadsPerFrame = 4;
loader.prefetchRadius = 2;
}
}
```

View File

@@ -0,0 +1,158 @@
---
title: "世界流式加载"
description: "基于区块的开放世界流式加载系统"
---
`@esengine/world-streaming` 提供基于区块的世界流式加载与管理,适用于开放世界游戏。根据玩家位置动态加载/卸载世界区块。
## 安装
```bash
npm install @esengine/world-streaming
```
## 快速开始
### 基础设置
```typescript
import {
ChunkManager,
ChunkStreamingSystem,
StreamingAnchorComponent,
ChunkLoaderComponent
} from '@esengine/world-streaming';
// 创建区块管理器 (512单位区块)
const chunkManager = new ChunkManager(512);
chunkManager.setScene(scene);
// 添加流式加载系统
const streamingSystem = new ChunkStreamingSystem();
streamingSystem.setChunkManager(chunkManager);
scene.addSystem(streamingSystem);
// 创建加载器实体
const loaderEntity = scene.createEntity('ChunkLoader');
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
loader.chunkSize = 512;
loader.loadRadius = 2;
loader.unloadRadius = 4;
// 创建玩家作为流式锚点
const playerEntity = scene.createEntity('Player');
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
// 每帧更新锚点位置
function update() {
anchor.x = player.position.x;
anchor.y = player.position.y;
}
```
### 程序化生成
```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> {
// 使用种子+坐标生成确定性随机数
const chunkSeed = this.hashCoord(coord);
const rng = this.createRNG(chunkSeed);
// 生成区块内容
const entities = this.generateEntities(coord, rng);
return {
coord,
entities,
version: 1
};
}
async saveChunkData(data: IChunkData): Promise<void> {
// 可选:持久化已修改的区块
}
private hashCoord(coord: IChunkCoord): number {
return this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
}
private createRNG(seed: number) {
// 简单的种子随机数生成器
return () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
}
private generateEntities(coord: IChunkCoord, rng: () => number) {
// 生成资源、树木等
return [];
}
}
// 使用数据提供器
chunkManager.setDataProvider(new ProceduralChunkProvider(12345));
```
## 核心概念
### 区块生命周期
```
未加载 → 加载中 → 已加载 → 卸载中 → 未加载
↓ ↓
失败 (发生错误时)
```
### 流式锚点
`StreamingAnchorComponent` 用于标记作为区块加载锚点的实体。系统会在所有锚点周围加载区块,在超出范围时卸载区块。
```typescript
// StreamingAnchorComponent 实现 IPositionable 接口
interface IPositionable {
readonly position: { x: number; y: number };
}
```
### 配置参数
| 属性 | 默认值 | 说明 |
|------|--------|------|
| `chunkSize` | 512 | 区块大小(世界单位) |
| `loadRadius` | 2 | 锚点周围加载的区块半径 |
| `unloadRadius` | 4 | 超过此半径的区块会被卸载 |
| `maxLoadsPerFrame` | 2 | 每帧最大异步加载数 |
| `unloadDelay` | 3000 | 卸载前的延迟(毫秒) |
| `bEnablePrefetch` | true | 沿移动方向预加载 |
## 模块设置(可选)
使用模块辅助函数快速配置:
```typescript
import { worldStreamingModule } from '@esengine/world-streaming';
const chunkManager = worldStreamingModule.setup(
scene,
services,
componentRegistry,
{ chunkSize: 256, bEnableCulling: true }
);
```
## 文档
- [区块管理器 API](./chunk-manager) - 加载队列、区块生命周期
- [流式系统](./streaming-system) - 基于锚点的加载
- [序列化](./serialization) - 自定义区块序列化
- [示例](./examples) - 程序化世界、MMO 区块

View File

@@ -0,0 +1,227 @@
---
title: "区块序列化"
description: "自定义区块数据序列化"
---
`ChunkSerializer` 负责实体数据与区块存储格式之间的转换。
## 默认序列化器
```typescript
import { ChunkSerializer, ChunkManager } from '@esengine/world-streaming';
const serializer = new ChunkSerializer();
const chunkManager = new ChunkManager(512, serializer);
```
## 自定义序列化器
继承 `ChunkSerializer` 实现自定义序列化逻辑:
```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 {
/**
* 获取实体位置
* 重写以使用你的位置组件
*/
protected getPositionable(entity: Entity) {
const transform = entity.getComponent(TransformComponent);
if (transform) {
return { position: { x: transform.x, y: transform.y } };
}
return null;
}
/**
* 反序列化后设置实体位置
*/
protected setEntityPosition(entity: Entity, x: number, y: number): void {
const transform = entity.addComponent(new TransformComponent());
transform.x = x;
transform.y = y;
}
/**
* 序列化组件
*/
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;
}
/**
* 反序列化组件
*/
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;
}
}
/**
* 过滤需要序列化的组件
*/
protected shouldSerializeComponent(componentName: string): boolean {
const include = ['ResourceComponent', 'NPCComponent', 'BuildingComponent'];
return include.includes(componentName);
}
}
```
## 区块数据格式
```typescript
interface IChunkData {
coord: IChunkCoord; // 区块坐标
entities: ISerializedEntity[]; // 实体数据
version: number; // 数据版本
}
interface ISerializedEntity {
name: string; // 实体名称
localPosition: { x: number; y: number }; // 区块内位置
components: Record<string, unknown>; // 组件数据
}
interface IChunkCoord {
x: number; // 区块 X 坐标
y: number; // 区块 Y 坐标
}
```
## 带序列化的数据提供器
```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));
}
}
```
## 程序化生成与序列化器
```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);
// 生成树木
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 }
}
});
}
// 生成资源
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;
}
}
```
## 版本迁移
```typescript
class VersionedSerializer extends ChunkSerializer {
private static readonly CURRENT_VERSION = 2;
deserialize(data: IChunkData, scene: IScene): Entity[] {
// 迁移旧数据
if (data.version < 2) {
data = this.migrateV1toV2(data);
}
return super.deserialize(data, scene);
}
private migrateV1toV2(data: IChunkData): IChunkData {
// 转换旧组件格式
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;
}
}
```

View File

@@ -0,0 +1,176 @@
---
title: "流式加载系统"
description: "ChunkStreamingSystem 根据锚点位置自动管理区块加载"
---
`ChunkStreamingSystem` 根据 `StreamingAnchorComponent` 的位置自动管理区块的加载和卸载。
## 设置
```typescript
import {
ChunkManager,
ChunkStreamingSystem,
ChunkLoaderComponent,
StreamingAnchorComponent
} from '@esengine/world-streaming';
// 创建并配置区块管理器
const chunkManager = new ChunkManager(512);
chunkManager.setScene(scene);
chunkManager.setDataProvider(myProvider);
// 创建流式系统
const streamingSystem = new ChunkStreamingSystem();
streamingSystem.setChunkManager(chunkManager);
scene.addSystem(streamingSystem);
// 创建加载器实体
const loaderEntity = scene.createEntity('ChunkLoader');
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
loader.chunkSize = 512;
loader.loadRadius = 2;
loader.unloadRadius = 4;
```
## 流式锚点
`StreamingAnchorComponent` 标记实体为区块加载锚点。区块会在所有锚点周围加载。
```typescript
// 创建玩家作为流式锚点
const playerEntity = scene.createEntity('Player');
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
// 每帧更新位置
function update() {
anchor.x = player.worldX;
anchor.y = player.worldY;
}
```
### 锚点属性
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `x` | number | 0 | 世界 X 坐标 |
| `y` | number | 0 | 世界 Y 坐标 |
| `weight` | number | 1.0 | 加载半径倍数 |
| `bEnablePrefetch` | boolean | true | 是否启用预加载 |
### 多锚点
```typescript
// 主玩家 - 完整加载半径
const playerAnchor = player.addComponent(new StreamingAnchorComponent());
playerAnchor.weight = 1.0;
// 相机预览 - 较小半径
const cameraAnchor = camera.addComponent(new StreamingAnchorComponent());
cameraAnchor.weight = 0.5; // 加载半径减半
cameraAnchor.bEnablePrefetch = false;
```
## 加载器配置
`ChunkLoaderComponent` 配置流式加载行为。
```typescript
const loader = entity.addComponent(new ChunkLoaderComponent());
// 区块尺寸
loader.chunkSize = 512; // 每区块世界单位
// 加载半径
loader.loadRadius = 2; // 锚点周围 2 个区块内加载
loader.unloadRadius = 4; // 超过 4 个区块卸载
// 性能调优
loader.maxLoadsPerFrame = 2; // 每帧最大异步加载数
loader.maxUnloadsPerFrame = 1; // 每帧最大卸载数
loader.unloadDelay = 3000; // 卸载前延迟(毫秒)
// 预加载
loader.bEnablePrefetch = true; // 启用移动方向预加载
loader.prefetchRadius = 1; // 预加载额外区块数
```
### 坐标辅助方法
```typescript
// 世界坐标转区块坐标
const coord = loader.worldToChunk(1500, 2300);
// 获取区块边界
const bounds = loader.getChunkBounds(coord);
```
## 预加载系统
启用后,系统会沿移动方向预加载区块:
```
移动方向 →
[ ][ ][ ] [ ][P][P] P = 预加载
[L][L][L] → [L][L][L] L = 已加载
[ ][ ][ ] [ ][ ][ ]
```
```typescript
// 启用预加载
loader.bEnablePrefetch = true;
loader.prefetchRadius = 2; // 向前预加载 2 个区块
// 单独控制锚点的预加载
anchor.bEnablePrefetch = true; // 主玩家启用
cameraAnchor.bEnablePrefetch = false; // 相机禁用
```
## 系统处理流程
系统每帧运行:
1. 更新锚点速度
2. 请求加载范围内的区块
3. 取消已回到范围内的区块卸载
4. 请求卸载超出范围的区块
5. 处理加载/卸载队列
```typescript
// 从系统访问区块管理器
const system = scene.getSystem(ChunkStreamingSystem);
const manager = system?.chunkManager;
if (manager) {
console.log('已加载:', manager.loadedChunkCount);
}
```
## 基于优先级的加载
区块按距离分配加载优先级:
| 距离 | 优先级 | 说明 |
|------|--------|------|
| 0 | Immediate | 玩家当前区块 |
| 1 | High | 相邻区块 |
| 2-4 | Normal | 附近区块 |
| 5+ | Low | 远处区块 |
| 预加载 | Prefetch | 移动方向 |
## 事件
```typescript
chunkManager.setEvents({
onChunkLoaded: (coord, entities) => {
// 区块就绪 - 生成 NPC启用碰撞
for (const entity of entities) {
entity.getComponent(ColliderComponent)?.enable();
}
},
onChunkUnloaded: (coord) => {
// 清理 - 保存状态,释放资源
}
});
```

View File

@@ -74,6 +74,7 @@
"lint:fix": "turbo run lint:fix",
"build:wasm": "cd packages/rust/engine && wasm-pack build --dev --out-dir pkg",
"build:wasm:release": "cd packages/rust/engine && wasm-pack build --release --out-dir pkg",
"build:rapier2d": "node scripts/build-rapier2d.mjs",
"copy-modules": "node scripts/copy-engine-modules.mjs"
},
"author": "yhh",

View File

@@ -0,0 +1,132 @@
# ESEngine Editor
A cross-platform desktop visual editor built with Tauri 2.x + React 18.
## Prerequisites
Before running the editor, ensure you have the following installed:
- **Node.js** >= 18.x
- **pnpm** >= 10.x
- **Rust** >= 1.70 (for Tauri and WASM builds)
- **wasm-pack** (for building Rapier2D physics engine)
- **Platform-specific dependencies**:
- **Windows**: Microsoft Visual Studio C++ Build Tools
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Linux**: See [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites)
### Installing wasm-pack
```bash
# Using cargo
cargo install wasm-pack
# Or using the official installer script (Linux/macOS)
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
```
## Quick Start
### 1. Clone and Install
```bash
git clone https://github.com/esengine/esengine.git
cd esengine
pnpm install
```
### 2. Build Rapier2D WASM
The editor depends on Rapier2D physics engine WASM artifacts. First-time setup only requires one command:
```bash
pnpm build:rapier2d
```
This command automatically:
1. Prepares the Rust project
2. Builds WASM
3. Copies artifacts to `packages/physics/rapier2d/pkg`
4. Generates TypeScript source code
> **Note**: Requires Rust and wasm-pack to be installed.
### 3. Build Editor
From the project root:
```bash
pnpm build:editor
```
### 4. Run Editor
```bash
cd packages/editor/editor-app
pnpm tauri:dev
```
## Available Scripts
| Script | Description |
|--------|-------------|
| `pnpm build:rapier2d` | Build Rapier2D WASM (required for first-time setup) |
| `pnpm build:editor` | Build editor and all dependencies |
| `pnpm tauri:dev` | Run editor in development mode with hot-reload |
| `pnpm tauri:build` | Build production application |
| `pnpm build:sdk` | Build editor-runtime SDK |
## Project Structure
```
editor-app/
├── src/ # React application source
│ ├── components/ # UI components
│ ├── panels/ # Editor panels
│ └── services/ # Core services
├── src-tauri/ # Tauri (Rust) backend
├── public/ # Static assets
└── scripts/ # Build scripts
```
## Troubleshooting
### Rapier2D WASM Build Failed
**Error**: `Could not resolve "../pkg/rapier_wasm2d"`
**Cause**: Missing Rapier2D WASM artifacts.
**Solution**:
1. Ensure `wasm-pack` is installed: `cargo install wasm-pack`
2. Run `pnpm build:rapier2d`
3. Verify `packages/physics/rapier2d/pkg/` directory exists and contains `rapier_wasm2d_bg.wasm` file
### Build Errors
```bash
pnpm clean
pnpm install
pnpm build:editor
```
### Rust/Tauri Errors
```bash
rustup update
```
### Windows Users Building WASM
The `pnpm build:rapier2d` script works directly on Windows. If you encounter issues:
1. Use Git Bash or WSL
2. Or download pre-built WASM artifacts from [Releases](https://github.com/esengine/esengine/releases)
## Documentation
- [ESEngine Documentation](https://esengine.cn/)
- [Tauri Documentation](https://tauri.app/)
## License
MIT License

View File

@@ -0,0 +1,132 @@
# ESEngine 编辑器
基于 Tauri 2.x + React 18 构建的跨平台桌面可视化编辑器。
## 环境要求
运行编辑器前,请确保已安装以下环境:
- **Node.js** >= 18.x
- **pnpm** >= 10.x
- **Rust** >= 1.70 (Tauri 和 WASM 构建需要)
- **wasm-pack** (构建 Rapier2D 物理引擎需要)
- **平台相关依赖**
- **Windows**: Microsoft Visual Studio C++ Build Tools
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Linux**: 参考 [Tauri 环境配置](https://tauri.app/v1/guides/getting-started/prerequisites)
### 安装 wasm-pack
```bash
# 使用 cargo 安装
cargo install wasm-pack
# 或使用官方安装脚本 (Linux/macOS)
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
```
## 快速开始
### 1. 克隆并安装
```bash
git clone https://github.com/esengine/esengine.git
cd esengine
pnpm install
```
### 2. 构建 Rapier2D WASM
编辑器依赖 Rapier2D 物理引擎的 WASM 产物。首次构建只需执行一条命令:
```bash
pnpm build:rapier2d
```
该命令会自动完成以下步骤:
1. 准备 Rust 项目
2. 构建 WASM
3. 复制产物到 `packages/physics/rapier2d/pkg`
4. 生成 TypeScript 源码
> **注意**:需要已安装 Rust 和 wasm-pack。
### 3. 构建编辑器
在项目根目录执行:
```bash
pnpm build:editor
```
### 4. 启动编辑器
```bash
cd packages/editor/editor-app
pnpm tauri:dev
```
## 可用脚本
| 脚本 | 说明 |
|------|------|
| `pnpm build:rapier2d` | 构建 Rapier2D WASM首次构建必须执行|
| `pnpm build:editor` | 构建编辑器及所有依赖 |
| `pnpm tauri:dev` | 开发模式运行编辑器(支持热重载)|
| `pnpm tauri:build` | 构建生产版本应用 |
| `pnpm build:sdk` | 构建 editor-runtime SDK |
## 项目结构
```
editor-app/
├── src/ # React 应用源码
│ ├── components/ # UI 组件
│ ├── panels/ # 编辑器面板
│ └── services/ # 核心服务
├── src-tauri/ # Tauri (Rust) 后端
├── public/ # 静态资源
└── scripts/ # 构建脚本
```
## 常见问题
### Rapier2D WASM 构建失败
**错误**: `Could not resolve "../pkg/rapier_wasm2d"`
**原因**: 缺少 Rapier2D 的 WASM 产物。
**解决方案**:
1. 确保已安装 `wasm-pack``cargo install wasm-pack`
2. 执行 `pnpm build:rapier2d`
3. 确认 `packages/physics/rapier2d/pkg/` 目录存在且包含 `rapier_wasm2d_bg.wasm` 文件
### 构建错误
```bash
pnpm clean
pnpm install
pnpm build:editor
```
### Rust/Tauri 错误
```bash
rustup update
```
### Windows 用户构建 WASM
`pnpm build:rapier2d` 脚本在 Windows 上可以直接运行。如果遇到问题:
1. 使用 Git Bash 或 WSL
2. 或从 [Releases](https://github.com/esengine/esengine/releases) 下载预编译的 WASM 产物
## 文档
- [ESEngine 文档](https://esengine.cn/)
- [Tauri 文档](https://tauri.app/)
## 许可证
MIT License

View File

@@ -9,7 +9,7 @@
"build": "npm run build:sdk && tsc && vite build",
"build:watch": "vite build --watch",
"tauri": "tauri",
"copy-modules": "node ../../scripts/copy-engine-modules.mjs",
"copy-modules": "node ../../../scripts/copy-engine-modules.mjs",
"tauri:dev": "npm run build:sdk && npm run copy-modules && tauri dev",
"bundle:runtime": "node scripts/bundle-runtime.mjs",
"tauri:build": "npm run build:sdk && npm run copy-modules && npm run bundle:runtime && tauri build",

File diff suppressed because it is too large Load Diff

View File

@@ -10,16 +10,16 @@ name = "ecs_editor_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2.0", features = ["protocol-asset"] }
tauri-plugin-shell = "2.0"
tauri-plugin-dialog = "2.0"
tauri-plugin-fs = "2.0"
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-http = "2.0"
tauri-plugin-cli = "2.0"
tauri-plugin-http = "2"
tauri-plugin-cli = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
glob = "0.3"

View File

@@ -30,6 +30,7 @@
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/asset-system": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/editor-runtime": "workspace:*",
"@esengine/node-editor": "workspace:*",

View File

@@ -0,0 +1,45 @@
/**
* @zh ESEngine 行为树运行时模块
* @en ESEngine Behavior Tree Runtime Module
*
* @zh 纯运行时模块,不依赖 asset-system。资产加载由编辑器在 install 时注册。
* @en Pure runtime module, no asset-system dependency. Asset loading is registered by editor during install.
*/
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
import type { IRuntimeModule, SystemContext } from '@esengine/engine-core';
import {
BehaviorTreeRuntimeComponent,
BehaviorTreeExecutionSystem,
BehaviorTreeAssetManager,
GlobalBlackboardService,
BehaviorTreeSystemToken
} from '@esengine/behavior-tree';
export class BehaviorTreeRuntimeModule implements IRuntimeModule {
registerComponents(registry: IComponentRegistry): void {
registry.register(BehaviorTreeRuntimeComponent);
}
registerServices(services: ServiceContainer): void {
if (!services.isRegistered(GlobalBlackboardService)) {
services.registerSingleton(GlobalBlackboardService);
}
if (!services.isRegistered(BehaviorTreeAssetManager)) {
services.registerSingleton(BehaviorTreeAssetManager);
}
}
createSystems(scene: IScene, context: SystemContext): void {
const ecsServices = (context as { ecsServices?: ServiceContainer }).ecsServices;
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(ecsServices);
if (context.isEditor) {
behaviorTreeSystem.enabled = false;
}
scene.addSystem(behaviorTreeSystem);
context.services.register(BehaviorTreeSystemToken, behaviorTreeSystem);
}
}

View File

@@ -30,8 +30,11 @@ import {
LocaleService,
} from '@esengine/editor-runtime';
// Runtime imports from @esengine/behavior-tree package
import { BehaviorTreeRuntimeComponent, BehaviorTreeRuntimeModule } from '@esengine/behavior-tree';
// Runtime imports
import { BehaviorTreeRuntimeComponent, BehaviorTreeAssetType } from '@esengine/behavior-tree';
import { AssetManagerToken } from '@esengine/asset-system';
import { BehaviorTreeRuntimeModule } from './BehaviorTreeRuntimeModule';
import { BehaviorTreeLoader } from './runtime/BehaviorTreeLoader';
// Editor components and services
import { BehaviorTreeService } from './services/BehaviorTreeService';
@@ -71,6 +74,10 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
// 设置插件上下文
PluginContext.setServices(services);
// 注册行为树资产加载器到 AssetManager
// Register behavior tree asset loader to AssetManager
this.registerAssetLoader();
// 注册服务
this.registerServices(services);
@@ -92,6 +99,22 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
logger.info('BehaviorTree editor module installed');
}
/**
* 注册行为树资产加载器
* Register behavior tree asset loader
*/
private registerAssetLoader(): void {
try {
const assetManager = PluginAPI.resolve(AssetManagerToken);
if (assetManager) {
assetManager.registerLoader(BehaviorTreeAssetType, new BehaviorTreeLoader());
logger.info('BehaviorTree asset loader registered');
}
} catch (error) {
logger.warn('Failed to register asset loader:', error);
}
}
private registerAssetCreationMappings(services: ServiceContainer): void {
try {
const fileActionRegistry = services.resolve<FileActionRegistry>(IFileActionRegistry);
@@ -376,7 +399,7 @@ export const BehaviorTreePlugin: IEditorPlugin = {
editorModule: new BehaviorTreeEditorModule(),
};
export { BehaviorTreeRuntimeModule };
// BehaviorTreeRuntimeModule is internal, not re-exported
// Re-exports for editor functionality
export { PluginContext } from './PluginContext';

View File

@@ -0,0 +1,61 @@
/**
* @zh ESEngine 资产加载器
* @en ESEngine asset loader
* @internal
*/
import { Core } from '@esengine/ecs-framework';
import {
BehaviorTreeAssetManager,
EditorToBehaviorTreeDataConverter,
BehaviorTreeAssetType,
type BehaviorTreeData
} from '@esengine/behavior-tree';
/**
* @zh 行为树资产接口
* @en Behavior tree asset interface
* @internal
*/
export interface IBehaviorTreeAsset {
data: BehaviorTreeData;
path: string;
}
/**
* @zh 行为树加载器
* @en Behavior tree loader implementing IAssetLoader interface
* @internal
*/
export class BehaviorTreeLoader {
readonly supportedType = BehaviorTreeAssetType;
readonly supportedExtensions = ['.btree'];
readonly contentType = 'text' as const;
async parse(content: { text?: string }, context: { metadata: { path: string } }): Promise<IBehaviorTreeAsset> {
if (!content.text) {
throw new Error('Behavior tree content is empty');
}
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content.text);
const assetPath = context.metadata.path;
treeData.id = assetPath;
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager) {
btAssetManager.loadAsset(treeData);
}
return {
data: treeData,
path: assetPath
};
}
dispose(asset: IBehaviorTreeAsset): void {
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager && asset.data) {
btAssetManager.unloadAsset(asset.data.id);
}
}
}

View File

@@ -1,23 +1,18 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"composite": false,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
"skipLibCheck": true,
"moduleResolution": "bundler",
"paths": {
"@esengine/asset-system": ["../../../engine/asset-system/src"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -2,6 +2,7 @@ import { defineConfig } from 'tsup';
import { editorOnlyPreset } from '../../../tools/build-config/src/presets/plugin-tsup';
export default defineConfig({
...editorOnlyPreset(),
tsconfig: 'tsconfig.build.json'
...editorOnlyPreset({}),
tsconfig: 'tsconfig.build.json',
noExternal: ['@esengine/asset-system']
});

View File

@@ -0,0 +1,13 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -1,6 +1,7 @@
{
"extends": "../build-config/tsconfig.json",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",

View File

@@ -5,6 +5,7 @@ export default defineConfig({
format: ['esm'],
dts: true,
clean: true,
tsconfig: 'tsconfig.build.json',
external: [
'react',
'react-dom',

View File

@@ -1,5 +1,121 @@
# @esengine/behavior-tree
## 4.2.0
### Minor Changes
- [#408](https://github.com/esengine/esengine/pull/408) [`b9ea8d1`](https://github.com/esengine/esengine/commit/b9ea8d14cf38e1480f638c229f9ee150b65f0c60) Thanks [@esengine](https://github.com/esengine)! - feat: add action() and condition() methods to BehaviorTreeBuilder
Added new methods to support custom executor types directly in the builder:
- `action(implementationType, name?, config?)` - Use custom action executors registered via `@NodeExecutorMetadata`
- `condition(implementationType, name?, config?)` - Use custom condition executors
This provides a cleaner API for using custom node executors compared to the existing `executeAction()` which only supports blackboard functions.
Example:
```typescript
// Define custom executor
@NodeExecutorMetadata({
implementationType: 'AttackAction',
nodeType: NodeType.Action,
displayName: 'Attack',
category: 'Combat'
})
class AttackAction implements INodeExecutor {
execute(context: NodeExecutionContext): TaskStatus {
return TaskStatus.Success;
}
}
// Use in builder
const tree = BehaviorTreeBuilder.create('AI')
.selector('Root')
.action('AttackAction', 'Attack', { damage: 50 })
.end()
.build();
```
## 4.1.2
### Patch Changes
- [#406](https://github.com/esengine/esengine/pull/406) [`0de4527`](https://github.com/esengine/esengine/commit/0de45279e612c04ae9be7fbd65ce496e4797a43c) Thanks [@esengine](https://github.com/esengine)! - fix(behavior-tree): export NodeExecutorMetadata as value instead of type
Fixed the export of `NodeExecutorMetadata` decorator in `execution/index.ts`.
Previously it was exported as `export type { NodeExecutorMetadata }` which only
exported the type signature, not the actual function. This caused runtime errors
in Cocos Creator: "TypeError: (intermediate value) is not a function".
Changed to `export { NodeExecutorMetadata }` to properly export the decorator function.
## 4.1.1
### Patch Changes
- Updated dependencies [[`3e5b778`](https://github.com/esengine/esengine/commit/3e5b7783beec08e247f7525184935401923ecde8)]:
- @esengine/ecs-framework@2.7.1
## 4.1.0
### Minor Changes
- [#400](https://github.com/esengine/esengine/pull/400) [`d2af9ca`](https://github.com/esengine/esengine/commit/d2af9caae9d5620c5f690272ab80dc246e9b7e10) Thanks [@esengine](https://github.com/esengine)! - feat(behavior-tree): add pure BehaviorTreePlugin class for Cocos/Laya integration
- Added `BehaviorTreePlugin` class that only depends on `@esengine/ecs-framework`
- Implements `IPlugin` interface with `install()`, `uninstall()`, and `setupScene()` methods
- Removed `esengine/` subdirectory that incorrectly depended on `@esengine/engine-core`
- Updated package documentation with correct usage examples
Usage:
```typescript
import { Core, Scene } from '@esengine/ecs-framework';
import { BehaviorTreePlugin, BehaviorTreeBuilder, BehaviorTreeStarter } from '@esengine/behavior-tree';
Core.create();
const plugin = new BehaviorTreePlugin();
await Core.installPlugin(plugin);
const scene = new Scene();
plugin.setupScene(scene);
Core.setScene(scene);
```
## 4.0.0
### Patch Changes
- Updated dependencies [[`1f3a76a`](https://github.com/esengine/esengine/commit/1f3a76aabea2d3eb8a5eb8b73e29127da57e2028)]:
- @esengine/ecs-framework@2.7.0
## 3.0.1
### Patch Changes
- Updated dependencies [[`04b08f3`](https://github.com/esengine/esengine/commit/04b08f3f073d69beb8f4be399c774bea0acb612e)]:
- @esengine/ecs-framework@2.6.1
## 3.0.0
### Patch Changes
- Updated dependencies []:
- @esengine/ecs-framework@2.6.0
## 2.0.1
### Patch Changes
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
- @esengine/ecs-framework@2.5.1
## 2.0.0
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
## 1.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/behavior-tree",
"version": "1.0.3",
"version": "4.2.0",
"description": "ECS-based AI behavior tree system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
"main": "dist/index.js",
"module": "dist/index.js",
@@ -29,7 +29,8 @@
"clean": "rimraf dist tsconfig.tsbuildinfo",
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
},
"author": "yhh",
"license": "MIT",

View File

@@ -181,12 +181,73 @@ export class BehaviorTreeBuilder {
}
/**
* 添加执行动作
* 添加执行动作(通过黑板函数)
*
* @zh 使用黑板中的 action_{actionName} 函数执行动作
* @en Execute action using action_{actionName} function from blackboard
*
* @example
* ```typescript
* BehaviorTreeBuilder.create("AI")
* .defineBlackboardVariable("action_Attack", (entity) => TaskStatus.Success)
* .selector("Root")
* .executeAction("Attack")
* .end()
* .build();
* ```
*/
executeAction(actionName: string, name?: string): BehaviorTreeBuilder {
return this.addActionNode('ExecuteAction', name || 'ExecuteAction', { actionName });
}
/**
* 添加自定义动作节点
*
* @zh 直接使用注册的执行器类型(通过 @NodeExecutorMetadata 装饰器注册的类)
* @en Use a registered executor type directly (class registered via @NodeExecutorMetadata decorator)
*
* @param implementationType - 执行器类型名称(@NodeExecutorMetadata 中的 implementationType
* @param name - 节点显示名称
* @param config - 节点配置参数
*
* @example
* ```typescript
* // 1. 定义自定义执行器
* @NodeExecutorMetadata({
* implementationType: 'AttackAction',
* nodeType: NodeType.Action,
* displayName: '攻击动作',
* category: 'Action'
* })
* class AttackAction implements INodeExecutor {
* execute(context: NodeExecutionContext): TaskStatus {
* console.log("执行攻击!");
* return TaskStatus.Success;
* }
* }
*
* // 2. 在行为树中使用
* BehaviorTreeBuilder.create("AI")
* .selector("Root")
* .action("AttackAction", "Attack")
* .end()
* .build();
* ```
*/
action(implementationType: string, name?: string, config?: Record<string, any>): BehaviorTreeBuilder {
return this.addActionNode(implementationType, name || implementationType, config || {});
}
/**
* 添加自定义条件节点
*
* @zh 直接使用注册的条件执行器类型
* @en Use a registered condition executor type directly
*/
condition(implementationType: string, name?: string, config?: Record<string, any>): BehaviorTreeBuilder {
return this.addConditionNode(implementationType, name || implementationType, config || {});
}
/**
* 添加黑板比较条件
*/

View File

@@ -0,0 +1,118 @@
import type { Core, ServiceContainer, IPlugin, IScene } from '@esengine/ecs-framework';
import { BehaviorTreeExecutionSystem } from './execution/BehaviorTreeExecutionSystem';
import { BehaviorTreeAssetManager } from './execution/BehaviorTreeAssetManager';
import { GlobalBlackboardService } from './Services/GlobalBlackboardService';
/**
* @zh 行为树插件
* @en Behavior Tree Plugin
*
* @zh 为 ECS 框架提供行为树支持的插件。
* 可与任何基于 @esengine/ecs-framework 的引擎集成Cocos、Laya、Node.js 等)。
*
* @en Plugin that provides behavior tree support for ECS framework.
* Can be integrated with any engine based on @esengine/ecs-framework (Cocos, Laya, Node.js, etc.).
*
* @example
* ```typescript
* import { Core, Scene } from '@esengine/ecs-framework';
* import { BehaviorTreePlugin, BehaviorTreeBuilder, BehaviorTreeStarter } from '@esengine/behavior-tree';
*
* // Initialize
* Core.create();
* const plugin = new BehaviorTreePlugin();
* await Core.installPlugin(plugin);
*
* // Setup scene
* const scene = new Scene();
* plugin.setupScene(scene);
* Core.setScene(scene);
*
* // Create and start behavior tree
* const tree = BehaviorTreeBuilder.create('MyAI')
* .selector('Root')
* .log('Hello from behavior tree!')
* .end()
* .build();
*
* const entity = scene.createEntity('AIEntity');
* BehaviorTreeStarter.start(entity, tree);
* ```
*/
export class BehaviorTreePlugin implements IPlugin {
/**
* @zh 插件名称
* @en Plugin name
*/
readonly name = '@esengine/behavior-tree';
/**
* @zh 插件版本
* @en Plugin version
*/
readonly version = '1.0.0';
/**
* @zh 插件依赖
* @en Plugin dependencies
*/
readonly dependencies: readonly string[] = [];
private _services: ServiceContainer | null = null;
/**
* @zh 安装插件
* @en Install plugin
*
* @param _core - Core 实例
* @param services - 服务容器
*/
install(_core: Core, services: ServiceContainer): void {
this._services = services;
// Register services
if (!services.isRegistered(GlobalBlackboardService)) {
services.registerSingleton(GlobalBlackboardService);
}
if (!services.isRegistered(BehaviorTreeAssetManager)) {
services.registerSingleton(BehaviorTreeAssetManager);
}
}
/**
* @zh 卸载插件
* @en Uninstall plugin
*/
uninstall(): void {
if (this._services) {
const assetManager = this._services.tryResolve(BehaviorTreeAssetManager);
if (assetManager) {
assetManager.dispose();
}
const blackboardService = this._services.tryResolve(GlobalBlackboardService);
if (blackboardService) {
blackboardService.dispose();
}
}
this._services = null;
}
/**
* @zh 设置场景,添加行为树执行系统
* @en Setup scene, add behavior tree execution system
*
* @param scene - 要设置的场景
*
* @example
* ```typescript
* const scene = new Scene();
* plugin.setupScene(scene);
* Core.setScene(scene);
* ```
*/
setupScene(scene: IScene): void {
const system = new BehaviorTreeExecutionSystem(this._services ?? undefined);
scene.addSystem(system);
}
}

View File

@@ -1,82 +0,0 @@
/**
* @zh ESEngine 资产加载器
* @en ESEngine asset loader
*
* @zh 实现 IAssetLoader 接口,用于通过 AssetManager 加载行为树文件。
* 此文件仅在使用 ESEngine 时需要。
*
* @en Implements IAssetLoader interface for loading behavior tree files via AssetManager.
* This file is only needed when using ESEngine.
*/
import type {
IAssetLoader,
IAssetParseContext,
IAssetContent,
AssetContentType
} from '@esengine/asset-system';
import { Core } from '@esengine/ecs-framework';
import { BehaviorTreeData } from '../execution/BehaviorTreeData';
import { BehaviorTreeAssetManager } from '../execution/BehaviorTreeAssetManager';
import { EditorToBehaviorTreeDataConverter } from '../Serialization/EditorToBehaviorTreeDataConverter';
import { BehaviorTreeAssetType } from '../constants';
/**
* @zh 行为树资产接口
* @en Behavior tree asset interface
*/
export interface IBehaviorTreeAsset {
/** @zh 行为树数据 @en Behavior tree data */
data: BehaviorTreeData;
/** @zh 文件路径 @en File path */
path: string;
}
/**
* @zh 行为树加载器
* @en Behavior tree loader implementing IAssetLoader interface
*/
export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
readonly supportedType = BehaviorTreeAssetType;
readonly supportedExtensions = ['.btree'];
readonly contentType: AssetContentType = 'text';
/**
* @zh 从内容解析行为树资产
* @en Parse behavior tree asset from content
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IBehaviorTreeAsset> {
if (!content.text) {
throw new Error('Behavior tree content is empty');
}
// Convert to runtime data
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content.text);
// Use file path as ID
const assetPath = context.metadata.path;
treeData.id = assetPath;
// Also register to BehaviorTreeAssetManager for legacy code
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager) {
btAssetManager.loadAsset(treeData);
}
return {
data: treeData,
path: assetPath
};
}
/**
* @zh 释放资产
* @en Dispose asset
*/
dispose(asset: IBehaviorTreeAsset): void {
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager && asset.data) {
btAssetManager.unloadAsset(asset.data.id);
}
}
}

View File

@@ -1,93 +0,0 @@
/**
* @zh ESEngine 集成模块
* @en ESEngine integration module
*
* @zh 此文件包含与 ESEngine 引擎核心集成的代码。
* 使用 Cocos/Laya 等其他引擎时不需要此文件。
*
* @en This file contains code for integrating with ESEngine engine-core.
* Not needed when using other engines like Cocos/Laya.
*/
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import { AssetManagerToken } from '@esengine/asset-system';
import { BehaviorTreeRuntimeComponent } from '../execution/BehaviorTreeRuntimeComponent';
import { BehaviorTreeExecutionSystem } from '../execution/BehaviorTreeExecutionSystem';
import { BehaviorTreeAssetManager } from '../execution/BehaviorTreeAssetManager';
import { GlobalBlackboardService } from '../Services/GlobalBlackboardService';
import { BehaviorTreeLoader } from './BehaviorTreeLoader';
import { BehaviorTreeAssetType } from '../constants';
import { BehaviorTreeSystemToken } from '../tokens';
// Re-export tokens for ESEngine users
export { BehaviorTreeSystemToken } from '../tokens';
class BehaviorTreeRuntimeModule implements IRuntimeModule {
private _loaderRegistered = false;
registerComponents(registry: IComponentRegistry): void {
registry.register(BehaviorTreeRuntimeComponent);
}
registerServices(services: ServiceContainer): void {
if (!services.isRegistered(GlobalBlackboardService)) {
services.registerSingleton(GlobalBlackboardService);
}
if (!services.isRegistered(BehaviorTreeAssetManager)) {
services.registerSingleton(BehaviorTreeAssetManager);
}
}
createSystems(scene: IScene, context: SystemContext): void {
// Get dependencies from service registry
const assetManager = context.services.get(AssetManagerToken);
if (!this._loaderRegistered && assetManager) {
assetManager.registerLoader(BehaviorTreeAssetType, new BehaviorTreeLoader());
this._loaderRegistered = true;
}
// Use ECS service container from context.services
const ecsServices = (context as { ecsServices?: ServiceContainer }).ecsServices;
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(ecsServices);
if (assetManager) {
behaviorTreeSystem.setAssetManager(assetManager);
}
if (context.isEditor) {
behaviorTreeSystem.enabled = false;
}
scene.addSystem(behaviorTreeSystem);
// Register service to service registry
context.services.register(BehaviorTreeSystemToken, behaviorTreeSystem);
}
}
const manifest: ModuleManifest = {
id: 'behavior-tree',
name: '@esengine/behavior-tree',
displayName: 'Behavior Tree',
version: '1.0.0',
description: 'AI behavior tree system',
category: 'AI',
icon: 'GitBranch',
isCore: false,
defaultEnabled: false,
isEngineModule: true,
canContainContent: true,
dependencies: ['core'],
exports: { components: ['BehaviorTreeComponent'] },
editorPackage: '@esengine/behavior-tree-editor'
};
export const BehaviorTreePlugin: IRuntimePlugin = {
manifest,
runtimeModule: new BehaviorTreeRuntimeModule()
};
export { BehaviorTreeRuntimeModule };

View File

@@ -1,39 +0,0 @@
/**
* @zh ESEngine 集成入口
* @en ESEngine integration entry point
*
* @zh 此模块包含与 ESEngine 引擎核心集成所需的所有代码。
* 使用 Cocos/Laya 等其他引擎时,只需导入主模块即可。
*
* @en This module contains all code required for ESEngine engine-core integration.
* When using other engines like Cocos/Laya, just import the main module.
*
* @example ESEngine 使用方式 / ESEngine usage:
* ```typescript
* import { BehaviorTreePlugin } from '@esengine/behavior-tree/esengine';
*
* // Register with ESEngine plugin system
* engine.registerPlugin(BehaviorTreePlugin);
* ```
*
* @example Cocos/Laya 使用方式 / Cocos/Laya usage:
* ```typescript
* import {
* BehaviorTreeAssetManager,
* BehaviorTreeExecutionSystem
* } from '@esengine/behavior-tree';
*
* // Load behavior tree from JSON
* const assetManager = new BehaviorTreeAssetManager();
* assetManager.loadFromEditorJSON(jsonContent);
*
* // Add system to your ECS world
* world.addSystem(new BehaviorTreeExecutionSystem());
* ```
*/
// Runtime module and plugin
export { BehaviorTreeRuntimeModule, BehaviorTreePlugin, BehaviorTreeSystemToken } from './BehaviorTreeRuntimeModule';
// Asset loader for ESEngine asset-system
export { BehaviorTreeLoader, type IBehaviorTreeAsset } from './BehaviorTreeLoader';

View File

@@ -5,7 +5,7 @@ export { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager';
export type { INodeExecutor, NodeExecutionContext } from './NodeExecutor';
export { NodeExecutorRegistry, BindingHelper } from './NodeExecutor';
export { BehaviorTreeExecutionSystem } from './BehaviorTreeExecutionSystem';
export type { NodeMetadata, ConfigFieldDefinition, NodeExecutorMetadata } from './NodeMetadata';
export { NodeMetadataRegistry } from './NodeMetadata';
export type { NodeMetadata, ConfigFieldDefinition } from './NodeMetadata';
export { NodeMetadataRegistry, NodeExecutorMetadata } from './NodeMetadata';
export * from './Executors';

View File

@@ -4,32 +4,44 @@
* @zh AI 行为树系统,支持运行时执行和可视化编辑
* @en AI Behavior Tree System with runtime execution and visual editor support
*
* @zh 此包是通用的行为树实现,可以与任何 ECS 框架配合使用。
* 对于 ESEngine 集成,请从 '@esengine/behavior-tree/esengine' 导入插件
* @zh 此包是通用的行为树实现,可以与任何基于 @esengine/ecs-framework 的引擎集成
* Cocos Creator、LayaAir、Node.js 等)
*
* @en This package is a generic behavior tree implementation that works with any ECS framework.
* For ESEngine integration, import the plugin from '@esengine/behavior-tree/esengine'.
* @en This package is a generic behavior tree implementation that works with any engine
* based on @esengine/ecs-framework (Cocos Creator, LayaAir, Node.js, etc.).
*
* @example Cocos/Laya/通用 ECS 使用方式:
* @example
* ```typescript
* import { Core, Scene } from '@esengine/ecs-framework';
* import {
* BehaviorTreeAssetManager,
* BehaviorTreeExecutionSystem,
* BehaviorTreeRuntimeComponent
* BehaviorTreePlugin,
* BehaviorTreeBuilder,
* BehaviorTreeStarter
* } from '@esengine/behavior-tree';
*
* // 1. Register service
* Core.services.registerSingleton(BehaviorTreeAssetManager);
* // 1. Initialize Core and install plugin
* Core.create();
* const plugin = new BehaviorTreePlugin();
* await Core.installPlugin(plugin);
*
* // 2. Load behavior tree from JSON
* const assetManager = Core.services.resolve(BehaviorTreeAssetManager);
* assetManager.loadFromEditorJSON(jsonContent);
* // 2. Create scene and setup behavior tree system
* const scene = new Scene();
* plugin.setupScene(scene);
* Core.setScene(scene);
*
* // 3. Add component to entity
* entity.addComponent(new BehaviorTreeRuntimeComponent());
* // 3. Build behavior tree
* const tree = BehaviorTreeBuilder.create('MyAI')
* .selector('Root')
* .log('Hello!')
* .end()
* .build();
*
* // 4. Add system to scene
* scene.addSystem(new BehaviorTreeExecutionSystem());
* // 4. Start behavior tree on entity
* const entity = scene.createEntity('AIEntity');
* BehaviorTreeStarter.start(entity, tree);
*
* // 5. Run game loop
* setInterval(() => Core.update(0.016), 16);
* ```
*
* @packageDocumentation
@@ -65,3 +77,6 @@ export { BlackboardTypes } from './Blackboard/BlackboardTypes';
// Service tokens (using ecs-framework's createServiceToken, not engine-core)
export { BehaviorTreeSystemToken } from './tokens';
// Plugin
export { BehaviorTreePlugin } from './BehaviorTreePlugin';

View File

@@ -1,5 +1,47 @@
# @esengine/blueprint
## 4.0.1
### Patch Changes
- Updated dependencies [[`3e5b778`](https://github.com/esengine/esengine/commit/3e5b7783beec08e247f7525184935401923ecde8)]:
- @esengine/ecs-framework@2.7.1
## 4.0.0
### Patch Changes
- Updated dependencies [[`1f3a76a`](https://github.com/esengine/esengine/commit/1f3a76aabea2d3eb8a5eb8b73e29127da57e2028)]:
- @esengine/ecs-framework@2.7.0
## 3.0.1
### Patch Changes
- Updated dependencies [[`04b08f3`](https://github.com/esengine/esengine/commit/04b08f3f073d69beb8f4be399c774bea0acb612e)]:
- @esengine/ecs-framework@2.6.1
## 3.0.0
### Patch Changes
- Updated dependencies []:
- @esengine/ecs-framework@2.6.0
## 2.0.1
### Patch Changes
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
- @esengine/ecs-framework@2.5.1
## 2.0.0
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
## 1.0.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/blueprint",
"version": "1.0.2",
"version": "4.0.1",
"description": "Visual scripting system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
"main": "dist/index.js",
"module": "dist/index.js",

View File

@@ -1,5 +1,217 @@
# @esengine/ecs-framework
## 2.7.1
### Patch Changes
- [#402](https://github.com/esengine/esengine/pull/402) [`3e5b778`](https://github.com/esengine/esengine/commit/3e5b7783beec08e247f7525184935401923ecde8) Thanks [@esengine](https://github.com/esengine)! - fix(ecs): 修复 ESM 环境下 require 不存在的问题
- 新增 `RuntimeConfig` 模块,作为运行时环境配置的独立存储
- `Core.runtimeEnvironment``Scene.runtimeEnvironment` 现在都从 `RuntimeConfig` 读取
- 移除 `Scene.ts` 中的 `require()` 调用,解决 Node.js ESM 环境下的兼容性问题
此修复解决了在 Node.js ESM 环境(如游戏服务端)中使用 `scene.isServer` 时报错 `ReferenceError: require is not defined` 的问题。
## 2.7.0
### Minor Changes
- [#398](https://github.com/esengine/esengine/pull/398) [`1f3a76a`](https://github.com/esengine/esengine/commit/1f3a76aabea2d3eb8a5eb8b73e29127da57e2028) Thanks [@esengine](https://github.com/esengine)! - feat(ecs): 添加运行时环境区分机制 | add runtime environment detection
新增功能:
- `Core` 新增静态属性 `runtimeEnvironment`,支持 `'server' | 'client' | 'standalone'`
- `Core` 新增 `isServer` / `isClient` 静态只读属性
- `ICoreConfig` 新增 `runtimeEnvironment` 配置项
- `Scene` 新增 `isServer` / `isClient` 只读属性(默认从 Core 继承,可通过 config 覆盖)
- 新增 `@ServerOnly()` / `@ClientOnly()` / `@NotServer()` / `@NotClient()` 方法装饰器
用于网络游戏中区分服务端权威逻辑和客户端逻辑:
```typescript
// 方式1: 全局设置(推荐)
Core.create({ runtimeEnvironment: 'server' });
// 或直接设置静态属性
Core.runtimeEnvironment = 'server';
// 所有场景自动继承
const scene = new Scene();
console.log(scene.isServer); // true
// 方式2: 单个场景覆盖(可选)
const clientScene = new Scene({ runtimeEnvironment: 'client' });
// 在系统中检查环境
class CollectibleSpawnSystem extends EntitySystem {
private checkCollections(): void {
if (!this.scene.isServer) return; // 客户端跳过
// ... 服务端权威逻辑
}
}
```
## 2.6.1
### Patch Changes
- [#396](https://github.com/esengine/esengine/pull/396) [`04b08f3`](https://github.com/esengine/esengine/commit/04b08f3f073d69beb8f4be399c774bea0acb612e) Thanks [@esengine](https://github.com/esengine)! - fix(ecs): COMPONENT_ADDED 事件添加 entity 字段
修复 `ECSEventType.COMPONENT_ADDED` 事件缺少 `entity` 字段的问题,导致 ECSRoom 的 `@NetworkEntity` 自动广播功能报错。
## 2.6.0
### Minor Changes
- feat(ecs): 添加 @NetworkEntity 装饰器,支持自动广播实体生成/销毁
### 新功能
**@NetworkEntity 装饰器**
- 标记组件为网络实体,自动广播 spawn/despawn 消息
- 支持 `autoSpawn` 和 `autoDespawn` 配置选项
- 通过事件系统(`ECSEventType.COMPONENT_ADDED` / `ECSEventType.ENTITY_DESTROYED`)实现
**ECSRoom 增强**
- 新增 `enableAutoNetworkEntity` 配置选项(默认启用)
- 自动监听组件添加和实体销毁事件
- 简化 GameRoom 实现,无需手动回调
### 改进
**Entity 事件**
- `Entity.destroy()` 现在发出 `entity:destroyed` 事件
- `Entity.active` 变化时发出 `entity:enabled` / `entity:disabled` 事件
- 使用 `ECSEventType` 常量替代硬编码字符串
### 使用示例
```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;
}
// 服务端
const entity = scene.createEntity('Enemy');
entity.addComponent(new EnemyComponent()); // 自动广播 spawn
entity.destroy(); // 自动广播 despawn
```
## 2.5.1
### Patch Changes
- [#392](https://github.com/esengine/esengine/pull/392) [`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76) Thanks [@esengine](https://github.com/esengine)! - fix(sync): Decoder 现在使用 GlobalComponentRegistry 查找组件 | Decoder now uses GlobalComponentRegistry for component lookup
**问题 | Problem:**
1. `Decoder.ts` 有自己独立的 `componentRegistry` Map与 `GlobalComponentRegistry` 完全分离。这导致通过 `@ECSComponent` 装饰器注册的组件在网络反序列化时找不到,产生 "Unknown component type" 错误。
2. `@sync` 装饰器使用 `constructor.name` 作为 `typeId`,而不是 `@ECSComponent` 装饰器指定的名称,导致编码和解码使用不同的类型 ID。
3. `Decoder.ts` had its own local `componentRegistry` Map that was completely separate from `GlobalComponentRegistry`. This caused components registered via `@ECSComponent` decorator to not be found during network deserialization, resulting in "Unknown component type" errors.
4. `@sync` decorator used `constructor.name` as `typeId` instead of the name specified by `@ECSComponent` decorator, causing encoding and decoding to use different type IDs.
**修改 | Changes:**
- 从 Decoder.ts 中移除本地 `componentRegistry`
- 更新 `decodeEntity` 和 `decodeSpawn` 使用 `GlobalComponentRegistry.getComponentType()`
- 移除已废弃的 `registerSyncComponent` 和 `autoRegisterSyncComponent` 函数
- 更新 `@sync` 装饰器使用 `getComponentTypeName()` 获取组件类型名称
- 更新 `@ECSComponent` 装饰器同步更新 `SYNC_METADATA.typeId`
- Removed local `componentRegistry` from Decoder.ts
- Updated `decodeEntity` and `decodeSpawn` to use `GlobalComponentRegistry.getComponentType()`
- Removed deprecated `registerSyncComponent` and `autoRegisterSyncComponent` functions
- Updated `@sync` decorator to use `getComponentTypeName()` for component type name
- Updated `@ECSComponent` decorator to sync update `SYNC_METADATA.typeId`
现在使用 `@ECSComponent` 装饰器的组件会自动可用于网络同步解码,无需手动注册。
Now `@ECSComponent` decorated components are automatically available for network sync decoding without any manual registration.
## 2.5.0
### Minor Changes
- [#390](https://github.com/esengine/esengine/pull/390) [`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256) Thanks [@esengine](https://github.com/esengine)! - feat: ECS 网络状态同步系统
## @esengine/ecs-framework
新增 `@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;
}
```
### 新增导出
- `sync` - 标记需要同步的字段装饰器
- `SyncType` - 支持的同步类型
- `SyncOperation` - 同步操作类型FULL/DELTA/SPAWN/DESPAWN
- `encodeSnapshot` / `decodeSnapshot` - 批量编解码
- `encodeSpawn` / `decodeSpawn` - 实体生成编解码
- `encodeDespawn` / `processDespawn` - 实体销毁编解码
- `ChangeTracker` - 字段级变更追踪
- `initChangeTracker` / `clearChanges` / `hasChanges` - 变更追踪工具函数
### 内部方法标记
将以下方法标记为 `@internal`,用户应通过 `Core.update()` 驱动更新:
- `Scene.update()`
- `SceneManager.update()`
- `WorldManager.updateAll()`
## @esengine/network
新增 `ComponentSyncSystem`,基于 `@sync` 装饰器自动同步组件状态:
```typescript
import { ComponentSyncSystem } from '@esengine/network';
// 服务端:编码状态
const data = syncSystem.encodeAllEntities(false);
// 客户端:解码状态
syncSystem.applySnapshot(data);
```
### 修复
- 将 `@esengine/ecs-framework` 从 devDependencies 移到 peerDependencies
## @esengine/server
新增 `ECSRoom`,带有 ECS World 支持的房间基类:
```typescript
import { ECSRoom } from '@esengine/server/ecs';
// 服务端启动
Core.create();
setInterval(() => Core.update(1 / 60), 16);
// 定义房间
class GameRoom extends ECSRoom {
onCreate() {
this.addSystem(new PhysicsSystem());
}
onJoin(player: Player) {
const entity = this.createPlayerEntity(player.id);
entity.addComponent(new PlayerComponent());
}
}
```
### 设计
- 每个 `ECSRoom` 在 `Core.worldManager` 中创建独立的 World
- `Core.update()` 统一更新 Time 和所有 World
- `onTick()` 只处理状态同步逻辑
## 2.4.4
### Patch Changes

View File

@@ -1,16 +1,18 @@
{
"name": "@esengine/ecs-framework",
"version": "2.4.4",
"version": "2.7.1",
"description": "用于Laya、Cocos Creator等JavaScript游戏引擎的高性能ECS框架",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"unpkg": "dist/index.umd.js",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
"require": "./dist/index.cjs",
"source": "./src/index.ts"
}
},
"files": [
@@ -50,23 +52,24 @@
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
"@babel/plugin-transform-optional-chaining": "^7.27.1",
"@babel/preset-env": "^7.28.3",
"@eslint/js": "^9.37.0",
"@jest/globals": "^29.7.0",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4",
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.19.17",
"@eslint/js": "^9.37.0",
"eslint": "^9.37.0",
"typescript-eslint": "^8.46.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"rimraf": "^5.0.0",
"rollup": "^4.42.0",
"rollup-plugin-dts": "^6.2.1",
"rollup-plugin-sourcemaps": "^0.6.3",
"ts-jest": "^29.4.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"typescript-eslint": "^8.46.1"
},
"publishConfig": {
"access": "public",

View File

@@ -5,7 +5,7 @@ import { Time } from './Utils/Time';
import { PerformanceMonitor } from './Utils/PerformanceMonitor';
import { PoolManager } from './Utils/Pool/PoolManager';
import { DebugManager } from './Utils/Debug';
import { ICoreConfig, IECSDebugConfig } from './Types';
import { ICoreConfig, IECSDebugConfig, RuntimeEnvironment } from './Types';
import { createLogger } from './Utils/Logger';
import { SceneManager } from './ECS/SceneManager';
import { IScene } from './ECS/IScene';
@@ -16,6 +16,7 @@ import { IPlugin } from './Core/Plugin';
import { WorldManager } from './ECS/WorldManager';
import { DebugConfigService } from './Utils/Debug/DebugConfigService';
import { createInstance } from './Core/DI/Decorators';
import { RuntimeConfig } from './RuntimeConfig';
/**
* @zh 游戏引擎核心类
@@ -63,6 +64,53 @@ export class Core {
*/
public static paused = false;
/**
* @zh 运行时环境
* @en Runtime environment
*
* @zh 全局运行时环境设置。所有 Scene 默认继承此值。
* 服务端框架(如 @esengine/server应在启动时设置为 'server'。
* 客户端应用应设置为 'client'。
* 单机游戏使用默认值 'standalone'。
*
* @en Global runtime environment setting. All Scenes inherit this value by default.
* Server frameworks (like @esengine/server) should set this to 'server' at startup.
* Client apps should set this to 'client'.
* Standalone games use the default 'standalone'.
*
* @example
* ```typescript
* // @zh 服务端启动时设置 | @en Set at server startup
* Core.runtimeEnvironment = 'server';
*
* // @zh 或在 Core.create 时配置 | @en Or configure in Core.create
* Core.create({ runtimeEnvironment: 'server' });
* ```
*/
public static get runtimeEnvironment(): RuntimeEnvironment {
return RuntimeConfig.runtimeEnvironment;
}
public static set runtimeEnvironment(value: RuntimeEnvironment) {
RuntimeConfig.runtimeEnvironment = value;
}
/**
* @zh 是否在服务端运行
* @en Whether running on server
*/
public static get isServer(): boolean {
return RuntimeConfig.isServer;
}
/**
* @zh 是否在客户端运行
* @en Whether running on client
*/
public static get isClient(): boolean {
return RuntimeConfig.isClient;
}
/**
* @zh 全局核心实例可能为null表示Core尚未初始化或已被销毁
* @en Global core instance, null means Core is not initialized or destroyed
@@ -133,6 +181,11 @@ export class Core {
this._config = { debug: true, ...config };
this._serviceContainer = new ServiceContainer();
// 设置全局运行时环境
if (config.runtimeEnvironment) {
Core.runtimeEnvironment = config.runtimeEnvironment;
}
this._timerManager = new TimerManager();
this._serviceContainer.registerInstance(TimerManager, this._timerManager);

View File

@@ -10,10 +10,16 @@ import { Int32 } from './Core/SoAStorage';
* @en Components in ECS architecture should be pure data containers.
* All game logic should be implemented in EntitySystem, not inside components.
*
* @zh **重要:所有 Component 子类都必须使用 @ECSComponent 装饰器!**
* @zh 该装饰器用于注册组件类型名称,是序列化、网络同步等功能正常工作的前提。
* @en **IMPORTANT: All Component subclasses MUST use the @ECSComponent decorator!**
* @en This decorator registers the component type name, which is required for serialization, network sync, etc.
*
* @example
* @zh 推荐做法:纯数据组件
* @en Recommended: Pure data component
* @zh 正确做法:使用 @ECSComponent 装饰器
* @en Correct: Use @ECSComponent decorator
* ```typescript
* @ECSComponent('HealthComponent')
* class HealthComponent extends Component {
* public health: number = 100;
* public maxHealth: number = 100;

View File

@@ -0,0 +1,188 @@
/**
* @zh 运行时环境装饰器
* @en Runtime Environment Decorators
*
* @zh 提供 @ServerOnly 和 @ClientOnly 装饰器,用于标记只在特定环境执行的方法
* @en Provides @ServerOnly and @ClientOnly decorators to mark methods that only execute in specific environments
*/
import type { EntitySystem } from '../Systems/EntitySystem';
/**
* @zh 服务端专用方法装饰器
* @en Server-only method decorator
*
* @zh 被装饰的方法只会在服务端环境执行scene.isServer === true
* 在客户端或单机模式下,方法调用会被静默跳过。
*
* @en Decorated methods only execute in server environment (scene.isServer === true).
* In client or standalone mode, method calls are silently skipped.
*
* @example
* ```typescript
* class CollectibleSpawnSystem extends EntitySystem {
* @ServerOnly()
* private checkCollections(players: readonly Entity[]): void {
* // 只在服务端执行收集检测
* // Only check collections on server
* for (const entity of this.scene.entities.buffer) {
* // ...
* }
* }
* }
* ```
*/
export function ServerOnly(): MethodDecorator {
return function <T>(
_target: object,
_propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> | void {
const originalMethod = descriptor.value as unknown as (...args: unknown[]) => unknown;
if (typeof originalMethod !== 'function') {
throw new Error(`@ServerOnly can only be applied to methods, not ${typeof originalMethod}`);
}
descriptor.value = function (this: EntitySystem, ...args: unknown[]): unknown {
if (!this.scene?.isServer) {
return undefined;
}
return originalMethod.apply(this, args);
} as unknown as T;
return descriptor;
};
}
/**
* @zh 客户端专用方法装饰器
* @en Client-only method decorator
*
* @zh 被装饰的方法只会在客户端环境执行scene.isClient === true
* 在服务端或单机模式下,方法调用会被静默跳过。
*
* @en Decorated methods only execute in client environment (scene.isClient === true).
* In server or standalone mode, method calls are silently skipped.
*
* @example
* ```typescript
* class RenderSystem extends EntitySystem {
* @ClientOnly()
* private updateVisuals(): void {
* // 只在客户端执行渲染逻辑
* // Only update visuals on client
* }
* }
* ```
*/
export function ClientOnly(): MethodDecorator {
return function <T>(
_target: object,
_propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> | void {
const originalMethod = descriptor.value as unknown as (...args: unknown[]) => unknown;
if (typeof originalMethod !== 'function') {
throw new Error(`@ClientOnly can only be applied to methods, not ${typeof originalMethod}`);
}
descriptor.value = function (this: EntitySystem, ...args: unknown[]): unknown {
if (!this.scene?.isClient) {
return undefined;
}
return originalMethod.apply(this, args);
} as unknown as T;
return descriptor;
};
}
/**
* @zh 非客户端环境方法装饰器
* @en Non-client method decorator
*
* @zh 被装饰的方法在服务端和单机模式下执行,但不在客户端执行。
* 用于需要在服务端和单机都运行,但客户端跳过的逻辑。
*
* @en Decorated methods execute in server and standalone mode, but not on client.
* Used for logic that should run on server and standalone, but skip on client.
*
* @example
* ```typescript
* class SpawnSystem extends EntitySystem {
* @NotClient()
* private spawnEntities(): void {
* // 服务端和单机模式执行,客户端跳过
* // Execute on server and standalone, skip on client
* }
* }
* ```
*/
export function NotClient(): MethodDecorator {
return function <T>(
_target: object,
_propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> | void {
const originalMethod = descriptor.value as unknown as (...args: unknown[]) => unknown;
if (typeof originalMethod !== 'function') {
throw new Error(`@NotClient can only be applied to methods, not ${typeof originalMethod}`);
}
descriptor.value = function (this: EntitySystem, ...args: unknown[]): unknown {
if (this.scene?.isClient) {
return undefined;
}
return originalMethod.apply(this, args);
} as unknown as T;
return descriptor;
};
}
/**
* @zh 非服务端环境方法装饰器
* @en Non-server method decorator
*
* @zh 被装饰的方法在客户端和单机模式下执行,但不在服务端执行。
* 用于需要在客户端和单机都运行,但服务端跳过的逻辑(如渲染、音效)。
*
* @en Decorated methods execute in client and standalone mode, but not on server.
* Used for logic that should run on client and standalone, but skip on server (like rendering, audio).
*
* @example
* ```typescript
* class AudioSystem extends EntitySystem {
* @NotServer()
* private playSound(): void {
* // 客户端和单机模式执行,服务端跳过
* // Execute on client and standalone, skip on server
* }
* }
* ```
*/
export function NotServer(): MethodDecorator {
return function <T>(
_target: object,
_propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> | void {
const originalMethod = descriptor.value as unknown as (...args: unknown[]) => unknown;
if (typeof originalMethod !== 'function') {
throw new Error(`@NotServer can only be applied to methods, not ${typeof originalMethod}`);
}
descriptor.value = function (this: EntitySystem, ...args: unknown[]): unknown {
if (this.scene?.isServer) {
return undefined;
}
return originalMethod.apply(this, args);
} as unknown as T;
return descriptor;
};
}

View File

@@ -19,6 +19,7 @@ import {
type ComponentEditorOptions,
type ComponentType
} from '../Core/ComponentStorage/ComponentTypeUtils';
import { SYNC_METADATA, type SyncMetadata } from '../Sync/types';
/**
* 存储系统类型名称的Symbol键
@@ -138,6 +139,14 @@ export function ECSComponent(typeName: string, options?: ComponentOptions) {
metadata[COMPONENT_EDITOR_OPTIONS] = options.editor;
}
// 更新 @sync 装饰器创建的 SYNC_METADATA.typeId如果存在
// Update SYNC_METADATA.typeId created by @sync decorator (if exists)
// Property decorators execute before class decorators, so @sync may have used constructor.name
const syncMeta = (target as any)[SYNC_METADATA] as SyncMetadata | undefined;
if (syncMeta) {
syncMeta.typeId = typeName;
}
// 自动注册到全局 ComponentRegistry使组件可以通过名称查找
// Auto-register to GlobalComponentRegistry, enabling lookup by name
GlobalComponentRegistry.register(target);

View File

@@ -82,3 +82,14 @@ export {
hasSchedulingMetadata,
SCHEDULING_METADATA
} from './SystemScheduling';
// ============================================================================
// Runtime Environment Decorators
// 运行时环境装饰器
// ============================================================================
export {
ServerOnly,
ClientOnly,
NotServer,
NotClient
} from './RuntimeEnvironment';

View File

@@ -7,14 +7,7 @@ import { getComponentInstanceTypeName, getComponentTypeName } from './Decorators
import { generateGUID } from '../Utils/GUID';
import type { IScene } from './IScene';
import { EntityHandle, NULL_HANDLE } from './Core/EntityHandle';
/**
* @zh 组件活跃状态变化接口
* @en Interface for component active state change
*/
interface IActiveChangeable {
onActiveChanged(): void;
}
import { ECSEventType } from './CoreEvents';
/**
* @zh 比较两个实体的优先级
@@ -482,9 +475,10 @@ export class Entity {
}
if (this.scene.eventSystem) {
this.scene.eventSystem.emitSync('component:added', {
this.scene.eventSystem.emitSync(ECSEventType.COMPONENT_ADDED, {
timestamp: Date.now(),
source: 'Entity',
entity: this,
entityId: this.id,
entityName: this.name,
entityTag: this.tag?.toString(),
@@ -639,7 +633,7 @@ export class Entity {
component.entityId = null;
if (this.scene?.eventSystem) {
this.scene.eventSystem.emitSync('component:removed', {
this.scene.eventSystem.emitSync(ECSEventType.COMPONENT_REMOVED, {
timestamp: Date.now(),
source: 'Entity',
entityId: this.id,
@@ -770,19 +764,23 @@ export class Entity {
}
/**
* 活跃状态改变时的回调
* @zh 活跃状态改变时的回调
* @en Callback when active state changes
*
* @zh 通过事件系统发出 ENTITY_ENABLED 或 ENTITY_DISABLED 事件,
* 组件可以通过监听这些事件来响应实体状态变化。
* @en Emits ENTITY_ENABLED or ENTITY_DISABLED event through the event system.
* Components can listen to these events to respond to entity state changes.
*/
private onActiveChanged(): void {
for (const component of this.components) {
if ('onActiveChanged' in component && typeof component.onActiveChanged === 'function') {
(component as IActiveChangeable).onActiveChanged();
}
}
if (this.scene?.eventSystem) {
const eventType = this._active
? ECSEventType.ENTITY_ENABLED
: ECSEventType.ENTITY_DISABLED;
if (this.scene && this.scene.eventSystem) {
this.scene.eventSystem.emitSync('entity:activeChanged', {
this.scene.eventSystem.emitSync(eventType, {
entity: this,
active: this._active
scene: this.scene,
});
}
}
@@ -801,6 +799,15 @@ export class Entity {
this._isDestroyed = true;
// 在清理之前发出销毁事件(组件仍然可访问)
if (this.scene?.eventSystem) {
this.scene.eventSystem.emitSync(ECSEventType.ENTITY_DESTROYED, {
entity: this,
entityId: this.id,
scene: this.scene,
});
}
if (this.scene && this.scene.referenceTracker) {
this.scene.referenceTracker.clearReferencesTo(this.id);
this.scene.referenceTracker.unregisterEntityScene(this.id);

View File

@@ -12,6 +12,10 @@ import type { ServiceContainer, ServiceType } from '../Core/ServiceContainer';
import type { TypedQueryBuilder } from './Core/Query/TypedQuery';
import type { SceneSerializationOptions, SceneDeserializationOptions } from './Serialization/SceneSerializer';
import type { IncrementalSnapshot, IncrementalSerializationOptions } from './Serialization/IncrementalSerializer';
import type { RuntimeEnvironment } from '../Types';
// Re-export for convenience
export type { RuntimeEnvironment };
/**
* 场景接口定义
@@ -113,6 +117,27 @@ export type IScene = {
*/
isEditorMode: boolean;
/**
* @zh 运行时环境
* @en Runtime environment
*
* @zh 标识场景运行在服务端、客户端还是单机模式
* @en Indicates whether scene runs on server, client, or standalone mode
*/
readonly runtimeEnvironment: RuntimeEnvironment;
/**
* @zh 是否在服务端运行
* @en Whether running on server
*/
readonly isServer: boolean;
/**
* @zh 是否在客户端运行
* @en Whether running on client
*/
readonly isClient: boolean;
/**
* 获取系统列表
*/
@@ -395,4 +420,18 @@ export type ISceneConfig = {
* @default 10
*/
maxSystemErrorCount?: number;
/**
* @zh 运行时环境
* @en Runtime environment
*
* @zh 用于区分场景运行在服务端、客户端还是单机模式。
* 配合 @ServerOnly / @ClientOnly 装饰器使用,可以让系统方法只在特定环境执行。
*
* @en Used to distinguish whether scene runs on server, client, or standalone mode.
* Works with @ServerOnly / @ClientOnly decorators to make system methods execute only in specific environments.
*
* @default 'standalone'
*/
runtimeEnvironment?: RuntimeEnvironment;
}

View File

@@ -12,7 +12,8 @@ import type { IComponentRegistry } from './Core/ComponentStorage';
import { QuerySystem } from './Core/QuerySystem';
import { TypeSafeEventSystem } from './Core/EventSystem';
import { ReferenceTracker } from './Core/ReferenceTracker';
import { IScene, ISceneConfig } from './IScene';
import { IScene, ISceneConfig, RuntimeEnvironment } from './IScene';
import { RuntimeConfig } from '../RuntimeConfig';
import { getComponentInstanceTypeName, getSystemInstanceTypeName, getSystemMetadata, getSystemInstanceMetadata } from './Decorators';
import { TypedQueryBuilder } from './Core/Query/TypedQuery';
import {
@@ -180,6 +181,45 @@ export class Scene implements IScene {
*/
public isEditorMode: boolean = false;
/**
* @zh 场景级别的运行时环境覆盖
* @en Scene-level runtime environment override
*
* @zh 如果未设置,则从 Core.runtimeEnvironment 读取
* @en If not set, reads from Core.runtimeEnvironment
*/
private _runtimeEnvironmentOverride: RuntimeEnvironment | undefined;
/**
* @zh 获取运行时环境
* @en Get runtime environment
*
* @zh 优先返回场景级别设置,否则返回 Core 全局设置
* @en Returns scene-level setting if set, otherwise returns Core global setting
*/
public get runtimeEnvironment(): RuntimeEnvironment {
if (this._runtimeEnvironmentOverride) {
return this._runtimeEnvironmentOverride;
}
return RuntimeConfig.runtimeEnvironment;
}
/**
* @zh 是否在服务端运行
* @en Whether running on server
*/
public get isServer(): boolean {
return this.runtimeEnvironment === 'server';
}
/**
* @zh 是否在客户端运行
* @en Whether running on client
*/
public get isClient(): boolean {
return this.runtimeEnvironment === 'client';
}
/**
* 延迟的组件生命周期回调队列
*
@@ -398,6 +438,11 @@ export class Scene implements IScene {
this._logger = createLogger('Scene');
this._maxErrorCount = config?.maxSystemErrorCount ?? 10;
// 只有显式指定时才覆盖,否则从 Core 读取
if (config?.runtimeEnvironment) {
this._runtimeEnvironmentOverride = config.runtimeEnvironment;
}
if (config?.name) {
this.name = config.name;
}
@@ -508,7 +553,9 @@ export class Scene implements IScene {
}
/**
* 更新场景
* @zh 更新场景
* @en Update scene
* @internal 由 SceneManager 或 World 调用,用户不应直接调用
*/
public update() {
this.epochManager.increment();

Some files were not shown because too many files have changed in this diff Show More