Compare commits

...

73 Commits

Author SHA1 Message Date
github-actions[bot]
f2c3a24404 chore: release packages (#437)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-04 17:27:10 +08:00
yhh
3bfb8a1c9b chore: add changeset for node-editor box selection feature 2026-01-04 17:24:20 +08:00
yhh
2ee8d87647 feat(node-editor): add box selection and variable node error states
- Add box selection (drag on empty canvas to select multiple nodes)
- Support Ctrl+drag for additive selection
- Add error state styling for invalid variable references (red border, warning icon)
- Support dynamic node title via data.displayTitle
- Support hiding inputs via data.hiddenInputs array
2026-01-04 17:22:20 +08:00
github-actions[bot]
2d537dc10c chore: release packages (#436)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-04 16:25:34 +08:00
YHH
c2acd14fce feat(blueprint): Add Component nodes + ECS refactor + node-editor fixes (#435)
* feat(blueprint): refactor BlueprintComponent as proper ECS Component

- Convert BlueprintComponent from interface to actual ECS Component class
- Add ready-to-use BlueprintSystem that extends EntitySystem
- Remove deprecated legacy APIs (createBlueprintSystem, etc.)
- Update all blueprint documentation (Chinese & English)
- Simplify user API: just add BlueprintSystem and BlueprintComponent

BREAKING CHANGE: BlueprintComponent is now a class extending Component,
not an interface. Use `new BlueprintComponent()` instead of
`createBlueprintComponentData()`.

* chore(blueprint): add changeset for ECS component refactor

* fix(node-editor): fix connections not rendering when node is collapsed

- getPinPosition now returns node header position when pin element is not found
- Added collapsedNodesKey to force re-render connections after collapse/expand
- Input pins connect to left side, output pins to right side of collapsed nodes

* chore(node-editor): add changeset for collapse connection fix

* feat(blueprint): add Add Component nodes for entity-component creation

- Add type-specific Add_ComponentName nodes via ComponentNodeGenerator
- Add generic ECS_AddComponent node for dynamic component creation
- Add ExecutionContext.getComponentClass() for component lookup
- Add registerComponentClass() helper for manual component registration
- Each Add node supports initial property values from @BlueprintProperty

* docs: update changeset with Add Component feature

* feat(blueprint): improve event nodes with Self output and auto-create BeginPlay

- Event Begin Play now outputs Self entity
- Event Tick now outputs Self entity + Delta Seconds
- Event End Play now outputs Self entity
- createEmptyBlueprint() now includes Event Begin Play by default
- Added menuPath to all event nodes for better organization

* docs: update changeset for auto component registration

* feat(blueprint): add variable nodes (Get/Set Variable)

* docs: update changeset with variable nodes
2026-01-04 16:22:59 +08:00
github-actions[bot]
7f631793d4 chore: release packages (#434)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-04 11:52:43 +08:00
YHH
2e84942ea1 feat(blueprint): refactor BlueprintComponent as proper ECS Component (#433)
* feat(blueprint): refactor BlueprintComponent as proper ECS Component

- Convert BlueprintComponent from interface to actual ECS Component class
- Add ready-to-use BlueprintSystem that extends EntitySystem
- Remove deprecated legacy APIs (createBlueprintSystem, etc.)
- Update all blueprint documentation (Chinese & English)
- Simplify user API: just add BlueprintSystem and BlueprintComponent

BREAKING CHANGE: BlueprintComponent is now a class extending Component,
not an interface. Use `new BlueprintComponent()` instead of
`createBlueprintComponentData()`.

* chore(blueprint): add changeset for ECS component refactor

* fix(node-editor): fix connections not rendering when node is collapsed

- getPinPosition now returns node header position when pin element is not found
- Added collapsedNodesKey to force re-render connections after collapse/expand
- Input pins connect to left side, output pins to right side of collapsed nodes

* chore(node-editor): add changeset for collapse connection fix

* feat(blueprint): add Add Component nodes for entity-component creation

- Add type-specific Add_ComponentName nodes via ComponentNodeGenerator
- Add generic ECS_AddComponent node for dynamic component creation
- Add ExecutionContext.getComponentClass() for component lookup
- Add registerComponentClass() helper for manual component registration
- Each Add node supports initial property values from @BlueprintProperty

* docs: update changeset with Add Component feature

* feat(blueprint): improve event nodes with Self output and auto-create BeginPlay

- Event Begin Play now outputs Self entity
- Event Tick now outputs Self entity + Delta Seconds
- Event End Play now outputs Self entity
- createEmptyBlueprint() now includes Event Begin Play by default
- Added menuPath to all event nodes for better organization
2026-01-04 11:50:16 +08:00
YHH
d0057333a7 feat(blueprint): refactor BlueprintComponent as proper ECS Component (#432)
- Convert BlueprintComponent from interface to actual ECS Component class
- Add ready-to-use BlueprintSystem that extends EntitySystem
- Remove deprecated legacy APIs (createBlueprintSystem, etc.)
- Update all blueprint documentation (Chinese & English)
- Simplify user API: just add BlueprintSystem and BlueprintComponent

BREAKING CHANGE: BlueprintComponent is now a class extending Component,
not an interface. Use `new BlueprintComponent()` instead of
`createBlueprintComponentData()`.
2026-01-04 09:53:28 +08:00
github-actions[bot]
54c8ff4d8f chore: release packages (#431)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-03 19:28:34 +08:00
YHH
caf3be72cd feat(blueprint, node-editor): 重构蓝图装饰器系统,添加 Shadow DOM 支持 (#430)
**blueprint**
- 移除 Reflect.getMetadata 依赖,装饰器要求显式指定类型
- 新增 ECS 节点:Entity、Component、Flow 控制节点
- 新增组件自动注册系统 (BlueprintExpose, BlueprintProperty, BlueprintMethod)
- 删除未实现的事件节点占位文件

**node-editor**
- 新增 injectNodeEditorStyles() 函数支持 Shadow DOM 样式注入
- 导出 nodeEditorCssText 用于手动样式注入
2026-01-03 19:24:34 +08:00
github-actions[bot]
ec3e449681 chore: release packages (#429)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-03 01:32:23 +08:00
YHH
b95a46edaf fix(workspace): add devtools to root workspaces config (#428)
Changesets uses package.json workspaces field, not pnpm-workspace.yaml.
This was causing the node-editor package to not be found during publish.
2026-01-03 01:23:26 +08:00
YHH
f493f2d6cc fix(node-editor): enable npm publishing (#427)
- Remove private flag from package.json
- Add node-editor to CI build list
2026-01-03 01:15:52 +08:00
YHH
6970394717 chore(changeset): add changeset for node-editor release (#426)
* refactor(node-editor): move to packages/devtools for standalone use

- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins

* fix(changeset): remove node-editor from ignore list for publishing

* fix(changeset): remove invalid changeset file

* chore(changeset): add changeset for node-editor release
2026-01-03 01:02:09 +08:00
YHH
0e4b66aac4 fix(changeset): remove invalid changeset file (#425)
* refactor(node-editor): move to packages/devtools for standalone use

- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins

* fix(changeset): remove node-editor from ignore list for publishing

* fix(changeset): remove invalid changeset file
2026-01-03 00:30:30 +08:00
YHH
7399e91a5b fix(changeset): remove node-editor from ignore list (#424)
* refactor(node-editor): move to packages/devtools for standalone use

- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins

* fix(changeset): remove node-editor from ignore list for publishing
2026-01-02 22:05:38 +08:00
YHH
c84addaa0b refactor(node-editor): move to packages/devtools for standalone use (#423)
- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins
2026-01-02 21:58:28 +08:00
github-actions[bot]
61da38faf5 chore: release packages (#422)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-02 17:23:17 +08:00
YHH
f333b81298 feat(server): add Schema validation system and binary encoding optimization (#421)
* feat(server): add distributed room support

- Add DistributedRoomManager for multi-server room management
- Add MemoryAdapter for testing and standalone mode
- Add RedisAdapter for production multi-server deployments
- Add LoadBalancedRouter with 5 load balancing strategies
- Add distributed config option to createServer
- Add $redirect message for cross-server player redirection
- Add failover mechanism for automatic room recovery
- Add room:migrated and server:draining event types
- Update documentation (zh/en)

* feat(server): add Schema validation system and binary encoding optimization

## Schema Validation System
- Add lightweight schema validation system (s.object, s.string, s.number, etc.)
- Support auto type inference with Infer<> generic
- Integrate schema validation into API/message handlers
- Add defineApiWithSchema and defineMsgWithSchema helpers

## Binary Encoding Optimization
- Add native WebSocket binary frame support via sendBinary()
- Add PacketType.Binary for efficient binary data transmission
- Optimize ECSRoom.broadcastBinary() to use native binary

## Architecture Improvements
- Extract BaseValidator to separate file to eliminate code duplication
- Add ECSRoom export to main index.ts for better discoverability
- Add Core.worldManager initialization check in ECSRoom constructor
- Remove deprecated validate field from ApiDefinition (use schema instead)

## Documentation
- Add Schema validation documentation in Chinese and English

* fix(rpc): resolve ESLint warnings with proper types

- Replace `any` with proper WebSocket type in connection.ts
- Add IncomingMessage type for request handling in index.ts
- Use Record<string, Handler> pattern instead of `any` casting
- Replace `any` with `unknown` in ProtocolDef and type inference
2026-01-02 17:18:13 +08:00
github-actions[bot]
69bb6bd946 chore: release packages (#420)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-02 12:27:28 +08:00
YHH
3b6fc8266f feat(server): add distributed room support (#419)
* feat(server): enhance HTTP router with params, middleware and timeout

- Add route parameter support (/users/:id → req.params.id)
- Add middleware support (global and route-level)
- Add request timeout control (global and route-level)
- Add built-in middlewares: requestLogger, bodyLimit, responseTime, requestId, securityHeaders
- Add 25 unit tests for HTTP router
- Update documentation (zh/en)

* chore: add changeset for HTTP router enhancement

* fix(server): prevent CORS credential leak vulnerability

- Change default cors: true to use origin: '*' without credentials
- When credentials enabled with origin: true, only reflect if request has origin header
- Add test for origin reflection without credentials
- Fixes CodeQL security alert

* fix(server): prevent CORS credential leak with wildcard/reflect origin

Security fix for CodeQL alert: CORS credential leak vulnerability.

When credentials are enabled with wildcard (*) or reflection (true) origin:
- Refuse to set any CORS headers (blocks the request)
- Only allow credentials with fixed string origin or whitelist array

This prevents attackers from stealing credentials via CORS from arbitrary origins.

Added 4 security tests to verify the fix.

* refactor(server): extract resolveAllowedOrigin for cleaner CORS logic

* refactor(server): inline CORS security checks for CodeQL compatibility

* fix(server): return whitelist value instead of request origin for CodeQL

* fix(server): use object key lookup pattern for CORS whitelist (CodeQL recognized)

* fix(server): skip null origin in reflect mode for additional security

* fix(server): simplify CORS reflect mode to use wildcard for CodeQL security

The reflect mode (cors.origin === true) now uses '*' instead of
reflecting the request origin. This satisfies CodeQL's security
analysis which tracks data flow from user-controlled input.

Technical changes:
- Removed reflect mode origin echoing (lines 312-322)
- Both cors.origin === true and cors.origin === '*' now set '*'
- Updated test to expect '*' instead of reflected origin

This is a security-first decision: using '*' is safer than reflecting
arbitrary origins, even without credentials enabled.

* fix(server): add lgtm suppression for configured CORS origin

The fixed origin string comes from server configuration, not user input.
Added lgtm annotation to suppress CodeQL false positive.

* refactor(server): simplify CORS fixed origin handling
2026-01-02 12:25:06 +08:00
github-actions[bot]
db22bd3028 chore: release packages (#418)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-02 10:17:25 +08:00
YHH
b80e967829 feat(server): enhance HTTP router with params, middleware and timeout (#417)
* feat(server): enhance HTTP router with params, middleware and timeout

- Add route parameter support (/users/:id → req.params.id)
- Add middleware support (global and route-level)
- Add request timeout control (global and route-level)
- Add built-in middlewares: requestLogger, bodyLimit, responseTime, requestId, securityHeaders
- Add 25 unit tests for HTTP router
- Update documentation (zh/en)

* chore: add changeset for HTTP router enhancement

* fix(server): prevent CORS credential leak vulnerability

- Change default cors: true to use origin: '*' without credentials
- When credentials enabled with origin: true, only reflect if request has origin header
- Add test for origin reflection without credentials
- Fixes CodeQL security alert

* fix(server): prevent CORS credential leak with wildcard/reflect origin

Security fix for CodeQL alert: CORS credential leak vulnerability.

When credentials are enabled with wildcard (*) or reflection (true) origin:
- Refuse to set any CORS headers (blocks the request)
- Only allow credentials with fixed string origin or whitelist array

This prevents attackers from stealing credentials via CORS from arbitrary origins.

Added 4 security tests to verify the fix.

* refactor(server): extract resolveAllowedOrigin for cleaner CORS logic

* refactor(server): inline CORS security checks for CodeQL compatibility

* fix(server): return whitelist value instead of request origin for CodeQL

* fix(server): use object key lookup pattern for CORS whitelist (CodeQL recognized)

* fix(server): skip null origin in reflect mode for additional security

* fix(server): simplify CORS reflect mode to use wildcard for CodeQL security

The reflect mode (cors.origin === true) now uses '*' instead of
reflecting the request origin. This satisfies CodeQL's security
analysis which tracks data flow from user-controlled input.

Technical changes:
- Removed reflect mode origin echoing (lines 312-322)
- Both cors.origin === true and cors.origin === '*' now set '*'
- Updated test to expect '*' instead of reflected origin

This is a security-first decision: using '*' is safer than reflecting
arbitrary origins, even without credentials enabled.

* fix(server): add lgtm suppression for configured CORS origin

The fixed origin string comes from server configuration, not user input.
Added lgtm annotation to suppress CodeQL false positive.

* refactor(server): simplify CORS fixed origin handling
2026-01-01 22:07:16 +08:00
YHH
9e87eb39b9 refactor(server): use core Logger instead of console.log (#416)
* refactor(server): use core Logger instead of console.log

- Add logger.ts module wrapping @esengine/ecs-framework's createLogger
- Replace all console.log/warn/error with structured logger calls
- Add @esengine/ecs-framework as dependency for Logger support
- Fix type errors in auth/providers.test.ts and ECSRoom.test.ts
- Refactor withRateLimit mixin with elegant type helper functions

* chore: update pnpm-lock.yaml

* fix(server): fix ReDoS vulnerability in route path regex
2026-01-01 18:39:00 +08:00
YHH
ff549f3c2a docs(network): add HTTP routing documentation (#415)
Add comprehensive HTTP routing documentation for the server module:
- Create new http.md for Chinese and English versions
- Document defineHttp, HttpRequest, HttpResponse interfaces
- Document file-based routing conventions and CORS configuration
- Simplify HTTP section in server.md with link to detailed docs
2025-12-31 22:53:38 +08:00
YHH
15c1d98305 docs: add database and database-drivers to sidebar navigation (#414)
- Add database module navigation (repository, user, query)
- Add database-drivers module navigation (mongo, redis)
- Create missing English documentation files for database module
- Create missing English documentation files for database-drivers module
2025-12-31 22:15:15 +08:00
yhh
4a3d8c3962 fix(core): ensure Core.destroy() cleans up scene manager
- Add sceneManager.destroy() call in Core.destroy()
- Update lawn-mower-demo submodule
2025-12-31 21:51:25 +08:00
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
381 changed files with 57303 additions and 3301 deletions

View File

@@ -49,7 +49,6 @@
"@esengine/material-editor",
"@esengine/shader-editor",
"@esengine/world-streaming-editor",
"@esengine/node-editor",
"@esengine/sdk",
"@esengine/worker-generator",
"@esengine/engine"

View File

@@ -57,8 +57,12 @@ jobs:
pnpm --filter "@esengine/rpc" build
pnpm --filter "@esengine/network" build
pnpm --filter "@esengine/server" build
pnpm --filter "@esengine/database-drivers" build
pnpm --filter "@esengine/database" build
pnpm --filter "@esengine/transaction" build
pnpm --filter "@esengine/cli" build
pnpm --filter "create-esengine-server" build
pnpm --filter "@esengine/node-editor" build
- name: Create Release Pull Request or Publish
id: changesets

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,57 @@ export default defineConfig({
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
{ label: 'HTTP 路由', slug: 'modules/network/http', translations: { en: 'HTTP Routing' } },
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
{ label: '状态同步', slug: 'modules/network/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: 'Database' },
items: [
{ label: '概述', slug: 'modules/database', translations: { en: 'Overview' } },
{ label: '仓储模式', slug: 'modules/database/repository', translations: { en: 'Repository' } },
{ label: '用户仓储', slug: 'modules/database/user', translations: { en: 'User Repository' } },
{ label: '查询构建器', slug: 'modules/database/query', translations: { en: 'Query Builder' } },
],
},
{
label: '数据库驱动',
translations: { en: 'Database Drivers' },
items: [
{ label: '概述', slug: 'modules/database-drivers', translations: { en: 'Overview' } },
{ label: 'MongoDB', slug: 'modules/database-drivers/mongo', translations: { en: 'MongoDB' } },
{ label: 'Redis', slug: 'modules/database-drivers/redis', translations: { en: 'Redis' } },
],
},
{
label: '世界流式加载',
translations: { en: 'World Streaming' },
items: [
{ label: '概述', slug: 'modules/world-streaming', translations: { en: 'Overview' } },
{ label: '区块管理', slug: 'modules/world-streaming/chunk-manager', translations: { en: 'Chunk Manager' } },
{ label: '流式系统', slug: 'modules/world-streaming/streaming-system', translations: { en: 'Streaming System' } },
{ label: '序列化', slug: 'modules/world-streaming/serialization', translations: { en: 'Serialization' } },
{ label: '实际示例', slug: 'modules/world-streaming/examples', translations: { en: 'Examples' } },
],
},
],
},
{
@@ -292,6 +342,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

@@ -28,13 +28,13 @@ const MyNodeTemplate: BlueprintNodeTemplate = {
## Implementing Node Executor
```typescript
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, ExecutionResult } from '@esengine/blueprint';
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
// Get input
const value = context.getInput<number>(node.id, 'value');
// Get input (using evaluateInput)
const value = context.evaluateInput(node.id, 'value', 0) as number;
// Execute logic
const result = value * 2;
@@ -100,29 +100,58 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
};
```
## Example: Input Handler Node
## Example: ECS Component Operation Node
```typescript
const InputMoveTemplate: BlueprintNodeTemplate = {
type: 'InputMove',
title: 'Get Movement Input',
category: 'input',
inputs: [],
outputs: [
{ name: 'direction', type: 'vector2', direction: 'output' }
import type { Entity } from '@esengine/ecs-framework';
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
// Custom heal node
const HealEntityTemplate: BlueprintNodeTemplate = {
type: 'HealEntity',
title: 'Heal Entity',
category: 'gameplay',
color: '#22aa22',
description: 'Heal an entity with HealthComponent',
keywords: ['heal', 'health', 'restore'],
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Target' },
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
],
isPure: true
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
]
};
@RegisterNode(InputMoveTemplate)
class InputMoveExecutor implements INodeExecutor {
@RegisterNode(HealEntityTemplate)
class HealEntityExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const input = context.scene.services.get(InputServiceToken);
const direction = {
x: input.getAxis('horizontal'),
y: input.getAxis('vertical')
};
return { outputs: { direction } };
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
if (!entity || entity.isDestroyed) {
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
}
// Get HealthComponent
const health = entity.components.find(c =>
(c.constructor as any).__componentName__ === 'Health'
) as any;
if (health) {
health.current = Math.min(health.current + amount, health.max);
return {
outputs: { newHealth: health.current },
nextExec: 'exec'
};
}
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
}
}
```

View File

@@ -3,85 +3,127 @@ title: "Examples"
description: "ECS integration and best practices"
---
## Player Control Blueprint
## Complete Game Integration Example
```typescript
// Define input handling node
const InputMoveTemplate: BlueprintNodeTemplate = {
type: 'InputMove',
title: 'Get Movement Input',
category: 'input',
inputs: [],
outputs: [
{ name: 'direction', type: 'vector2', direction: 'output' }
],
isPure: true
};
import { Scene, Core, Component, ECSComponent } from '@esengine/ecs-framework';
import {
BlueprintSystem,
BlueprintComponent,
BlueprintExpose,
BlueprintProperty,
BlueprintMethod
} from '@esengine/blueprint';
@RegisterNode(InputMoveTemplate)
class InputMoveExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const input = context.scene.services.get(InputServiceToken);
const direction = {
x: input.getAxis('horizontal'),
y: input.getAxis('vertical')
};
return { outputs: { direction } };
// 1. Define game components
@ECSComponent('Player')
@BlueprintExpose({ displayName: 'Player', category: 'gameplay' })
export class PlayerComponent extends Component {
@BlueprintProperty({ displayName: 'Move Speed', type: 'float' })
moveSpeed: number = 5;
@BlueprintProperty({ displayName: 'Score', type: 'int' })
score: number = 0;
@BlueprintMethod({ displayName: 'Add Score' })
addScore(points: number): void {
this.score += points;
}
}
@ECSComponent('Health')
@BlueprintExpose({ displayName: 'Health', category: 'gameplay' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: 'Current Health' })
current: number = 100;
@BlueprintProperty({ displayName: 'Max Health' })
max: number = 100;
@BlueprintMethod({ displayName: 'Heal' })
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
@BlueprintMethod({ displayName: 'Take Damage' })
takeDamage(amount: number): boolean {
this.current -= amount;
return this.current <= 0;
}
}
// 2. Initialize game
async function initGame() {
const scene = new Scene();
// Add blueprint system
scene.addSystem(new BlueprintSystem());
Core.setScene(scene);
// 3. Create player
const player = scene.createEntity('Player');
player.addComponent(new PlayerComponent());
player.addComponent(new HealthComponent());
// Add blueprint control
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
player.addComponent(blueprint);
}
```
## State Switching Logic
## Custom Node Example
```typescript
// Implement state machine logic in blueprint
const stateBlueprint = createEmptyBlueprint('PlayerState');
import type { Entity } from '@esengine/ecs-framework';
import {
BlueprintNodeTemplate,
BlueprintNode,
ExecutionContext,
ExecutionResult,
INodeExecutor,
RegisterNode
} from '@esengine/blueprint';
// Add state variable
stateBlueprint.variables.push({
name: 'currentState',
type: 'string',
defaultValue: 'idle',
scope: 'instance'
});
// Check state transitions in Tick event
// ... implemented via node connections
```
## Damage Handling System
```typescript
// Custom damage node
const ApplyDamageTemplate: BlueprintNodeTemplate = {
type: 'ApplyDamage',
title: 'Apply Damage',
category: 'combat',
color: '#aa2222',
description: 'Apply damage to entity with Health component',
keywords: ['damage', 'hurt', 'attack'],
menuPath: ['Combat', 'Apply Damage'],
inputs: [
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
{ name: 'target', type: 'entity', direction: 'input' },
{ name: 'amount', type: 'number', direction: 'input', defaultValue: 10 }
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'target', type: 'entity', displayName: 'Target' },
{ name: 'amount', type: 'float', displayName: 'Damage', defaultValue: 10 }
],
outputs: [
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
{ name: 'killed', type: 'boolean', direction: 'output' }
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'killed', type: 'bool', displayName: 'Killed' }
]
};
@RegisterNode(ApplyDamageTemplate)
class ApplyDamageExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const target = context.getInput<Entity>(node.id, 'target');
const amount = context.getInput<number>(node.id, 'amount');
const target = context.evaluateInput(node.id, 'target', context.entity) as Entity;
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
if (!target || target.isDestroyed) {
return { outputs: { killed: false }, nextExec: 'exec' };
}
const health = target.components.find(c =>
(c.constructor as any).__componentName__ === 'Health'
) as any;
const health = target.getComponent(HealthComponent);
if (health) {
health.current -= amount;
const killed = health.current <= 0;
return {
outputs: { killed },
nextExec: 'exec'
};
return { outputs: { killed }, nextExec: 'exec' };
}
return { outputs: { killed: false }, nextExec: 'exec' };
@@ -132,7 +174,8 @@ vm.maxStepsPerFrame = 1000;
```typescript
// Enable debug mode for execution logs
vm.debug = true;
const blueprint = entity.getComponent(BlueprintComponent);
blueprint.debug = true;
// Use Print nodes for intermediate values
// Set breakpoints in editor

View File

@@ -1,8 +1,9 @@
---
title: "Blueprint Visual Scripting"
description: "Visual scripting system deeply integrated with ECS framework"
---
`@esengine/blueprint` provides a full-featured visual scripting system supporting node-based programming, event-driven execution, and blueprint composition.
`@esengine/blueprint` provides a visual scripting system deeply integrated with the ECS framework, supporting node-based programming to control entity behavior.
## Installation
@@ -10,405 +11,141 @@ title: "Blueprint Visual Scripting"
npm install @esengine/blueprint
```
## Core Features
- **Deep ECS Integration** - Built-in Entity and Component operation nodes
- **Auto-generated Component Nodes** - Use decorators to mark components, auto-generate Get/Set/Call nodes
- **Runtime Blueprint Execution** - Efficient virtual machine executes blueprint logic
## Quick Start
### 1. Add Blueprint System
```typescript
import { Scene, Core } from '@esengine/ecs-framework';
import { BlueprintSystem } from '@esengine/blueprint';
// Create scene and add blueprint system
const scene = new Scene();
scene.addSystem(new BlueprintSystem());
// Set scene
Core.setScene(scene);
```
### 2. Add Blueprint to Entity
```typescript
import { BlueprintComponent } from '@esengine/blueprint';
// Create entity
const player = scene.createEntity('Player');
// Add blueprint component
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
blueprint.autoStart = true;
player.addComponent(blueprint);
```
### 3. Mark Components (Auto-generate Blueprint Nodes)
```typescript
import {
createBlueprintSystem,
createBlueprintComponentData,
NodeRegistry,
RegisterNode
BlueprintExpose,
BlueprintProperty,
BlueprintMethod
} from '@esengine/blueprint';
import { Component, ECSComponent } from '@esengine/ecs-framework';
// Create blueprint system
const blueprintSystem = createBlueprintSystem(scene);
@ECSComponent('Health')
@BlueprintExpose({ displayName: 'Health', category: 'gameplay' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: 'Current Health', type: 'float' })
current: number = 100;
// Load blueprint asset
const blueprint = await loadBlueprintAsset('player.bp');
@BlueprintProperty({ displayName: 'Max Health', type: 'float' })
max: number = 100;
// Create blueprint component data
const componentData = createBlueprintComponentData();
componentData.blueprintAsset = blueprint;
@BlueprintMethod({
displayName: 'Heal',
params: [{ name: 'amount', type: 'float' }]
})
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
// Update in game loop
function gameLoop(dt: number) {
blueprintSystem.process(entities, dt);
@BlueprintMethod({ displayName: 'Take Damage' })
takeDamage(amount: number): boolean {
this.current -= amount;
return this.current <= 0;
}
}
```
## Core Concepts
After marking, the following nodes will appear in the blueprint editor:
- **Get Health** - Get Health component
- **Get Current Health** - Get current property
- **Set Current Health** - Set current property
- **Heal** - Call heal method
- **Take Damage** - Call takeDamage method
### Blueprint Asset Structure
## ECS Integration Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Core.update() │
│ ↓ │
│ Scene.updateSystems() │
│ ↓ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ BlueprintSystem │ │
│ │ │ │
│ │ Matcher.all(BlueprintComponent) │ │
│ │ ↓ │ │
│ │ process(entities) → blueprint.tick() for each entity │ │
│ │ ↓ │ │
│ │ BlueprintVM.tick(dt) │ │
│ │ ↓ │ │
│ │ Execute Event/ECS/Flow Nodes │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Node Types
| Category | Description | Color |
|----------|-------------|-------|
| `event` | Event nodes (BeginPlay, Tick, EndPlay) | Red |
| `entity` | ECS entity operations | Blue |
| `component` | ECS component access | Cyan |
| `flow` | Flow control (Branch, Sequence, Loop) | Gray |
| `math` | Math operations | Green |
| `time` | Time utilities (Delay, GetDeltaTime) | Cyan |
| `debug` | Debug utilities (Print) | Gray |
## Blueprint Asset Structure
Blueprints are saved as `.bp` files:
```typescript
interface BlueprintAsset {
version: number; // Format version
type: 'blueprint'; // Asset type
metadata: BlueprintMetadata; // Metadata
variables: BlueprintVariable[]; // Variable definitions
nodes: BlueprintNode[]; // Node instances
connections: BlueprintConnection[]; // Connections
version: number;
type: 'blueprint';
metadata: {
name: string;
description?: string;
};
variables: BlueprintVariable[];
nodes: BlueprintNode[];
connections: BlueprintConnection[];
}
```
### Node Categories
## Documentation Navigation
| Category | Description | Color |
|----------|-------------|-------|
| `event` | Event nodes (entry points) | Red |
| `flow` | Flow control | Gray |
| `entity` | Entity operations | Blue |
| `component` | Component access | Cyan |
| `math` | Math operations | Green |
| `logic` | Logic operations | Red |
| `variable` | Variable access | Purple |
| `time` | Time utilities | Cyan |
| `debug` | Debug utilities | Gray |
### Pin Types
Nodes connect through pins:
```typescript
interface BlueprintPinDefinition {
name: string; // Pin name
type: PinDataType; // Data type
direction: 'input' | 'output';
isExec?: boolean; // Execution pin
defaultValue?: unknown;
}
type PinDataType =
| 'exec' // Execution flow
| 'boolean' // Boolean
| 'number' // Number
| 'string' // String
| 'vector2' // 2D vector
| 'vector3' // 3D vector
| 'entity' // Entity reference
| 'component' // Component reference
| 'any'; // Any type
```
### Variable Scopes
```typescript
type VariableScope =
| 'local' // Per execution
| 'instance' // Per entity
| 'global'; // Shared globally
```
## Virtual Machine API
### BlueprintVM
The virtual machine executes blueprint graphs:
```typescript
import { BlueprintVM } from '@esengine/blueprint';
const vm = new BlueprintVM(blueprintAsset, entity, scene);
vm.start(); // Start (triggers BeginPlay)
vm.tick(deltaTime); // Update (triggers Tick)
vm.stop(); // Stop (triggers EndPlay)
vm.pause();
vm.resume();
// Trigger events
vm.triggerEvent('EventCollision', { other: otherEntity });
vm.triggerCustomEvent('OnDamage', { amount: 50 });
// Debug mode
vm.debug = true;
```
### Execution Context
```typescript
interface ExecutionContext {
blueprint: BlueprintAsset;
entity: Entity;
scene: IScene;
deltaTime: number;
time: number;
getInput<T>(nodeId: string, pinName: string): T;
setOutput(nodeId: string, pinName: string, value: unknown): void;
getVariable<T>(name: string): T;
setVariable(name: string, value: unknown): void;
}
```
### Execution Result
```typescript
interface ExecutionResult {
outputs?: Record<string, unknown>; // Output values
nextExec?: string | null; // Next exec pin
delay?: number; // Delay execution (ms)
yield?: boolean; // Pause until next frame
error?: string; // Error message
}
```
## Custom Nodes
### Define Node Template
```typescript
import { BlueprintNodeTemplate } from '@esengine/blueprint';
const MyNodeTemplate: BlueprintNodeTemplate = {
type: 'MyCustomNode',
title: 'My Custom Node',
category: 'custom',
description: 'A custom node example',
keywords: ['custom', 'example'],
inputs: [
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
{ name: 'value', type: 'number', direction: 'input', defaultValue: 0 }
],
outputs: [
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
{ name: 'result', type: 'number', direction: 'output' }
]
};
```
### Implement Node Executor
```typescript
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = context.getInput<number>(node.id, 'value');
const result = value * 2;
return {
outputs: { result },
nextExec: 'exec'
};
}
}
```
### Registration Methods
```typescript
// Method 1: Decorator
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor { ... }
// Method 2: Manual registration
NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
```
## Node Registry
```typescript
import { NodeRegistry } from '@esengine/blueprint';
const registry = NodeRegistry.instance;
const allTemplates = registry.getAllTemplates();
const mathNodes = registry.getTemplatesByCategory('math');
const results = registry.searchTemplates('add');
if (registry.has('MyCustomNode')) { ... }
```
## Built-in Nodes
### Event Nodes
| Node | Description |
|------|-------------|
| `EventBeginPlay` | Triggered on blueprint start |
| `EventTick` | Triggered every frame |
| `EventEndPlay` | Triggered on blueprint stop |
| `EventCollision` | Triggered on collision |
| `EventInput` | Triggered on input |
| `EventTimer` | Triggered by timer |
### Time Nodes
| Node | Description |
|------|-------------|
| `Delay` | Delay execution |
| `GetDeltaTime` | Get frame delta |
| `GetTime` | Get total runtime |
### Math Nodes
| Node | Description |
|------|-------------|
| `Add`, `Subtract`, `Multiply`, `Divide` | Basic operations |
| `Abs`, `Clamp`, `Lerp`, `Min`, `Max` | Utility functions |
### Debug Nodes
| Node | Description |
|------|-------------|
| `Print` | Print to console |
## Blueprint Composition
### Blueprint Fragments
Encapsulate reusable logic as fragments:
```typescript
import { createFragment } from '@esengine/blueprint';
const healthFragment = createFragment('HealthSystem', {
inputs: [
{ name: 'damage', type: 'number', internalNodeId: 'input1', internalPinName: 'value' }
],
outputs: [
{ name: 'isDead', type: 'boolean', internalNodeId: 'output1', internalPinName: 'value' }
],
graph: { nodes: [...], connections: [...], variables: [...] }
});
```
### Compose Blueprints
```typescript
import { createComposer, FragmentRegistry } from '@esengine/blueprint';
// Register fragments
FragmentRegistry.instance.register('health', healthFragment);
FragmentRegistry.instance.register('movement', movementFragment);
// Create composer
const composer = createComposer('PlayerBlueprint');
// Add fragments to slots
composer.addFragment(healthFragment, 'slot1', { position: { x: 0, y: 0 } });
composer.addFragment(movementFragment, 'slot2', { position: { x: 400, y: 0 } });
// Connect slots
composer.connect('slot1', 'onDeath', 'slot2', 'disable');
// Validate
const validation = composer.validate();
if (!validation.isValid) {
console.error(validation.errors);
}
// Compile to blueprint
const blueprint = composer.compile();
```
## Trigger System
### Define Trigger Conditions
```typescript
import { TriggerCondition, TriggerDispatcher } from '@esengine/blueprint';
const lowHealthCondition: TriggerCondition = {
type: 'comparison',
left: { type: 'variable', name: 'health' },
operator: '<',
right: { type: 'constant', value: 20 }
};
```
### Use Trigger Dispatcher
```typescript
const dispatcher = new TriggerDispatcher();
dispatcher.register('lowHealth', lowHealthCondition, (context) => {
context.triggerEvent('OnLowHealth');
});
dispatcher.evaluate(context);
```
## ECS Integration
### Using Blueprint System
```typescript
import { createBlueprintSystem } from '@esengine/blueprint';
class GameScene {
private blueprintSystem: BlueprintSystem;
initialize() {
this.blueprintSystem = createBlueprintSystem(this.scene);
}
update(dt: number) {
this.blueprintSystem.process(this.entities, dt);
}
}
```
### Triggering Blueprint Events
```typescript
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
```
## Serialization
### Save Blueprint
```typescript
import { validateBlueprintAsset } from '@esengine/blueprint';
function saveBlueprint(blueprint: BlueprintAsset, path: string): void {
if (!validateBlueprintAsset(blueprint)) {
throw new Error('Invalid blueprint structure');
}
const json = JSON.stringify(blueprint, null, 2);
fs.writeFileSync(path, json);
}
```
### Load Blueprint
```typescript
async function loadBlueprint(path: string): Promise<BlueprintAsset> {
const json = await fs.readFile(path, 'utf-8');
const asset = JSON.parse(json);
if (!validateBlueprintAsset(asset)) {
throw new Error('Invalid blueprint file');
}
return asset;
}
```
## Best Practices
1. **Use fragments for reusable logic**
2. **Choose appropriate variable scopes**
- `local`: Temporary calculations
- `instance`: Entity state (e.g., health)
- `global`: Game-wide state
3. **Avoid infinite loops** - VM has max steps per frame (default 1000)
4. **Debug techniques**
- Enable `vm.debug = true` for execution logs
- Use Print nodes for intermediate values
5. **Performance optimization**
- Pure nodes (`isPure: true`) cache outputs
- Avoid heavy computation in Tick
## Documentation
- [Virtual Machine API](./vm) - BlueprintVM execution and context
- [Custom Nodes](./custom-nodes) - Creating custom nodes
- [Built-in Nodes](./nodes) - Built-in node reference
- [Blueprint Composition](./composition) - Fragments and composer
- [Examples](./examples) - ECS integration and best practices
- [Virtual Machine API](./vm) - BlueprintVM and ECS integration
- [ECS Node Reference](./nodes) - Built-in ECS operation nodes
- [Custom Nodes](./custom-nodes) - Create custom ECS nodes
- [Blueprint Composition](./composition) - Fragment reuse
- [Examples](./examples) - ECS game logic examples

View File

@@ -1,107 +1,118 @@
---
title: "Built-in Nodes"
description: "Blueprint built-in node reference"
title: "ECS Node Reference"
description: "Blueprint built-in ECS operation nodes"
---
## Event Nodes
Lifecycle events as blueprint entry points:
| Node | Description |
|------|-------------|
| `EventBeginPlay` | Triggered when blueprint starts |
| `EventTick` | Triggered each frame |
| `EventTick` | Triggered each frame, receives deltaTime |
| `EventEndPlay` | Triggered when blueprint stops |
| `EventCollision` | Triggered on collision |
| `EventInput` | Triggered on input event |
| `EventTimer` | Triggered by timer |
| `EventMessage` | Triggered by custom message |
## Entity Nodes
ECS entity operations:
| Node | Description | Type |
|------|-------------|------|
| `Get Self` | Get entity owning this blueprint | Pure |
| `Create Entity` | Create new entity in scene | Execution |
| `Destroy Entity` | Destroy specified entity | Execution |
| `Destroy Self` | Destroy self entity | Execution |
| `Is Valid` | Check if entity is valid | Pure |
| `Get Entity Name` | Get entity name | Pure |
| `Set Entity Name` | Set entity name | Execution |
| `Get Entity Tag` | Get entity tag | Pure |
| `Set Entity Tag` | Set entity tag | Execution |
| `Set Active` | Set entity active state | Execution |
| `Is Active` | Check if entity is active | Pure |
| `Find Entity By Name` | Find entity by name | Pure |
| `Find Entities By Tag` | Find all entities by tag | Pure |
| `Get Entity ID` | Get entity unique ID | Pure |
| `Find Entity By ID` | Find entity by ID | Pure |
## Component Nodes
ECS component operations:
| Node | Description | Type |
|------|-------------|------|
| `Has Component` | Check if entity has specified component | Pure |
| `Get Component` | Get component from entity | Pure |
| `Get All Components` | Get all components from entity | Pure |
| `Remove Component` | Remove component | Execution |
| `Get Component Property` | Get component property value | Pure |
| `Set Component Property` | Set component property value | Execution |
| `Get Component Type` | Get component type name | Pure |
| `Get Owner Entity` | Get owning entity from component | Pure |
## Flow Control Nodes
Control execution flow:
| Node | Description |
|------|-------------|
| `Branch` | Conditional branch (if/else) |
| `Sequence` | Execute multiple outputs in sequence |
| `ForLoop` | Loop execution |
| `WhileLoop` | Conditional loop |
| `DoOnce` | Execute only once |
| `FlipFlop` | Alternate between two branches |
| `For Loop` | Loop execution |
| `For Each` | Iterate array |
| `While Loop` | Conditional loop |
| `Do Once` | Execute only once |
| `Flip Flop` | Alternate between two branches |
| `Gate` | Toggleable execution gate |
## Time Nodes
| Node | Description |
|------|-------------|
| `Delay` | Delay execution |
| `GetDeltaTime` | Get frame delta time |
| `GetTime` | Get runtime |
| `SetTimer` | Set timer |
| `ClearTimer` | Clear timer |
| Node | Description | Type |
|------|-------------|------|
| `Delay` | Delay execution | Execution |
| `Get Delta Time` | Get frame delta time | Pure |
| `Get Time` | Get total runtime | Pure |
## Math Nodes
| Node | Description |
|------|-------------|
| `Add` | Addition |
| `Subtract` | Subtraction |
| `Multiply` | Multiplication |
| `Divide` | Division |
| `Add` / `Subtract` / `Multiply` / `Divide` | Basic operations |
| `Abs` | Absolute value |
| `Clamp` | Clamp to range |
| `Lerp` | Linear interpolation |
| `Min` / `Max` | Minimum/Maximum |
| `Sin` / `Cos` | Trigonometric functions |
| `Sqrt` | Square root |
| `Power` | Power |
## Logic Nodes
| Node | Description |
|------|-------------|
| `And` | Logical AND |
| `Or` | Logical OR |
| `Not` | Logical NOT |
| `Equal` | Equality comparison |
| `NotEqual` | Inequality comparison |
| `Greater` | Greater than comparison |
| `Less` | Less than comparison |
## Vector Nodes
| Node | Description |
|------|-------------|
| `MakeVector2` | Create 2D vector |
| `BreakVector2` | Break 2D vector |
| `VectorAdd` | Vector addition |
| `VectorSubtract` | Vector subtraction |
| `VectorMultiply` | Vector multiplication |
| `VectorLength` | Vector length |
| `VectorNormalize` | Vector normalization |
| `VectorDistance` | Vector distance |
## Entity Nodes
| Node | Description |
|------|-------------|
| `GetSelf` | Get current entity |
| `GetComponent` | Get component |
| `HasComponent` | Check component |
| `AddComponent` | Add component |
| `RemoveComponent` | Remove component |
| `SpawnEntity` | Create entity |
| `DestroyEntity` | Destroy entity |
## Variable Nodes
| Node | Description |
|------|-------------|
| `GetVariable` | Get variable value |
| `SetVariable` | Set variable value |
## Debug Nodes
| Node | Description |
|------|-------------|
| `Print` | Print to console |
| `DrawDebugLine` | Draw debug line |
| `DrawDebugPoint` | Draw debug point |
| `Breakpoint` | Debug breakpoint |
## Auto-generated Component Nodes
Components marked with `@BlueprintExpose` decorator auto-generate nodes:
```typescript
@ECSComponent('Transform')
@BlueprintExpose({ displayName: 'Transform', category: 'core' })
export class TransformComponent extends Component {
@BlueprintProperty({ displayName: 'X Position' })
x: number = 0;
@BlueprintProperty({ displayName: 'Y Position' })
y: number = 0;
@BlueprintMethod({ displayName: 'Translate' })
translate(dx: number, dy: number): void {
this.x += dx;
this.y += dy;
}
}
```
Generated nodes:
- **Get Transform** - Get Transform component
- **Get X Position** / **Set X Position** - Access x property
- **Get Y Position** / **Set Y Position** - Access y property
- **Translate** - Call translate method

View File

@@ -45,7 +45,7 @@ interface ExecutionContext {
time: number; // Total runtime
// Get input value
getInput<T>(nodeId: string, pinName: string): T;
evaluateInput(nodeId: string, pinName: string, defaultValue: unknown): unknown;
// Set output value
setOutput(nodeId: string, pinName: string, value: unknown): void;
@@ -70,35 +70,33 @@ interface ExecutionResult {
## ECS Integration
### Using Blueprint System
### Using Built-in Blueprint System
```typescript
import { createBlueprintSystem } from '@esengine/blueprint';
import { Scene, Core } from '@esengine/ecs-framework';
import { BlueprintSystem, BlueprintComponent } from '@esengine/blueprint';
class GameScene {
private blueprintSystem: BlueprintSystem;
// Add blueprint system to scene
const scene = new Scene();
scene.addSystem(new BlueprintSystem());
Core.setScene(scene);
initialize() {
this.blueprintSystem = createBlueprintSystem(this.scene);
}
update(dt: number) {
// Process all entities with blueprint components
this.blueprintSystem.process(this.entities, dt);
}
}
// Add blueprint to entity
const entity = scene.createEntity('Player');
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
entity.addComponent(blueprint);
```
### Triggering Blueprint Events
```typescript
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
// Trigger built-in event
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
// Trigger custom event
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
// Get blueprint component from entity and trigger events
const blueprint = entity.getComponent(BlueprintComponent);
if (blueprint?.vm) {
blueprint.vm.triggerEvent('EventCollision', { other: otherEntity });
blueprint.vm.triggerCustomEvent('OnPickup', { item: itemEntity });
}
```
## Serialization

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,265 @@
---
title: "MongoDB Connection"
description: "MongoDB connection management, connection pooling, auto-reconnect"
---
## Configuration Options
```typescript
interface MongoConnectionConfig {
/** MongoDB connection URI */
uri: string
/** Database name */
database: string
/** Connection pool configuration */
pool?: {
minSize?: number // Minimum connections
maxSize?: number // Maximum connections
acquireTimeout?: number // Connection acquire timeout (ms)
maxLifetime?: number // Maximum connection lifetime (ms)
}
/** Auto-reconnect (default true) */
autoReconnect?: boolean
/** Reconnect interval (ms, default 5000) */
reconnectInterval?: number
/** Maximum reconnect attempts (default 10) */
maxReconnectAttempts?: number
}
```
## Complete Example
```typescript
import { createMongoConnection, MongoConnectionToken } from '@esengine/database-drivers'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game',
pool: {
minSize: 5,
maxSize: 20,
acquireTimeout: 5000,
maxLifetime: 300000
},
autoReconnect: true,
reconnectInterval: 5000,
maxReconnectAttempts: 10
})
// Event listeners
mongo.on('connected', () => {
console.log('MongoDB connected')
})
mongo.on('disconnected', () => {
console.log('MongoDB disconnected')
})
mongo.on('reconnecting', () => {
console.log('MongoDB reconnecting...')
})
mongo.on('reconnected', () => {
console.log('MongoDB reconnected')
})
mongo.on('error', (event) => {
console.error('MongoDB error:', event.error)
})
// Connect
await mongo.connect()
// Check status
console.log('Connected:', mongo.isConnected())
console.log('Ping:', await mongo.ping())
```
## IMongoConnection Interface
```typescript
interface IMongoConnection {
/** Connection ID */
readonly id: string
/** Connection state */
readonly state: ConnectionState
/** Establish connection */
connect(): Promise<void>
/** Disconnect */
disconnect(): Promise<void>
/** Check if connected */
isConnected(): boolean
/** Test connection */
ping(): Promise<boolean>
/** Get typed collection */
collection<T extends object>(name: string): IMongoCollection<T>
/** Get database interface */
getDatabase(): IMongoDatabase
/** Get native client (advanced usage) */
getNativeClient(): MongoClientType
/** Get native database (advanced usage) */
getNativeDatabase(): Db
}
```
## IMongoCollection Interface
Type-safe collection interface, decoupled from native MongoDB types:
```typescript
interface IMongoCollection<T extends object> {
readonly name: string
// Query
findOne(filter: object, options?: FindOptions): Promise<T | null>
find(filter: object, options?: FindOptions): Promise<T[]>
countDocuments(filter?: object): Promise<number>
// Insert
insertOne(doc: T): Promise<InsertOneResult>
insertMany(docs: T[]): Promise<InsertManyResult>
// Update
updateOne(filter: object, update: object): Promise<UpdateResult>
updateMany(filter: object, update: object): Promise<UpdateResult>
findOneAndUpdate(
filter: object,
update: object,
options?: FindOneAndUpdateOptions
): Promise<T | null>
// Delete
deleteOne(filter: object): Promise<DeleteResult>
deleteMany(filter: object): Promise<DeleteResult>
// Index
createIndex(
spec: Record<string, 1 | -1>,
options?: IndexOptions
): Promise<string>
}
```
## Usage Examples
### Basic CRUD
```typescript
interface User {
id: string
name: string
email: string
score: number
}
const users = mongo.collection<User>('users')
// Insert
await users.insertOne({
id: '1',
name: 'John',
email: 'john@example.com',
score: 100
})
// Query
const user = await users.findOne({ name: 'John' })
const topUsers = await users.find(
{ score: { $gte: 100 } },
{ sort: { score: -1 }, limit: 10 }
)
// Update
await users.updateOne(
{ id: '1' },
{ $inc: { score: 10 } }
)
// Delete
await users.deleteOne({ id: '1' })
```
### Batch Operations
```typescript
// Batch insert
await users.insertMany([
{ id: '1', name: 'Alice', email: 'alice@example.com', score: 100 },
{ id: '2', name: 'Bob', email: 'bob@example.com', score: 200 },
{ id: '3', name: 'Carol', email: 'carol@example.com', score: 150 }
])
// Batch update
await users.updateMany(
{ score: { $lt: 100 } },
{ $set: { status: 'inactive' } }
)
// Batch delete
await users.deleteMany({ status: 'inactive' })
```
### Index Management
```typescript
// Create indexes
await users.createIndex({ email: 1 }, { unique: true })
await users.createIndex({ score: -1 })
await users.createIndex({ name: 1, score: -1 })
```
## Integration with Other Modules
### With @esengine/database
```typescript
import { createMongoConnection } from '@esengine/database-drivers'
import { UserRepository, createRepository } from '@esengine/database'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game'
})
await mongo.connect()
// Use UserRepository
const userRepo = new UserRepository(mongo)
await userRepo.register({ username: 'john', password: '123456' })
// Use generic repository
const playerRepo = createRepository<Player>(mongo, 'players')
```
### With @esengine/transaction
```typescript
import { createMongoConnection } from '@esengine/database-drivers'
import { createMongoStorage, TransactionManager } from '@esengine/transaction'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game'
})
await mongo.connect()
// Create transaction storage (shared connection)
const storage = createMongoStorage(mongo)
await storage.ensureIndexes()
const txManager = new TransactionManager({ storage })
```

View File

@@ -0,0 +1,228 @@
---
title: "Redis Connection"
description: "Redis connection management, auto-reconnect, key prefix"
---
## Configuration Options
```typescript
interface RedisConnectionConfig {
/** Redis host */
host?: string
/** Redis port */
port?: number
/** Authentication password */
password?: string
/** Database number */
db?: number
/** Key prefix */
keyPrefix?: string
/** Auto-reconnect (default true) */
autoReconnect?: boolean
/** Reconnect interval (ms, default 5000) */
reconnectInterval?: number
/** Maximum reconnect attempts (default 10) */
maxReconnectAttempts?: number
}
```
## Complete Example
```typescript
import { createRedisConnection, RedisConnectionToken } from '@esengine/database-drivers'
const redis = createRedisConnection({
host: 'localhost',
port: 6379,
password: 'your-password',
db: 0,
keyPrefix: 'game:',
autoReconnect: true,
reconnectInterval: 5000,
maxReconnectAttempts: 10
})
// Event listeners
redis.on('connected', () => {
console.log('Redis connected')
})
redis.on('disconnected', () => {
console.log('Redis disconnected')
})
redis.on('error', (event) => {
console.error('Redis error:', event.error)
})
// Connect
await redis.connect()
// Check status
console.log('Connected:', redis.isConnected())
console.log('Ping:', await redis.ping())
```
## IRedisConnection Interface
```typescript
interface IRedisConnection {
/** Connection ID */
readonly id: string
/** Connection state */
readonly state: ConnectionState
/** Establish connection */
connect(): Promise<void>
/** Disconnect */
disconnect(): Promise<void>
/** Check if connected */
isConnected(): boolean
/** Test connection */
ping(): Promise<boolean>
/** Get value */
get(key: string): Promise<string | null>
/** Set value (optional TTL in seconds) */
set(key: string, value: string, ttl?: number): Promise<void>
/** Delete key */
del(key: string): Promise<boolean>
/** Check if key exists */
exists(key: string): Promise<boolean>
/** Set expiration (seconds) */
expire(key: string, seconds: number): Promise<boolean>
/** Get remaining TTL (seconds) */
ttl(key: string): Promise<number>
/** Get native client (advanced usage) */
getNativeClient(): Redis
}
```
## Usage Examples
### Basic Operations
```typescript
// Set value
await redis.set('user:1:name', 'John')
// Set value with expiration (1 hour)
await redis.set('session:abc123', 'user-data', 3600)
// Get value
const name = await redis.get('user:1:name')
// Check if key exists
const exists = await redis.exists('user:1:name')
// Delete key
await redis.del('user:1:name')
// Get remaining TTL
const ttl = await redis.ttl('session:abc123')
```
### Key Prefix
When `keyPrefix` is configured, all operations automatically add the prefix:
```typescript
const redis = createRedisConnection({
host: 'localhost',
keyPrefix: 'game:'
})
// Actual key is 'game:user:1'
await redis.set('user:1', 'data')
// Actual key queried is 'game:user:1'
const data = await redis.get('user:1')
```
### Advanced Operations
Use native client for advanced operations:
```typescript
const client = redis.getNativeClient()
// Using Pipeline
const pipeline = client.pipeline()
pipeline.set('key1', 'value1')
pipeline.set('key2', 'value2')
pipeline.set('key3', 'value3')
await pipeline.exec()
// Using Transactions
const multi = client.multi()
multi.incr('counter')
multi.get('counter')
const results = await multi.exec()
// Using Lua Scripts
const result = await client.eval(
`return redis.call('get', KEYS[1])`,
1,
'mykey'
)
```
## Integration with Transaction System
```typescript
import { createRedisConnection } from '@esengine/database-drivers'
import { RedisStorage, TransactionManager } from '@esengine/transaction'
const redis = createRedisConnection({
host: 'localhost',
port: 6379,
keyPrefix: 'tx:'
})
await redis.connect()
// Create transaction storage
const storage = new RedisStorage({
factory: () => redis.getNativeClient(),
prefix: 'tx:'
})
const txManager = new TransactionManager({ storage })
```
## Connection State
```typescript
type ConnectionState =
| 'disconnected' // Not connected
| 'connecting' // Connecting
| 'connected' // Connected
| 'disconnecting' // Disconnecting
| 'error' // Error state
```
## Events
| Event | Description |
|-------|-------------|
| `connected` | Connection established |
| `disconnected` | Connection closed |
| `reconnecting` | Reconnecting |
| `reconnected` | Reconnection successful |
| `error` | Error occurred |

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

@@ -0,0 +1,185 @@
---
title: "Query Syntax"
description: "Query condition operators and syntax"
---
## Basic Queries
### Exact Match
```typescript
await repo.findMany({
where: {
name: 'John',
status: 'active'
}
})
```
### Using Operators
```typescript
await repo.findMany({
where: {
score: { $gte: 100 },
rank: { $in: ['gold', 'platinum'] }
}
})
```
## Query Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `$eq` | Equal | `{ score: { $eq: 100 } }` |
| `$ne` | Not equal | `{ status: { $ne: 'banned' } }` |
| `$gt` | Greater than | `{ score: { $gt: 50 } }` |
| `$gte` | Greater than or equal | `{ level: { $gte: 10 } }` |
| `$lt` | Less than | `{ age: { $lt: 18 } }` |
| `$lte` | Less than or equal | `{ price: { $lte: 100 } }` |
| `$in` | In array | `{ rank: { $in: ['gold', 'platinum'] } }` |
| `$nin` | Not in array | `{ status: { $nin: ['banned', 'suspended'] } }` |
| `$like` | Pattern match | `{ name: { $like: '%john%' } }` |
| `$regex` | Regex match | `{ email: { $regex: '@gmail.com$' } }` |
## Logical Operators
### $or
```typescript
await repo.findMany({
where: {
$or: [
{ score: { $gte: 1000 } },
{ rank: 'legendary' }
]
}
})
```
### $and
```typescript
await repo.findMany({
where: {
$and: [
{ score: { $gte: 100 } },
{ score: { $lte: 500 } }
]
}
})
```
### Combined Usage
```typescript
await repo.findMany({
where: {
status: 'active',
$or: [
{ rank: 'gold' },
{ score: { $gte: 1000 } }
]
}
})
```
## Pattern Matching
### $like Syntax
- `%` - Matches any sequence of characters
- `_` - Matches single character
```typescript
// Starts with 'John'
{ name: { $like: 'John%' } }
// Ends with 'son'
{ name: { $like: '%son' } }
// Contains 'oh'
{ name: { $like: '%oh%' } }
// Second character is 'o'
{ name: { $like: '_o%' } }
```
### $regex Syntax
Uses standard regular expressions:
```typescript
// Starts with 'John' (case insensitive)
{ name: { $regex: '^john' } }
// Gmail email
{ email: { $regex: '@gmail\\.com$' } }
// Contains numbers
{ username: { $regex: '\\d+' } }
```
## Sorting
```typescript
await repo.findMany({
sort: {
score: 'desc', // Descending
name: 'asc' // Ascending
}
})
```
## Pagination
### Using limit/offset
```typescript
// First page
await repo.findMany({
limit: 20,
offset: 0
})
// Second page
await repo.findMany({
limit: 20,
offset: 20
})
```
### Using findPaginated
```typescript
const result = await repo.findPaginated(
{ page: 2, pageSize: 20 },
{ sort: { createdAt: 'desc' } }
)
```
## Complete Examples
```typescript
// Find active gold players with scores between 100-1000
// Sort by score descending, get top 10
const players = await repo.findMany({
where: {
status: 'active',
rank: 'gold',
score: { $gte: 100, $lte: 1000 }
},
sort: { score: 'desc' },
limit: 10
})
// Search for users with 'john' in username or gmail email
const users = await repo.findMany({
where: {
$or: [
{ username: { $like: '%john%' } },
{ email: { $regex: '@gmail\\.com$' } }
]
}
})
```

View File

@@ -0,0 +1,244 @@
---
title: "Repository API"
description: "Generic repository interface, CRUD operations, pagination, soft delete"
---
## Creating a Repository
### Using Factory Function
```typescript
import { createRepository } from '@esengine/database'
const playerRepo = createRepository<Player>(mongo, 'players')
// Enable soft delete
const playerRepo = createRepository<Player>(mongo, 'players', true)
```
### Extending Repository
```typescript
import { Repository, BaseEntity } from '@esengine/database'
interface Player extends BaseEntity {
name: string
score: number
}
class PlayerRepository extends Repository<Player> {
constructor(connection: IMongoConnection) {
super(connection, 'players', false) // Third param: enable soft delete
}
// Add custom methods
async findTopPlayers(limit: number): Promise<Player[]> {
return this.findMany({
sort: { score: 'desc' },
limit
})
}
}
```
## BaseEntity Interface
All entities must extend `BaseEntity`:
```typescript
interface BaseEntity {
id: string
createdAt: Date
updatedAt: Date
deletedAt?: Date // Used for soft delete
}
```
## Query Methods
### findById
```typescript
const player = await repo.findById('player-123')
```
### findOne
```typescript
const player = await repo.findOne({
where: { name: 'John' }
})
const topPlayer = await repo.findOne({
sort: { score: 'desc' }
})
```
### findMany
```typescript
// Simple query
const players = await repo.findMany({
where: { rank: 'gold' }
})
// Complex query
const players = await repo.findMany({
where: {
score: { $gte: 100 },
rank: { $in: ['gold', 'platinum'] }
},
sort: { score: 'desc', name: 'asc' },
limit: 10,
offset: 0
})
```
### findPaginated
```typescript
const result = await repo.findPaginated(
{ page: 1, pageSize: 20 },
{
where: { rank: 'gold' },
sort: { score: 'desc' }
}
)
console.log(result.data) // Player[]
console.log(result.total) // Total count
console.log(result.totalPages) // Total pages
console.log(result.hasNext) // Has next page
console.log(result.hasPrev) // Has previous page
```
### count
```typescript
const count = await repo.count({
where: { rank: 'gold' }
})
```
### exists
```typescript
const exists = await repo.exists({
where: { email: 'john@example.com' }
})
```
## Create Methods
### create
```typescript
const player = await repo.create({
name: 'John',
score: 0
})
// Automatically generates id, createdAt, updatedAt
```
### createMany
```typescript
const players = await repo.createMany([
{ name: 'Alice', score: 100 },
{ name: 'Bob', score: 200 },
{ name: 'Carol', score: 150 }
])
```
## Update Methods
### update
```typescript
const updated = await repo.update('player-123', {
score: 200,
rank: 'gold'
})
// Automatically updates updatedAt
```
## Delete Methods
### delete
```typescript
// Hard delete
await repo.delete('player-123')
// Soft delete (if enabled)
// Actually sets the deletedAt field
```
### deleteMany
```typescript
const count = await repo.deleteMany({
where: { score: { $lt: 10 } }
})
```
## Soft Delete
### Enabling Soft Delete
```typescript
const repo = createRepository<Player>(mongo, 'players', true)
```
### Query Behavior
```typescript
// Excludes soft-deleted records by default
const players = await repo.findMany()
// Include soft-deleted records
const allPlayers = await repo.findMany({
includeSoftDeleted: true
})
```
### Restore Records
```typescript
await repo.restore('player-123')
```
## QueryOptions
```typescript
interface QueryOptions<T> {
/** Query conditions */
where?: WhereCondition<T>
/** Sorting */
sort?: Partial<Record<keyof T, 'asc' | 'desc'>>
/** Limit count */
limit?: number
/** Offset */
offset?: number
/** Include soft-deleted records (only when soft delete is enabled) */
includeSoftDeleted?: boolean
}
```
## PaginatedResult
```typescript
interface PaginatedResult<T> {
data: T[]
total: number
page: number
pageSize: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
```

View File

@@ -0,0 +1,277 @@
---
title: "User Management"
description: "UserRepository for user registration, authentication, and role management"
---
## Overview
`UserRepository` provides out-of-the-box user management features:
- User registration and authentication
- Password hashing (using scrypt)
- Role management
- Account status management
## Quick Start
```typescript
import { createMongoConnection } from '@esengine/database-drivers'
import { UserRepository } from '@esengine/database'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game'
})
await mongo.connect()
const userRepo = new UserRepository(mongo)
```
## User Registration
```typescript
const user = await userRepo.register({
username: 'john',
password: 'securePassword123',
email: 'john@example.com', // Optional
displayName: 'John Doe', // Optional
roles: ['player'] // Optional, defaults to []
})
console.log(user)
// {
// id: 'uuid-...',
// username: 'john',
// email: 'john@example.com',
// displayName: 'John Doe',
// roles: ['player'],
// status: 'active',
// createdAt: Date,
// updatedAt: Date
// }
```
**Note**: `register` returns a `SafeUser` which excludes the password hash.
## User Authentication
```typescript
const user = await userRepo.authenticate('john', 'securePassword123')
if (user) {
console.log('Login successful:', user.username)
} else {
console.log('Invalid username or password')
}
```
## Password Management
### Change Password
```typescript
const success = await userRepo.changePassword(
userId,
'oldPassword123',
'newPassword456'
)
if (success) {
console.log('Password changed successfully')
} else {
console.log('Invalid current password')
}
```
### Reset Password
```typescript
// Admin directly resets password
const success = await userRepo.resetPassword(userId, 'newPassword123')
```
## Role Management
### Add Role
```typescript
await userRepo.addRole(userId, 'admin')
await userRepo.addRole(userId, 'moderator')
```
### Remove Role
```typescript
await userRepo.removeRole(userId, 'moderator')
```
### Query Roles
```typescript
// Find all admins
const admins = await userRepo.findByRole('admin')
// Check if user has a role
const user = await userRepo.findById(userId)
const isAdmin = user?.roles.includes('admin')
```
## Querying Users
### Find by Username
```typescript
const user = await userRepo.findByUsername('john')
```
### Find by Email
```typescript
const user = await userRepo.findByEmail('john@example.com')
```
### Find by Role
```typescript
const admins = await userRepo.findByRole('admin')
```
### Using Inherited Methods
```typescript
// Paginated query
const result = await userRepo.findPaginated(
{ page: 1, pageSize: 20 },
{
where: { status: 'active' },
sort: { createdAt: 'desc' }
}
)
// Complex query
const users = await userRepo.findMany({
where: {
status: 'active',
roles: { $in: ['admin', 'moderator'] }
}
})
```
## Account Status
```typescript
type UserStatus = 'active' | 'inactive' | 'banned' | 'suspended'
```
### Update Status
```typescript
await userRepo.update(userId, { status: 'banned' })
```
### Query by Status
```typescript
const activeUsers = await userRepo.findMany({
where: { status: 'active' }
})
const bannedUsers = await userRepo.findMany({
where: { status: 'banned' }
})
```
## Type Definitions
### UserEntity
```typescript
interface UserEntity extends BaseEntity {
username: string
passwordHash: string
email?: string
displayName?: string
roles: string[]
status: UserStatus
lastLoginAt?: Date
}
```
### SafeUser
```typescript
type SafeUser = Omit<UserEntity, 'passwordHash'>
```
### CreateUserParams
```typescript
interface CreateUserParams {
username: string
password: string
email?: string
displayName?: string
roles?: string[]
}
```
## Password Utilities
Standalone password utility functions:
```typescript
import { hashPassword, verifyPassword } from '@esengine/database'
// Hash password
const hash = await hashPassword('myPassword123')
// Verify password
const isValid = await verifyPassword('myPassword123', hash)
```
### Security Notes
- Uses Node.js built-in `scrypt` algorithm
- Automatically generates random salt
- Uses secure iteration parameters by default
- Hash format: `salt:hash` (both hex encoded)
## Extending UserRepository
```typescript
import { UserRepository, UserEntity } from '@esengine/database'
interface GameUser extends UserEntity {
level: number
experience: number
coins: number
}
class GameUserRepository extends UserRepository {
// Override collection name
constructor(connection: IMongoConnection) {
super(connection, 'game_users')
}
// Add game-related methods
async addExperience(userId: string, amount: number): Promise<GameUser | null> {
const user = await this.findById(userId) as GameUser | null
if (!user) return null
const newExp = user.experience + amount
const newLevel = Math.floor(newExp / 1000) + 1
return this.update(userId, {
experience: newExp,
level: newLevel
}) as Promise<GameUser | null>
}
async findTopPlayers(limit: number = 10): Promise<GameUser[]> {
return this.findMany({
sort: { level: 'desc', experience: 'desc' },
limit
}) as Promise<GameUser[]>
}
}
```

View File

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

@@ -0,0 +1,441 @@
---
title: "Distributed Rooms"
description: "Multi-server room management with DistributedRoomManager"
---
## Overview
Distributed room support allows multiple server instances to share a room registry, enabling cross-server player routing and failover.
```
┌─────────────────────────────────────────────────────────┐
│ Server A Server B Server C │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ IDistributedAdapter │ │
│ │ (Redis / Memory) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Quick Start
### Single Server Mode (Testing)
```typescript
import {
DistributedRoomManager,
MemoryAdapter,
Room
} from '@esengine/server';
// Define room type
class GameRoom extends Room {
maxPlayers = 4;
}
// Create adapter and manager
const adapter = new MemoryAdapter();
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
// Register room type
manager.define('game', GameRoom);
// Start manager
await manager.start();
// Distributed join/create room
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// Player should connect to another server
console.log(`Redirect to: ${result.redirect}`);
} else {
// Player joined local room
const { room, player } = result;
}
// Graceful shutdown
await manager.stop(true);
```
### Multi-Server Mode (Production)
```typescript
import Redis from 'ioredis';
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis({
host: 'redis.example.com',
port: 6379
}),
prefix: 'game:',
serverTtl: 30,
snapshotTtl: 86400
});
const manager = new DistributedRoomManager(adapter, {
serverId: process.env.SERVER_ID,
serverAddress: process.env.PUBLIC_IP,
serverPort: 3000,
heartbeatInterval: 5000,
snapshotInterval: 30000,
enableFailover: true,
capacity: 100
}, sendFn);
```
## DistributedRoomManager
### Configuration Options
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `serverId` | `string` | required | Unique server identifier |
| `serverAddress` | `string` | required | Public address for client connections |
| `serverPort` | `number` | required | Server port |
| `heartbeatInterval` | `number` | `5000` | Heartbeat interval (ms) |
| `snapshotInterval` | `number` | `30000` | State snapshot interval, 0 to disable |
| `migrationTimeout` | `number` | `10000` | Room migration timeout |
| `enableFailover` | `boolean` | `true` | Enable automatic failover |
| `capacity` | `number` | `100` | Max rooms on this server |
### Lifecycle Methods
#### start()
Start the distributed room manager. Connects to adapter, registers server, starts heartbeat.
```typescript
await manager.start();
```
#### stop(graceful?)
Stop the manager. If `graceful=true`, marks server as draining and saves all room snapshots.
```typescript
await manager.stop(true);
```
### Routing Methods
#### joinOrCreateDistributed()
Join or create a room with distributed awareness. Returns `{ room, player }` for local rooms or `{ redirect: string }` for remote rooms.
```typescript
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// Client should redirect to another server
res.json({ redirect: result.redirect });
} else {
// Player joined local room
const { room, player } = result;
}
```
#### route()
Route a player to the appropriate room/server.
```typescript
const result = await manager.route({
roomType: 'game',
playerId: 'p1'
});
switch (result.type) {
case 'local': // Room is on this server
break;
case 'redirect': // Room is on another server
// result.serverAddress contains target server
break;
case 'create': // No room exists, need to create
break;
case 'unavailable': // Cannot find or create room
// result.reason contains error message
break;
}
```
### State Management
#### saveSnapshot()
Manually save a room's state snapshot.
```typescript
await manager.saveSnapshot(roomId);
```
#### restoreFromSnapshot()
Restore a room from its saved snapshot.
```typescript
const success = await manager.restoreFromSnapshot(roomId);
```
### Query Methods
#### getServers()
Get all online servers.
```typescript
const servers = await manager.getServers();
```
#### queryDistributedRooms()
Query rooms across all servers.
```typescript
const rooms = await manager.queryDistributedRooms({
roomType: 'game',
hasSpace: true,
notLocked: true
});
```
## IDistributedAdapter
Interface for distributed backends. Implement this to add support for Redis, message queues, etc.
### Built-in Adapters
#### MemoryAdapter
In-memory implementation for testing and single-server mode.
```typescript
const adapter = new MemoryAdapter({
serverTtl: 15000, // Server offline after no heartbeat (ms)
enableTtlCheck: true, // Enable automatic TTL checking
ttlCheckInterval: 5000 // TTL check interval (ms)
});
```
#### RedisAdapter
Redis-based implementation for production multi-server deployments.
```typescript
import Redis from 'ioredis';
import { RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis('redis://localhost:6379'),
prefix: 'game:', // Key prefix (default: 'dist:')
serverTtl: 30, // Server TTL in seconds (default: 30)
roomTtl: 0, // Room TTL, 0 = never expire (default: 0)
snapshotTtl: 86400, // Snapshot TTL in seconds (default: 24h)
channel: 'game:events' // Pub/Sub channel (default: 'distributed:events')
});
```
**RedisAdapter Configuration:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `factory` | `() => RedisClient` | required | Redis client factory (lazy connection) |
| `prefix` | `string` | `'dist:'` | Key prefix for all Redis keys |
| `serverTtl` | `number` | `30` | Server TTL in seconds |
| `roomTtl` | `number` | `0` | Room TTL in seconds, 0 = no expiry |
| `snapshotTtl` | `number` | `86400` | Snapshot TTL in seconds |
| `channel` | `string` | `'distributed:events'` | Pub/Sub channel name |
**Features:**
- Server registry with automatic heartbeat TTL
- Room registry with cross-server lookup
- State snapshots with configurable TTL
- Pub/Sub for cross-server events
- Distributed locks using Redis SET NX
### Custom Adapters
```typescript
import type { IDistributedAdapter } from '@esengine/server';
class MyAdapter implements IDistributedAdapter {
// Lifecycle
async connect(): Promise<void> { }
async disconnect(): Promise<void> { }
isConnected(): boolean { return true; }
// Server Registry
async registerServer(server: ServerRegistration): Promise<void> { }
async unregisterServer(serverId: string): Promise<void> { }
async heartbeat(serverId: string): Promise<void> { }
async getServers(): Promise<ServerRegistration[]> { return []; }
// Room Registry
async registerRoom(room: RoomRegistration): Promise<void> { }
async unregisterRoom(roomId: string): Promise<void> { }
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
// State Snapshots
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
// Pub/Sub
async publish(event: DistributedEvent): Promise<void> { }
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
// Distributed Locks
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
async releaseLock(key: string): Promise<void> { }
}
```
## Player Routing Flow
```
Client Server A Server B
│ │ │
│─── joinOrCreate ────────►│ │
│ │ │
│ │── findAvailableRoom() ───►│
│ │◄──── room on Server B ────│
│ │ │
│◄─── redirect: B:3001 ────│ │
│ │ │
│───────────────── connect to Server B ───────────────►│
│ │ │
│◄─────────────────────────────── joined ─────────────│
```
## Event Types
The distributed system publishes these events:
| Event | Description |
|-------|-------------|
| `server:online` | Server came online |
| `server:offline` | Server went offline |
| `server:draining` | Server is draining |
| `room:created` | Room was created |
| `room:disposed` | Room was disposed |
| `room:updated` | Room info updated |
| `room:message` | Cross-server room message |
| `room:migrated` | Room migrated to another server |
| `player:joined` | Player joined room |
| `player:left` | Player left room |
## Best Practices
1. **Use Unique Server IDs** - Use hostname, container ID, or UUID
2. **Configure Proper Heartbeat** - Balance between freshness and network overhead
3. **Enable Snapshots for Stateful Rooms** - Ensure room state survives server restarts
4. **Handle Redirects Gracefully** - Client should reconnect to target server
```typescript
// Client handling redirect
if (response.redirect) {
await client.disconnect();
await client.connect(response.redirect);
await client.joinRoom(roomId);
}
```
5. **Use Distributed Locks** - Prevent race conditions in joinOrCreate
## Using createServer Integration
The simplest way to use distributed rooms is through `createServer`'s `distributed` config:
```typescript
import { createServer } from '@esengine/server';
import { RedisAdapter, Room } from '@esengine/server';
import Redis from 'ioredis';
class GameRoom extends Room {
maxPlayers = 4;
}
const server = await createServer({
port: 3000,
distributed: {
enabled: true,
adapter: new RedisAdapter({ factory: () => new Redis() }),
serverId: 'server-1',
serverAddress: 'ws://192.168.1.100',
serverPort: 3000,
enableFailover: true,
capacity: 100
}
});
server.define('game', GameRoom);
await server.start();
```
When clients call the `JoinRoom` API, the server will automatically:
1. Find available rooms (local or remote)
2. If room is on another server, send `$redirect` message to client
3. Client receives redirect and connects to target server
## Load Balancing
Use `LoadBalancedRouter` for server selection:
```typescript
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
// Using factory function
const router = createLoadBalancedRouter('least-players');
// Or create directly
const router = new LoadBalancedRouter({
strategy: 'least-rooms', // Select server with fewest rooms
preferLocal: true // Prefer local server
});
// Available strategies
// - 'round-robin': Round robin selection
// - 'least-rooms': Fewest rooms
// - 'least-players': Fewest players
// - 'random': Random selection
// - 'weighted': Weighted by capacity usage
```
## Failover
When a server goes offline with `enableFailover` enabled, the system will automatically:
1. Detect server offline (via heartbeat timeout)
2. Query all rooms on that server
3. Use distributed lock to prevent multiple servers recovering same room
4. Restore room state from snapshot
5. Publish `room:migrated` event to notify other servers
```typescript
// Ensure periodic snapshots
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000,
snapshotInterval: 30000, // Save snapshot every 30 seconds
enableFailover: true // Enable failover
}, sendFn);
```
## Future Releases
- Redis Cluster support
- More load balancing strategies (geo-location, latency-aware)

View File

@@ -0,0 +1,679 @@
---
title: "HTTP Routing"
description: "HTTP REST API routing with WebSocket port sharing support"
---
`@esengine/server` includes a lightweight HTTP routing feature that can share the same port with WebSocket services, making it easy to implement REST APIs.
## Quick Start
### Inline Route Definition
The simplest way is to define HTTP routes directly when creating the server:
```typescript
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
http: {
'/api/health': (req, res) => {
res.json({ status: 'ok', time: Date.now() })
},
'/api/users': {
GET: (req, res) => {
res.json({ users: [] })
},
POST: async (req, res) => {
const body = req.body as { name: string }
res.status(201).json({ id: '1', name: body.name })
}
}
},
cors: true // Enable CORS
})
await server.start()
```
### File-based Routing
For larger projects, file-based routing is recommended. Create a `src/http` directory where each file corresponds to a route:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST',
handler(req, res) {
const { username, password } = req.body as LoginBody
// Validate user...
if (username === 'admin' && password === '123456') {
res.json({ token: 'jwt-token-here', userId: 'user-1' })
} else {
res.error(401, 'Invalid username or password')
}
}
})
```
```typescript
// server.ts
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP routes directory
httpPrefix: '/api', // Route prefix
cors: true
})
await server.start()
// Route: POST /api/login
```
## defineHttp Definition
`defineHttp` is used to define type-safe HTTP handlers:
```typescript
import { defineHttp } from '@esengine/server'
interface CreateUserBody {
username: string
email: string
password: string
}
export default defineHttp<CreateUserBody>({
// HTTP method (default POST)
method: 'POST',
// Handler function
handler(req, res) {
const body = req.body as CreateUserBody
// Handle request...
res.status(201).json({ id: 'new-user-id' })
}
})
```
### Supported HTTP Methods
```typescript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
```
## HttpRequest Object
The HTTP request object contains the following properties:
```typescript
interface HttpRequest {
/** Raw Node.js IncomingMessage */
raw: IncomingMessage
/** HTTP method */
method: string
/** Request path */
path: string
/** Route parameters (extracted from URL path, e.g., /users/:id) */
params: Record<string, string>
/** Query parameters */
query: Record<string, string>
/** Request headers */
headers: Record<string, string | string[] | undefined>
/** Parsed request body */
body: unknown
/** Client IP */
ip: string
}
```
### Usage Examples
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// Get query parameters
const page = parseInt(req.query.page ?? '1')
const limit = parseInt(req.query.limit ?? '10')
// Get request headers
const authHeader = req.headers.authorization
// Get client IP
console.log('Request from:', req.ip)
res.json({ page, limit })
}
})
```
### Body Parsing
The request body is automatically parsed based on `Content-Type`:
- `application/json` - Parsed as JSON object
- `application/x-www-form-urlencoded` - Parsed as key-value object
- Others - Kept as raw string
```typescript
export default defineHttp<{ name: string; age: number }>({
method: 'POST',
handler(req, res) {
// body is already parsed
const { name, age } = req.body as { name: string; age: number }
res.json({ received: { name, age } })
}
})
```
## HttpResponse Object
The HTTP response object provides a chainable API:
```typescript
interface HttpResponse {
/** Raw Node.js ServerResponse */
raw: ServerResponse
/** Set status code */
status(code: number): HttpResponse
/** Set response header */
header(name: string, value: string): HttpResponse
/** Send JSON response */
json(data: unknown): void
/** Send text response */
text(data: string): void
/** Send error response */
error(code: number, message: string): void
}
```
### Usage Examples
```typescript
export default defineHttp({
method: 'POST',
handler(req, res) {
// Set status code and custom headers
res
.status(201)
.header('X-Custom-Header', 'value')
.json({ created: true })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// Send error response
res.error(404, 'Resource not found')
// Equivalent to: res.status(404).json({ error: 'Resource not found' })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// Send plain text
res.text('Hello, World!')
}
})
```
## File Routing Conventions
### Name Conversion
File names are automatically converted to route paths:
| File Path | Route Path (prefix=/api) |
|-----------|-------------------------|
| `login.ts` | `/api/login` |
| `users/profile.ts` | `/api/users/profile` |
| `users/[id].ts` | `/api/users/:id` |
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
### Dynamic Route Parameters
Use `[param]` syntax to define dynamic parameters:
```typescript
// src/http/users/[id].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
// Get route parameter directly from params
const { id } = req.params
res.json({ userId: id })
}
})
```
Multiple parameters:
```typescript
// src/http/users/[userId]/posts/[postId].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
const { userId, postId } = req.params
res.json({ userId, postId })
}
})
```
### Skip Rules
The following files are automatically skipped:
- Files starting with `_` (e.g., `_helper.ts`)
- `index.ts` / `index.js` files
- Non `.ts` / `.js` / `.mts` / `.mjs` files
### Directory Structure Example
```
src/
└── http/
├── _utils.ts # Skipped (underscore prefix)
├── index.ts # Skipped (index file)
├── health.ts # GET /api/health
├── login.ts # POST /api/login
├── register.ts # POST /api/register
└── users/
├── index.ts # Skipped
├── list.ts # GET /api/users/list
└── [id].ts # GET /api/users/:id
```
## CORS Configuration
### Quick Enable
```typescript
const server = await createServer({
port: 3000,
cors: true // Use default configuration
})
```
### Custom Configuration
```typescript
const server = await createServer({
port: 3000,
cors: {
// Allowed origins
origin: ['http://localhost:5173', 'https://myapp.com'],
// Or use wildcard
// origin: '*',
// origin: true, // Reflect request origin
// Allowed HTTP methods
methods: ['GET', 'POST', 'PUT', 'DELETE'],
// Allowed request headers
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
// Allow credentials (cookies)
credentials: true,
// Preflight cache max age (seconds)
maxAge: 86400
}
})
```
### CorsOptions Type
```typescript
interface CorsOptions {
/** Allowed origins: string, string array, true (reflect) or '*' */
origin?: string | string[] | boolean
/** Allowed HTTP methods */
methods?: string[]
/** Allowed request headers */
allowedHeaders?: string[]
/** Allow credentials */
credentials?: boolean
/** Preflight cache max age (seconds) */
maxAge?: number
}
```
## Route Merging
File routes and inline routes can be used together, with inline routes having higher priority:
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http',
httpPrefix: '/api',
// Inline routes merge with file routes
http: {
'/health': (req, res) => res.json({ status: 'ok' }),
'/api/special': (req, res) => res.json({ special: true })
}
})
```
## Sharing Port with WebSocket
HTTP routes automatically share the same port with WebSocket services:
```typescript
const server = await createServer({
port: 3000,
// WebSocket related config
apiDir: './src/api',
msgDir: './src/msg',
// HTTP related config
httpDir: './src/http',
httpPrefix: '/api',
cors: true
})
await server.start()
// Same port 3000:
// - WebSocket: ws://localhost:3000
// - HTTP API: http://localhost:3000/api/*
```
## Complete Examples
### Game Server Login API
```typescript
// src/http/auth/login.ts
import { defineHttp } from '@esengine/server'
import { createJwtAuthProvider } from '@esengine/server/auth'
interface LoginRequest {
username: string
password: string
}
interface LoginResponse {
token: string
userId: string
expiresAt: number
}
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600
})
export default defineHttp<LoginRequest>({
method: 'POST',
async handler(req, res) {
const { username, password } = req.body as LoginRequest
// Validate user
const user = await db.users.findByUsername(username)
if (!user || !await verifyPassword(password, user.passwordHash)) {
res.error(401, 'Invalid username or password')
return
}
// Generate JWT
const token = jwtProvider.sign({
sub: user.id,
name: user.username,
roles: user.roles
})
const response: LoginResponse = {
token,
userId: user.id,
expiresAt: Date.now() + 3600 * 1000
}
res.json(response)
}
})
```
### Game Data Query API
```typescript
// src/http/game/leaderboard.ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
async handler(req, res) {
const limit = parseInt(req.query.limit ?? '10')
const offset = parseInt(req.query.offset ?? '0')
const players = await db.players.findMany({
sort: { score: 'desc' },
limit,
offset
})
res.json({
data: players,
pagination: { limit, offset }
})
}
})
```
## Middleware
### Middleware Type
Middleware are functions that execute before and after route handlers:
```typescript
type HttpMiddleware = (
req: HttpRequest,
res: HttpResponse,
next: () => Promise<void>
) => void | Promise<void>
```
### Built-in Middleware
```typescript
import {
requestLogger,
bodyLimit,
responseTime,
requestId,
securityHeaders
} from '@esengine/server'
const server = await createServer({
port: 3000,
http: { /* ... */ },
// Global middleware configured via createHttpRouter
})
```
#### requestLogger - Request Logging
```typescript
import { requestLogger } from '@esengine/server'
// Log request and response time
requestLogger()
// Also log request body
requestLogger({ logBody: true })
```
#### bodyLimit - Request Body Size Limit
```typescript
import { bodyLimit } from '@esengine/server'
// Limit request body to 1MB
bodyLimit(1024 * 1024)
```
#### responseTime - Response Time Header
```typescript
import { responseTime } from '@esengine/server'
// Automatically add X-Response-Time header
responseTime()
```
#### requestId - Request ID
```typescript
import { requestId } from '@esengine/server'
// Auto-generate and add X-Request-ID header
requestId()
// Custom header name
requestId('X-Trace-ID')
```
#### securityHeaders - Security Headers
```typescript
import { securityHeaders } from '@esengine/server'
// Add common security response headers
securityHeaders()
// Custom configuration
securityHeaders({
hidePoweredBy: true,
frameOptions: 'DENY',
noSniff: true
})
```
### Custom Middleware
```typescript
import type { HttpMiddleware } from '@esengine/server'
// Authentication middleware
const authMiddleware: HttpMiddleware = async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) {
res.error(401, 'Unauthorized')
return // Don't call next(), terminate request
}
// Validate token...
(req as any).userId = 'decoded-user-id'
await next() // Continue to next middleware and handler
}
```
### Using Middleware
#### With createHttpRouter
```typescript
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
const router = createHttpRouter({
'/api/users': (req, res) => res.json([]),
'/api/admin': {
GET: {
handler: (req, res) => res.json({ admin: true }),
middlewares: [adminAuthMiddleware] // Route-level middleware
}
}
}, {
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // Global middleware
timeout: 30000 // Global timeout 30 seconds
})
```
## Request Timeout
### Global Timeout
```typescript
import { createHttpRouter } from '@esengine/server'
const router = createHttpRouter({
'/api/data': async (req, res) => {
// If processing exceeds 30 seconds, auto-return 408 Request Timeout
await someSlowOperation()
res.json({ data: 'result' })
}
}, {
timeout: 30000 // 30 seconds
})
```
### Route-level Timeout
```typescript
const router = createHttpRouter({
'/api/quick': (req, res) => res.json({ fast: true }),
'/api/slow': {
POST: {
handler: async (req, res) => {
await verySlowOperation()
res.json({ done: true })
},
timeout: 120000 // This route allows 2 minutes
}
}
}, {
timeout: 10000 // Global 10 seconds (overridden by route-level)
})
```
## Best Practices
1. **Use defineHttp** - Get better type hints and code organization
2. **Unified Error Handling** - Use `res.error()` to return consistent error format
3. **Enable CORS** - Required for frontend-backend separation
4. **Directory Organization** - Organize HTTP route files by functional modules
5. **Validate Input** - Always validate `req.body` and `req.query` content
6. **Status Code Standards** - Follow HTTP status code conventions (200, 201, 400, 401, 404, 500, etc.)
7. **Use Middleware** - Implement cross-cutting concerns like auth, logging, rate limiting via middleware
8. **Set Timeouts** - Prevent slow requests from blocking the server

View File

@@ -147,7 +147,11 @@ service.on('chat', (data) => {
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
- [Server Side](/en/modules/network/server/) - GameServer and Room management
- [State Sync](/en/modules/network/sync/) - Interpolation, prediction and snapshots
- [Distributed Rooms](/en/modules/network/distributed/) - Multi-server room management and player routing
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization
- [Delta Compression](/en/modules/network/delta/) - State delta synchronization
- [API Reference](/en/modules/network/api/) - Complete API documentation
## Service Tokens
@@ -159,10 +163,14 @@ import {
NetworkServiceToken,
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken
NetworkInputSystemToken,
NetworkPredictionSystemToken,
NetworkAOISystemToken,
} from '@esengine/network';
const networkService = services.get(NetworkServiceToken);
const predictionSystem = services.get(NetworkPredictionSystemToken);
const aoiSystem = services.get(NetworkAOISystemToken);
```
## Blueprint Nodes

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

@@ -79,10 +79,33 @@ await server.start()
| `tickRate` | `number` | `20` | Global tick rate (Hz) |
| `apiDir` | `string` | `'src/api'` | API handlers directory |
| `msgDir` | `string` | `'src/msg'` | Message handlers directory |
| `httpDir` | `string` | `'src/http'` | HTTP routes directory |
| `httpPrefix` | `string` | `'/api'` | HTTP routes prefix |
| `cors` | `boolean \| CorsOptions` | - | CORS configuration |
| `onStart` | `(port) => void` | - | Start callback |
| `onConnect` | `(conn) => void` | - | Connection callback |
| `onDisconnect` | `(conn) => void` | - | Disconnect callback |
## HTTP Routing
Supports HTTP API sharing the same port with WebSocket, ideal for login, registration, and similar scenarios.
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP routes directory
httpPrefix: '/api', // Route prefix
cors: true,
// Or inline definition
http: {
'/health': (req, res) => res.json({ status: 'ok' })
}
})
```
> For detailed documentation, see [HTTP Routing](/en/modules/network/http)
## Room System
Room is the base class for game rooms, managing players and game state.
@@ -243,6 +266,122 @@ class GameRoom extends Room {
}
```
## Schema Validation
Use the built-in Schema validation system for runtime type validation:
### Basic Usage
```typescript
import { s, defineApiWithSchema } from '@esengine/server'
// Define schema
const MoveSchema = s.object({
x: s.number(),
y: s.number(),
speed: s.number().optional()
})
// Auto type inference
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
// Use schema to define API (auto validation)
export default defineApiWithSchema(MoveSchema, {
handler(req, ctx) {
// req is validated, type-safe
console.log(req.x, req.y)
}
})
```
### Validator Types
| Type | Example | Description |
|------|---------|-------------|
| `s.string()` | `s.string().min(1).max(50)` | String with length constraints |
| `s.number()` | `s.number().min(0).int()` | Number with range and integer constraints |
| `s.boolean()` | `s.boolean()` | Boolean |
| `s.literal()` | `s.literal('admin')` | Literal type |
| `s.object()` | `s.object({ name: s.string() })` | Object |
| `s.array()` | `s.array(s.number())` | Array |
| `s.enum()` | `s.enum(['a', 'b'] as const)` | Enum |
| `s.union()` | `s.union([s.string(), s.number()])` | Union type |
| `s.record()` | `s.record(s.any())` | Record type |
### Modifiers
```typescript
// Optional field
s.string().optional()
// Default value
s.number().default(0)
// Nullable
s.string().nullable()
// String validation
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
// Number validation
s.number().min(0).max(100).int().positive()
// Array validation
s.array(s.string()).min(1).max(10).nonempty()
// Object validation
s.object({ ... }).strict() // No extra fields allowed
s.object({ ... }).partial() // All fields optional
s.object({ ... }).pick('name', 'age') // Pick fields
s.object({ ... }).omit('password') // Omit fields
```
### Message Validation
```typescript
import { s, defineMsgWithSchema } from '@esengine/server'
const InputSchema = s.object({
keys: s.array(s.string()),
timestamp: s.number()
})
export default defineMsgWithSchema(InputSchema, {
handler(msg, ctx) {
// msg is validated
console.log(msg.keys, msg.timestamp)
}
})
```
### Manual Validation
```typescript
import { s, parse, safeParse, createGuard } from '@esengine/server'
const UserSchema = s.object({
name: s.string(),
age: s.number().int().min(0)
})
// Throws on error
const user = parse(UserSchema, data)
// Returns result object
const result = safeParse(UserSchema, data)
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}
// Type guard
const isUser = createGuard(UserSchema)
if (isUser(data)) {
// data is User type
}
```
## Protocol Definition
Define shared types in `src/shared/protocol.ts`:
@@ -311,6 +450,93 @@ client.send('RoomMessage', {
})
```
## ECSRoom
`ECSRoom` is a room base class with ECS World support, suitable for games that need ECS architecture.
### Server Startup
```typescript
import { Core } from '@esengine/ecs-framework';
import { createServer } from '@esengine/server';
import { GameRoom } from './rooms/GameRoom.js';
// Initialize Core
Core.create();
// Global game loop
setInterval(() => Core.update(1/60), 16);
// Create server
const server = await createServer({ port: 3000 });
server.define('game', GameRoom);
await server.start();
```
### Define ECSRoom
```typescript
import { ECSRoom, Player } from '@esengine/server/ecs';
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
// Define sync component
@ECSComponent('Player')
class PlayerComponent extends Component {
@sync("string") name: string = "";
@sync("uint16") score: number = 0;
@sync("float32") x: number = 0;
@sync("float32") y: number = 0;
}
// Define room
class GameRoom extends ECSRoom {
onCreate() {
this.addSystem(new MovementSystem());
}
onJoin(player: Player) {
const entity = this.createPlayerEntity(player.id);
const comp = entity.addComponent(new PlayerComponent());
comp.name = player.id;
}
}
```
### ECSRoom API
```typescript
abstract class ECSRoom<TState, TPlayerData> extends Room<TState, TPlayerData> {
protected readonly world: World; // ECS World
protected readonly scene: Scene; // Main scene
// Scene management
protected addSystem(system: EntitySystem): void;
protected createEntity(name?: string): Entity;
protected createPlayerEntity(playerId: string, name?: string): Entity;
protected getPlayerEntity(playerId: string): Entity | undefined;
protected destroyPlayerEntity(playerId: string): void;
// State sync
protected sendFullState(player: Player): void;
protected broadcastSpawn(entity: Entity, prefabType?: string): void;
protected broadcastDelta(): void;
}
```
### @sync Decorator
Mark component fields that need network synchronization:
| Type | Description | Bytes |
|------|-------------|-------|
| `"boolean"` | Boolean | 1 |
| `"int8"` / `"uint8"` | 8-bit integer | 1 |
| `"int16"` / `"uint16"` | 16-bit integer | 2 |
| `"int32"` / `"uint32"` | 32-bit integer | 4 |
| `"float32"` | 32-bit float | 4 |
| `"float64"` | 64-bit float | 8 |
| `"string"` | String | Variable |
## Best Practices
1. **Set Appropriate Tick Rate**

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

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

@@ -28,13 +28,13 @@ const MyNodeTemplate: BlueprintNodeTemplate = {
## 实现节点执行器
```typescript
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, ExecutionResult } from '@esengine/blueprint';
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
// 获取输入
const value = context.getInput<number>(node.id, 'value');
// 获取输入(使用 evaluateInput
const value = context.evaluateInput(node.id, 'value', 0) as number;
// 执行逻辑
const result = value * 2;
@@ -100,29 +100,58 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
};
```
## 实际示例:输入处理节点
## 实际示例:ECS 组件操作节点
```typescript
const InputMoveTemplate: BlueprintNodeTemplate = {
type: 'InputMove',
title: 'Get Movement Input',
category: 'input',
inputs: [],
outputs: [
{ name: 'direction', type: 'vector2', direction: 'output' }
import type { Entity } from '@esengine/ecs-framework';
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
// 自定义治疗节点
const HealEntityTemplate: BlueprintNodeTemplate = {
type: 'HealEntity',
title: 'Heal Entity',
category: 'gameplay',
color: '#22aa22',
description: 'Heal an entity with HealthComponent',
keywords: ['heal', 'health', 'restore'],
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Target' },
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
],
isPure: true
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
]
};
@RegisterNode(InputMoveTemplate)
class InputMoveExecutor implements INodeExecutor {
@RegisterNode(HealEntityTemplate)
class HealEntityExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const input = context.scene.services.get(InputServiceToken);
const direction = {
x: input.getAxis('horizontal'),
y: input.getAxis('vertical')
};
return { outputs: { direction } };
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
if (!entity || entity.isDestroyed) {
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
}
// 获取 HealthComponent
const health = entity.components.find(c =>
(c.constructor as any).__componentName__ === 'Health'
) as any;
if (health) {
health.current = Math.min(health.current + amount, health.max);
return {
outputs: { newHealth: health.current },
nextExec: 'exec'
};
}
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
}
}
```

View File

@@ -3,85 +3,127 @@ title: "实际示例"
description: "ECS 集成和最佳实践"
---
## 玩家控制蓝图
## 完整游戏集成示例
```typescript
// 定义输入处理节点
const InputMoveTemplate: BlueprintNodeTemplate = {
type: 'InputMove',
title: 'Get Movement Input',
category: 'input',
inputs: [],
outputs: [
{ name: 'direction', type: 'vector2', direction: 'output' }
],
isPure: true
};
import { Scene, Core, Component, ECSComponent } from '@esengine/ecs-framework';
import {
BlueprintSystem,
BlueprintComponent,
BlueprintExpose,
BlueprintProperty,
BlueprintMethod
} from '@esengine/blueprint';
@RegisterNode(InputMoveTemplate)
class InputMoveExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const input = context.scene.services.get(InputServiceToken);
const direction = {
x: input.getAxis('horizontal'),
y: input.getAxis('vertical')
};
return { outputs: { direction } };
// 1. 定义游戏组件
@ECSComponent('Player')
@BlueprintExpose({ displayName: '玩家', category: 'gameplay' })
export class PlayerComponent extends Component {
@BlueprintProperty({ displayName: '移动速度', type: 'float' })
moveSpeed: number = 5;
@BlueprintProperty({ displayName: '分数', type: 'int' })
score: number = 0;
@BlueprintMethod({ displayName: '增加分数' })
addScore(points: number): void {
this.score += points;
}
}
@ECSComponent('Health')
@BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: '当前生命值' })
current: number = 100;
@BlueprintProperty({ displayName: '最大生命值' })
max: number = 100;
@BlueprintMethod({ displayName: '治疗' })
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
@BlueprintMethod({ displayName: '受伤' })
takeDamage(amount: number): boolean {
this.current -= amount;
return this.current <= 0;
}
}
// 2. 初始化游戏
async function initGame() {
const scene = new Scene();
// 添加蓝图系统
scene.addSystem(new BlueprintSystem());
Core.setScene(scene);
// 3. 创建玩家
const player = scene.createEntity('Player');
player.addComponent(new PlayerComponent());
player.addComponent(new HealthComponent());
// 添加蓝图控制
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
player.addComponent(blueprint);
}
```
## 状态切换逻辑
## 自定义节点示例
```typescript
// 在蓝图中实现状态机逻辑
const stateBlueprint = createEmptyBlueprint('PlayerState');
import type { Entity } from '@esengine/ecs-framework';
import {
BlueprintNodeTemplate,
BlueprintNode,
ExecutionContext,
ExecutionResult,
INodeExecutor,
RegisterNode
} from '@esengine/blueprint';
// 添加状态变量
stateBlueprint.variables.push({
name: 'currentState',
type: 'string',
defaultValue: 'idle',
scope: 'instance'
});
// 在 Tick 事件中检查状态转换
// ... 通过节点连接实现
```
## 伤害处理系统
```typescript
// 自定义伤害节点
const ApplyDamageTemplate: BlueprintNodeTemplate = {
type: 'ApplyDamage',
title: 'Apply Damage',
category: 'combat',
color: '#aa2222',
description: '对带有 Health 组件的实体造成伤害',
keywords: ['damage', 'hurt', 'attack'],
menuPath: ['Combat', 'Apply Damage'],
inputs: [
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
{ name: 'target', type: 'entity', direction: 'input' },
{ name: 'amount', type: 'number', direction: 'input', defaultValue: 10 }
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'target', type: 'entity', displayName: '目标' },
{ name: 'amount', type: 'float', displayName: '伤害量', defaultValue: 10 }
],
outputs: [
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
{ name: 'killed', type: 'boolean', direction: 'output' }
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'killed', type: 'bool', displayName: '已击杀' }
]
};
@RegisterNode(ApplyDamageTemplate)
class ApplyDamageExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const target = context.getInput<Entity>(node.id, 'target');
const amount = context.getInput<number>(node.id, 'amount');
const target = context.evaluateInput(node.id, 'target', context.entity) as Entity;
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
if (!target || target.isDestroyed) {
return { outputs: { killed: false }, nextExec: 'exec' };
}
const health = target.components.find(c =>
(c.constructor as any).__componentName__ === 'Health'
) as any;
const health = target.getComponent(HealthComponent);
if (health) {
health.current -= amount;
const killed = health.current <= 0;
return {
outputs: { killed },
nextExec: 'exec'
};
return { outputs: { killed }, nextExec: 'exec' };
}
return { outputs: { killed: false }, nextExec: 'exec' };
@@ -89,25 +131,6 @@ class ApplyDamageExecutor implements INodeExecutor {
}
```
## 技能冷却系统
```typescript
// 冷却检查节点
const CheckCooldownTemplate: BlueprintNodeTemplate = {
type: 'CheckCooldown',
title: 'Check Cooldown',
category: 'ability',
inputs: [
{ name: 'skillId', type: 'string', direction: 'input' }
],
outputs: [
{ name: 'ready', type: 'boolean', direction: 'output' },
{ name: 'remaining', type: 'number', direction: 'output' }
],
isPure: true
};
```
## 最佳实践
### 1. 使用片段复用逻辑
@@ -151,7 +174,8 @@ vm.maxStepsPerFrame = 1000;
```typescript
// 启用调试模式查看执行日志
vm.debug = true;
const blueprint = entity.getComponent(BlueprintComponent);
blueprint.debug = true;
// 使用 Print 节点输出中间值
// 在编辑器中设置断点

View File

@@ -1,9 +1,9 @@
---
title: "蓝图可视化脚本 (Blueprint)"
description: "完整的可视化脚本系统"
description: "与 ECS 框架深度集成的可视化脚本系统"
---
`@esengine/blueprint` 提供了一个功能完整的可视化脚本系统,支持节点式编程、事件驱动和蓝图组合
`@esengine/blueprint` 提供与 ECS 框架深度集成的可视化脚本系统,支持通过节点式编程控制实体行为
## 安装
@@ -11,104 +11,141 @@ description: "完整的可视化脚本系统"
npm install @esengine/blueprint
```
## 核心特性
- **ECS 深度集成** - 内置 Entity、Component 操作节点
- **组件自动节点生成** - 使用装饰器标记组件,自动生成 Get/Set/Call 节点
- **运行时蓝图执行** - 高效的虚拟机执行蓝图逻辑
## 快速开始
### 1. 添加蓝图系统
```typescript
import { Scene, Core } from '@esengine/ecs-framework';
import { BlueprintSystem } from '@esengine/blueprint';
// 创建场景并添加蓝图系统
const scene = new Scene();
scene.addSystem(new BlueprintSystem());
// 设置场景
Core.setScene(scene);
```
### 2. 为实体添加蓝图
```typescript
import { BlueprintComponent } from '@esengine/blueprint';
// 创建实体
const player = scene.createEntity('Player');
// 添加蓝图组件
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
blueprint.autoStart = true;
player.addComponent(blueprint);
```
### 3. 标记组件(自动生成蓝图节点)
```typescript
import {
createBlueprintSystem,
createBlueprintComponentData,
NodeRegistry,
RegisterNode
BlueprintExpose,
BlueprintProperty,
BlueprintMethod
} from '@esengine/blueprint';
import { Component, ECSComponent } from '@esengine/ecs-framework';
// 创建蓝图系统
const blueprintSystem = createBlueprintSystem(scene);
@ECSComponent('Health')
@BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: '当前生命值', type: 'float' })
current: number = 100;
// 加载蓝图资产
const blueprint = await loadBlueprintAsset('player.bp');
@BlueprintProperty({ displayName: '最大生命值', type: 'float' })
max: number = 100;
// 创建蓝图组件数据
const componentData = createBlueprintComponentData();
componentData.blueprintAsset = blueprint;
@BlueprintMethod({
displayName: '治疗',
params: [{ name: 'amount', type: 'float' }]
})
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
// 在游戏循环中更新
function gameLoop(dt: number) {
blueprintSystem.process(entities, dt);
@BlueprintMethod({ displayName: '受伤' })
takeDamage(amount: number): boolean {
this.current -= amount;
return this.current <= 0;
}
}
```
## 核心概念
标记后,蓝图编辑器中会自动出现以下节点:
- **Get Health** - 获取 Health 组件
- **Get 当前生命值** - 获取 current 属性
- **Set 当前生命值** - 设置 current 属性
- **治疗** - 调用 heal 方法
- **受伤** - 调用 takeDamage 方法
### 蓝图资产结
## ECS 集成架
蓝图保存为 `.bp` 文件,包含以下结构:
```typescript
interface BlueprintAsset {
version: number; // 格式版本
type: 'blueprint'; // 资产类型
metadata: BlueprintMetadata; // 元数据
variables: BlueprintVariable[]; // 变量定义
nodes: BlueprintNode[]; // 节点实例
connections: BlueprintConnection[]; // 连接
}
```
┌─────────────────────────────────────────────────────────────┐
│ Core.update() │
│ ↓ │
Scene.updateSystems() │
↓ │
│ ┌───────────────────────────────────────────────────────┐ │
BlueprintSystem │ │
│ │
Matcher.all(BlueprintComponent) │ │
│ │ ↓ │ │
│ │ process(entities) → blueprint.tick() for each entity │ │
│ │ ↓ │ │
│ │ BlueprintVM.tick(dt) │ │
│ │ ↓ │ │
│ │ Execute Event/ECS/Flow Nodes │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 节点类型
节点按功能分为以下类别:
## 节点类型
| 类别 | 说明 | 颜色 |
|------|------|------|
| `event` | 事件节点(入口点 | 红色 |
| `flow` | 流程控制 | 色 |
| `entity` | 实体操作 | 色 |
| `component` | 组件访问 | 色 |
| `event` | 事件节点(BeginPlay, Tick, EndPlay | 红色 |
| `entity` | ECS 实体操作 | 色 |
| `component` | ECS 组件访问 | 色 |
| `flow` | 流程控制Branch, Sequence, Loop | 色 |
| `math` | 数学运算 | 绿色 |
| `logic` | 逻辑运算 | 色 |
| `variable` | 变量访问 | 色 |
| `time` | 时间工具 | 青色 |
| `debug` | 调试工具 | 灰色 |
| `time` | 时间工具Delay, GetDeltaTime | 色 |
| `debug` | 调试工具Print | 色 |
### 引脚类型
## 蓝图资产结构
节点通过引脚连接
蓝图保存为 `.bp` 文件
```typescript
interface BlueprintPinDefinition {
name: string; // 引脚名称
type: PinDataType; // 数据类型
direction: 'input' | 'output';
isExec?: boolean; // 是否是执行引脚
defaultValue?: unknown;
interface BlueprintAsset {
version: number;
type: 'blueprint';
metadata: {
name: string;
description?: string;
};
variables: BlueprintVariable[];
nodes: BlueprintNode[];
connections: BlueprintConnection[];
}
// 支持的数据类型
type PinDataType =
| 'exec' // 执行流
| 'boolean' // 布尔值
| 'number' // 数字
| 'string' // 字符串
| 'vector2' // 2D 向量
| 'vector3' // 3D 向量
| 'entity' // 实体引用
| 'component' // 组件引用
| 'any'; // 任意类型
```
### 变量作用域
```typescript
type VariableScope =
| 'local' // 每次执行独立
| 'instance' // 每个实体独立
| 'global'; // 全局共享
```
## 文档导航
- [虚拟机 API](./vm) - BlueprintVM 执行和上下文
- [自定义节点](./custom-nodes) - 创建自定义节点
- [内置节点](./nodes) - 内置节点参考
- [蓝图组合](./composition) - 片段和组合器
- [实际示例](./examples) - ECS 集成和最佳实践
- [虚拟机 API](./vm) - BlueprintVM 与 ECS 集成
- [ECS 节点参考](./nodes) - 内置 ECS 操作节点
- [自定义节点](./custom-nodes) - 创建自定义 ECS 节点
- [蓝图组合](./composition) - 片段复用
- [实际示例](./examples) - ECS 游戏逻辑示例

View File

@@ -1,107 +1,118 @@
---
title: "内置节点"
description: "蓝图内置节点参考"
title: "ECS 节点参考"
description: "蓝图内置 ECS 操作节点"
---
## 事件节点
生命周期事件,作为蓝图执行的入口点:
| 节点 | 说明 |
|------|------|
| `EventBeginPlay` | 蓝图启动时触发 |
| `EventTick` | 每帧触发 |
| `EventTick` | 每帧触发,接收 deltaTime |
| `EventEndPlay` | 蓝图停止时触发 |
| `EventCollision` | 碰撞时触发 |
| `EventInput` | 输入事件触发 |
| `EventTimer` | 定时器触发 |
| `EventMessage` | 自定义消息触发 |
## 流程控制节点
## 实体节点 (Entity)
操作 ECS 实体:
| 节点 | 说明 | 类型 |
|------|------|------|
| `Get Self` | 获取拥有此蓝图的实体 | 纯节点 |
| `Create Entity` | 在场景中创建新实体 | 执行节点 |
| `Destroy Entity` | 销毁指定实体 | 执行节点 |
| `Destroy Self` | 销毁自身实体 | 执行节点 |
| `Is Valid` | 检查实体是否有效 | 纯节点 |
| `Get Entity Name` | 获取实体名称 | 纯节点 |
| `Set Entity Name` | 设置实体名称 | 执行节点 |
| `Get Entity Tag` | 获取实体标签 | 纯节点 |
| `Set Entity Tag` | 设置实体标签 | 执行节点 |
| `Set Active` | 设置实体激活状态 | 执行节点 |
| `Is Active` | 检查实体是否激活 | 纯节点 |
| `Find Entity By Name` | 按名称查找实体 | 纯节点 |
| `Find Entities By Tag` | 按标签查找所有实体 | 纯节点 |
| `Get Entity ID` | 获取实体唯一 ID | 纯节点 |
| `Find Entity By ID` | 按 ID 查找实体 | 纯节点 |
## 组件节点 (Component)
操作 ECS 组件:
| 节点 | 说明 | 类型 |
|------|------|------|
| `Has Component` | 检查实体是否有指定组件 | 纯节点 |
| `Get Component` | 获取实体的组件 | 纯节点 |
| `Get All Components` | 获取实体所有组件 | 纯节点 |
| `Remove Component` | 移除组件 | 执行节点 |
| `Get Component Property` | 获取组件属性值 | 纯节点 |
| `Set Component Property` | 设置组件属性值 | 执行节点 |
| `Get Component Type` | 获取组件类型名称 | 纯节点 |
| `Get Owner Entity` | 从组件获取所属实体 | 纯节点 |
## 流程控制节点 (Flow)
控制执行流程:
| 节点 | 说明 |
|------|------|
| `Branch` | 条件分支 (if/else) |
| `Sequence` | 顺序执行多个输出 |
| `ForLoop` | 循环执行 |
| `WhileLoop` | 条件循环 |
| `DoOnce` | 只执行一次 |
| `FlipFlop` | 交替执行两个分支 |
| `For Loop` | 循环执行 |
| `For Each` | 遍历数组 |
| `While Loop` | 条件循环 |
| `Do Once` | 只执行一次 |
| `Flip Flop` | 交替执行两个分支 |
| `Gate` | 可开关的执行门 |
## 时间节点
## 时间节点 (Time)
| 节点 | 说明 | 类型 |
|------|------|------|
| `Delay` | 延迟执行 | 执行节点 |
| `Get Delta Time` | 获取帧间隔时间 | 纯节点 |
| `Get Time` | 获取运行总时间 | 纯节点 |
## 数学节点 (Math)
| 节点 | 说明 |
|------|------|
| `Delay` | 延迟执行 |
| `GetDeltaTime` | 获取帧间隔 |
| `GetTime` | 获取运行时间 |
| `SetTimer` | 设置定时器 |
| `ClearTimer` | 清除定时器 |
## 数学节点
| 节点 | 说明 |
|------|------|
| `Add` | 加法 |
| `Subtract` | 减法 |
| `Multiply` | 乘法 |
| `Divide` | 除法 |
| `Add` / `Subtract` / `Multiply` / `Divide` | 四则运算 |
| `Abs` | 绝对值 |
| `Clamp` | 限制范围 |
| `Lerp` | 线性插值 |
| `Min` / `Max` | 最小/最大值 |
| `Sin` / `Cos` | 三角函数 |
| `Sqrt` | 平方根 |
| `Power` | 幂运算 |
## 逻辑节点
## 调试节点 (Debug)
| 节点 | 说明 |
|------|------|
| `And` | 逻辑与 |
| `Or` | 逻辑或 |
| `Not` | 逻辑非 |
| `Equal` | 相等比较 |
| `NotEqual` | 不等比较 |
| `Greater` | 大于比较 |
| `Less` | 小于比较 |
| `Print` | 输出到控制台 |
## 向量节点
## 自动生成的组件节点
| 节点 | 说明 |
|------|------|
| `MakeVector2` | 创建 2D 向量 |
| `BreakVector2` | 分解 2D 向量 |
| `VectorAdd` | 向量加法 |
| `VectorSubtract` | 向量减法 |
| `VectorMultiply` | 向量乘法 |
| `VectorLength` | 向量长度 |
| `VectorNormalize` | 向量归一化 |
| `VectorDistance` | 向量距离 |
使用 `@BlueprintExpose` 装饰器标记的组件会自动生成节点:
## 实体节点
```typescript
@ECSComponent('Transform')
@BlueprintExpose({ displayName: '变换', category: 'core' })
export class TransformComponent extends Component {
@BlueprintProperty({ displayName: 'X 坐标' })
x: number = 0;
| 节点 | 说明 |
|------|------|
| `GetSelf` | 获取当前实体 |
| `GetComponent` | 获取组件 |
| `HasComponent` | 检查组件 |
| `AddComponent` | 添加组件 |
| `RemoveComponent` | 移除组件 |
| `SpawnEntity` | 创建实体 |
| `DestroyEntity` | 销毁实体 |
@BlueprintProperty({ displayName: 'Y 坐标' })
y: number = 0;
## 变量节点
@BlueprintMethod({ displayName: '移动' })
translate(dx: number, dy: number): void {
this.x += dx;
this.y += dy;
}
}
```
| 节点 | 说明 |
|------|------|
| `GetVariable` | 获取变量值 |
| `SetVariable` | 设置变量值 |
## 调试节点
| 节点 | 说明 |
|------|------|
| `Print` | 打印到控制台 |
| `DrawDebugLine` | 绘制调试线 |
| `DrawDebugPoint` | 绘制调试点 |
| `Breakpoint` | 调试断点 |
生成的节点:
- **Get Transform** - 获取 Transform 组件
- **Get X 坐标** / **Set X 坐标** - 访问 x 属性
- **Get Y 坐标** / **Set Y 坐标** - 访问 y 属性
- **移动** - 调用 translate 方法

View File

@@ -45,7 +45,7 @@ interface ExecutionContext {
time: number; // 总运行时间
// 获取输入值
getInput<T>(nodeId: string, pinName: string): T;
evaluateInput(nodeId: string, pinName: string, defaultValue: unknown): unknown;
// 设置输出值
setOutput(nodeId: string, pinName: string, value: unknown): void;
@@ -70,35 +70,33 @@ interface ExecutionResult {
## 与 ECS 集成
### 使用蓝图系统
### 使用内置蓝图系统
```typescript
import { createBlueprintSystem } from '@esengine/blueprint';
import { Scene, Core } from '@esengine/ecs-framework';
import { BlueprintSystem, BlueprintComponent } from '@esengine/blueprint';
class GameScene {
private blueprintSystem: BlueprintSystem;
// 添加蓝图系统到场景
const scene = new Scene();
scene.addSystem(new BlueprintSystem());
Core.setScene(scene);
initialize() {
this.blueprintSystem = createBlueprintSystem(this.scene);
}
update(dt: number) {
// 处理所有带蓝图组件的实体
this.blueprintSystem.process(this.entities, dt);
}
}
// 为实体添加蓝图
const entity = scene.createEntity('Player');
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
entity.addComponent(blueprint);
```
### 触发蓝图事件
```typescript
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
// 触发内置事件
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
// 触发自定义事件
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
// 从实体获取蓝图组件并触发事件
const blueprint = entity.getComponent(BlueprintComponent);
if (blueprint?.vm) {
blueprint.vm.triggerEvent('EventCollision', { other: otherEntity });
blueprint.vm.triggerCustomEvent('OnPickup', { item: itemEntity });
}
```
## 序列化

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

@@ -35,6 +35,14 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
| 模块 | 包名 | 描述 |
|------|------|------|
| [网络同步](/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

@@ -0,0 +1,441 @@
---
title: "分布式房间"
description: "使用 DistributedRoomManager 实现多服务器房间管理"
---
## 概述
分布式房间支持允许多个服务器实例共享房间注册表,实现跨服务器玩家路由和故障转移。
```
┌─────────────────────────────────────────────────────────┐
│ Server A Server B Server C │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ IDistributedAdapter │ │
│ │ (Redis / Memory) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## 快速开始
### 单机模式(测试用)
```typescript
import {
DistributedRoomManager,
MemoryAdapter,
Room
} from '@esengine/server';
// 定义房间类型
class GameRoom extends Room {
maxPlayers = 4;
}
// 创建适配器和管理器
const adapter = new MemoryAdapter();
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
// 注册房间类型
manager.define('game', GameRoom);
// 启动管理器
await manager.start();
// 分布式加入/创建房间
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// 玩家应连接到其他服务器
console.log(`重定向到: ${result.redirect}`);
} else {
// 玩家加入本地房间
const { room, player } = result;
}
// 优雅关闭
await manager.stop(true);
```
### 多服务器模式(生产用)
```typescript
import Redis from 'ioredis';
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis({
host: 'redis.example.com',
port: 6379
}),
prefix: 'game:',
serverTtl: 30,
snapshotTtl: 86400
});
const manager = new DistributedRoomManager(adapter, {
serverId: process.env.SERVER_ID,
serverAddress: process.env.PUBLIC_IP,
serverPort: 3000,
heartbeatInterval: 5000,
snapshotInterval: 30000,
enableFailover: true,
capacity: 100
}, sendFn);
```
## DistributedRoomManager
### 配置选项
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `serverId` | `string` | 必填 | 服务器唯一标识 |
| `serverAddress` | `string` | 必填 | 客户端连接的公开地址 |
| `serverPort` | `number` | 必填 | 服务器端口 |
| `heartbeatInterval` | `number` | `5000` | 心跳间隔(毫秒) |
| `snapshotInterval` | `number` | `30000` | 状态快照间隔0 禁用 |
| `migrationTimeout` | `number` | `10000` | 房间迁移超时 |
| `enableFailover` | `boolean` | `true` | 启用自动故障转移 |
| `capacity` | `number` | `100` | 本服务器最大房间数 |
### 生命周期方法
#### start()
启动分布式房间管理器。连接适配器、注册服务器、启动心跳。
```typescript
await manager.start();
```
#### stop(graceful?)
停止管理器。如果 `graceful=true`,将服务器标记为 draining 并保存所有房间快照。
```typescript
await manager.stop(true);
```
### 路由方法
#### joinOrCreateDistributed()
分布式感知的加入或创建房间。返回本地房间的 `{ room, player }` 或远程房间的 `{ redirect: string }`
```typescript
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// 客户端应重定向到其他服务器
res.json({ redirect: result.redirect });
} else {
// 玩家加入了本地房间
const { room, player } = result;
}
```
#### route()
将玩家路由到合适的房间/服务器。
```typescript
const result = await manager.route({
roomType: 'game',
playerId: 'p1'
});
switch (result.type) {
case 'local': // 房间在本服务器
break;
case 'redirect': // 房间在其他服务器
// result.serverAddress 包含目标服务器地址
break;
case 'create': // 没有可用房间,需要创建
break;
case 'unavailable': // 无法找到或创建房间
// result.reason 包含错误信息
break;
}
```
### 状态管理
#### saveSnapshot()
手动保存房间状态快照。
```typescript
await manager.saveSnapshot(roomId);
```
#### restoreFromSnapshot()
从保存的快照恢复房间。
```typescript
const success = await manager.restoreFromSnapshot(roomId);
```
### 查询方法
#### getServers()
获取所有在线服务器。
```typescript
const servers = await manager.getServers();
```
#### queryDistributedRooms()
查询所有服务器上的房间。
```typescript
const rooms = await manager.queryDistributedRooms({
roomType: 'game',
hasSpace: true,
notLocked: true
});
```
## IDistributedAdapter
分布式后端的接口。实现此接口以支持 Redis、消息队列等。
### 内置适配器
#### MemoryAdapter
用于测试和单机模式的内存实现。
```typescript
const adapter = new MemoryAdapter({
serverTtl: 15000, // 无心跳后服务器离线时间(毫秒)
enableTtlCheck: true, // 启用自动 TTL 检查
ttlCheckInterval: 5000 // TTL 检查间隔(毫秒)
});
```
#### RedisAdapter
用于生产环境多服务器部署的 Redis 实现。
```typescript
import Redis from 'ioredis';
import { RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis('redis://localhost:6379'),
prefix: 'game:', // 键前缀(默认: 'dist:'
serverTtl: 30, // 服务器 TTL默认: 30
roomTtl: 0, // 房间 TTL0 = 永不过期(默认: 0
snapshotTtl: 86400, // 快照 TTL默认: 24 小时)
channel: 'game:events' // Pub/Sub 频道(默认: 'distributed:events'
});
```
**RedisAdapter 配置:**
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `factory` | `() => RedisClient` | 必填 | Redis 客户端工厂(惰性连接) |
| `prefix` | `string` | `'dist:'` | 所有 Redis 键的前缀 |
| `serverTtl` | `number` | `30` | 服务器 TTL |
| `roomTtl` | `number` | `0` | 房间 TTL0 = 不过期 |
| `snapshotTtl` | `number` | `86400` | 快照 TTL |
| `channel` | `string` | `'distributed:events'` | Pub/Sub 频道名 |
**功能特性:**
- 带自动心跳 TTL 的服务器注册
- 跨服务器查找的房间注册
- 可配置 TTL 的状态快照
- 跨服务器事件的 Pub/Sub
- 使用 Redis SET NX 的分布式锁
### 自定义适配器
```typescript
import type { IDistributedAdapter } from '@esengine/server';
class MyAdapter implements IDistributedAdapter {
// 生命周期
async connect(): Promise<void> { }
async disconnect(): Promise<void> { }
isConnected(): boolean { return true; }
// 服务器注册
async registerServer(server: ServerRegistration): Promise<void> { }
async unregisterServer(serverId: string): Promise<void> { }
async heartbeat(serverId: string): Promise<void> { }
async getServers(): Promise<ServerRegistration[]> { return []; }
// 房间注册
async registerRoom(room: RoomRegistration): Promise<void> { }
async unregisterRoom(roomId: string): Promise<void> { }
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
// 状态快照
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
// 发布/订阅
async publish(event: DistributedEvent): Promise<void> { }
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
// 分布式锁
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
async releaseLock(key: string): Promise<void> { }
}
```
## 玩家路由流程
```
客户端 服务器 A 服务器 B
│ │ │
│─── joinOrCreate ────────►│ │
│ │ │
│ │── findAvailableRoom() ───►│
│ │◄──── 服务器 B 上有房间 ────│
│ │ │
│◄─── redirect: B:3001 ────│ │
│ │ │
│───────────────── 连接到服务器 B ────────────────────►│
│ │ │
│◄─────────────────────────────── 已加入 ─────────────│
```
## 事件类型
分布式系统发布以下事件:
| 事件 | 描述 |
|------|------|
| `server:online` | 服务器上线 |
| `server:offline` | 服务器离线 |
| `server:draining` | 服务器正在排空 |
| `room:created` | 房间已创建 |
| `room:disposed` | 房间已销毁 |
| `room:updated` | 房间信息已更新 |
| `room:message` | 跨服务器房间消息 |
| `room:migrated` | 房间已迁移到其他服务器 |
| `player:joined` | 玩家加入房间 |
| `player:left` | 玩家离开房间 |
## 最佳实践
1. **使用唯一服务器 ID** - 使用主机名、容器 ID 或 UUID
2. **配置合适的心跳** - 在新鲜度和网络开销之间平衡
3. **为有状态房间启用快照** - 确保房间状态在服务器重启后存活
4. **优雅处理重定向** - 客户端应重新连接到目标服务器
```typescript
// 客户端处理重定向
if (response.redirect) {
await client.disconnect();
await client.connect(response.redirect);
await client.joinRoom(roomId);
}
```
5. **使用分布式锁** - 防止 joinOrCreate 中的竞态条件
## 使用 createServer 集成
最简单的使用方式是通过 `createServer` 的 `distributed` 配置:
```typescript
import { createServer } from '@esengine/server';
import { RedisAdapter, Room } from '@esengine/server';
import Redis from 'ioredis';
class GameRoom extends Room {
maxPlayers = 4;
}
const server = await createServer({
port: 3000,
distributed: {
enabled: true,
adapter: new RedisAdapter({ factory: () => new Redis() }),
serverId: 'server-1',
serverAddress: 'ws://192.168.1.100',
serverPort: 3000,
enableFailover: true,
capacity: 100
}
});
server.define('game', GameRoom);
await server.start();
```
当客户端调用 `JoinRoom` API 时,服务器会自动:
1. 查找可用房间(本地或远程)
2. 如果房间在其他服务器,发送 `$redirect` 消息给客户端
3. 客户端收到重定向消息后连接到目标服务器
## 负载均衡
使用 `LoadBalancedRouter` 进行服务器选择:
```typescript
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
// 使用工厂函数
const router = createLoadBalancedRouter('least-players');
// 或直接创建
const router = new LoadBalancedRouter({
strategy: 'least-rooms', // 选择房间数最少的服务器
preferLocal: true // 优先选择本地服务器
});
// 可用策略
// - 'round-robin': 轮询
// - 'least-rooms': 最少房间数
// - 'least-players': 最少玩家数
// - 'random': 随机选择
// - 'weighted': 权重(基于容量使用率)
```
## 故障转移
当服务器离线时,启用 `enableFailover` 后系统会自动:
1. 检测到服务器离线(通过心跳超时)
2. 查询该服务器上的所有房间
3. 使用分布式锁防止多服务器同时恢复
4. 从快照恢复房间状态
5. 发布 `room:migrated` 事件通知其他服务器
```typescript
// 确保定期保存快照
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000,
snapshotInterval: 30000, // 每 30 秒保存快照
enableFailover: true // 启用故障转移
}, sendFn);
```
## 后续版本
- Redis Cluster 支持
- 更多负载均衡策略(地理位置、延迟感知)

View File

@@ -0,0 +1,679 @@
---
title: "HTTP 路由"
description: "HTTP REST API 路由功能,支持与 WebSocket 共用端口"
---
`@esengine/server` 内置了轻量级的 HTTP 路由功能,可以与 WebSocket 服务共用同一端口,方便实现 REST API。
## 快速开始
### 内联路由定义
最简单的方式是在创建服务器时直接定义 HTTP 路由:
```typescript
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
http: {
'/api/health': (req, res) => {
res.json({ status: 'ok', time: Date.now() })
},
'/api/users': {
GET: (req, res) => {
res.json({ users: [] })
},
POST: async (req, res) => {
const body = req.body as { name: string }
res.status(201).json({ id: '1', name: body.name })
}
}
},
cors: true // 启用 CORS
})
await server.start()
```
### 文件路由
对于较大的项目,推荐使用文件路由。创建 `src/http` 目录,每个文件对应一个路由:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST',
handler(req, res) {
const { username, password } = req.body as LoginBody
// 验证用户...
if (username === 'admin' && password === '123456') {
res.json({ token: 'jwt-token-here', userId: 'user-1' })
} else {
res.error(401, '用户名或密码错误')
}
}
})
```
```typescript
// server.ts
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP 路由目录
httpPrefix: '/api', // 路由前缀
cors: true
})
await server.start()
// 路由: POST /api/login
```
## defineHttp 定义
`defineHttp` 用于定义类型安全的 HTTP 处理器:
```typescript
import { defineHttp } from '@esengine/server'
interface CreateUserBody {
username: string
email: string
password: string
}
export default defineHttp<CreateUserBody>({
// HTTP 方法(默认 POST
method: 'POST',
// 处理函数
handler(req, res) {
const body = req.body as CreateUserBody
// 处理请求...
res.status(201).json({ id: 'new-user-id' })
}
})
```
### 支持的 HTTP 方法
```typescript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
```
## HttpRequest 对象
HTTP 请求对象包含以下属性:
```typescript
interface HttpRequest {
/** 原始 Node.js IncomingMessage */
raw: IncomingMessage
/** HTTP 方法 */
method: string
/** 请求路径 */
path: string
/** 路由参数(从 URL 路径提取,如 /users/:id */
params: Record<string, string>
/** 查询参数 */
query: Record<string, string>
/** 请求头 */
headers: Record<string, string | string[] | undefined>
/** 解析后的请求体 */
body: unknown
/** 客户端 IP */
ip: string
}
```
### 使用示例
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// 获取查询参数
const page = parseInt(req.query.page ?? '1')
const limit = parseInt(req.query.limit ?? '10')
// 获取请求头
const authHeader = req.headers.authorization
// 获取客户端 IP
console.log('Request from:', req.ip)
res.json({ page, limit })
}
})
```
### 请求体解析
请求体会根据 `Content-Type` 自动解析:
- `application/json` - 解析为 JSON 对象
- `application/x-www-form-urlencoded` - 解析为键值对对象
- 其他 - 保持原始字符串
```typescript
export default defineHttp<{ name: string; age: number }>({
method: 'POST',
handler(req, res) {
// body 已自动解析
const { name, age } = req.body as { name: string; age: number }
res.json({ received: { name, age } })
}
})
```
## HttpResponse 对象
HTTP 响应对象提供链式 API
```typescript
interface HttpResponse {
/** 原始 Node.js ServerResponse */
raw: ServerResponse
/** 设置状态码 */
status(code: number): HttpResponse
/** 设置响应头 */
header(name: string, value: string): HttpResponse
/** 发送 JSON 响应 */
json(data: unknown): void
/** 发送文本响应 */
text(data: string): void
/** 发送错误响应 */
error(code: number, message: string): void
}
```
### 使用示例
```typescript
export default defineHttp({
method: 'POST',
handler(req, res) {
// 设置状态码和自定义头
res
.status(201)
.header('X-Custom-Header', 'value')
.json({ created: true })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// 发送错误响应
res.error(404, '资源不存在')
// 等价于: res.status(404).json({ error: '资源不存在' })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// 发送纯文本
res.text('Hello, World!')
}
})
```
## 文件路由规范
### 命名转换
文件名会自动转换为路由路径:
| 文件路径 | 路由路径prefix=/api |
|---------|----------------------|
| `login.ts` | `/api/login` |
| `users/profile.ts` | `/api/users/profile` |
| `users/[id].ts` | `/api/users/:id` |
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
### 动态路由参数
使用 `[param]` 语法定义动态参数:
```typescript
// src/http/users/[id].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
// 直接从 params 获取路由参数
const { id } = req.params
res.json({ userId: id })
}
})
```
多个参数的情况:
```typescript
// src/http/users/[userId]/posts/[postId].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
const { userId, postId } = req.params
res.json({ userId, postId })
}
})
```
### 跳过规则
以下文件会被自动跳过:
-`_` 开头的文件(如 `_helper.ts`
- `index.ts` / `index.js` 文件
-`.ts` / `.js` / `.mts` / `.mjs` 文件
### 目录结构示例
```
src/
└── http/
├── _utils.ts # 跳过(下划线开头)
├── index.ts # 跳过index 文件)
├── health.ts # GET /api/health
├── login.ts # POST /api/login
├── register.ts # POST /api/register
└── users/
├── index.ts # 跳过
├── list.ts # GET /api/users/list
└── [id].ts # GET /api/users/:id
```
## CORS 配置
### 快速启用
```typescript
const server = await createServer({
port: 3000,
cors: true // 使用默认配置
})
```
### 自定义配置
```typescript
const server = await createServer({
port: 3000,
cors: {
// 允许的来源
origin: ['http://localhost:5173', 'https://myapp.com'],
// 或使用通配符
// origin: '*',
// origin: true, // 反射请求来源
// 允许的 HTTP 方法
methods: ['GET', 'POST', 'PUT', 'DELETE'],
// 允许的请求头
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
// 是否允许携带凭证cookies
credentials: true,
// 预检请求缓存时间(秒)
maxAge: 86400
}
})
```
### CorsOptions 类型
```typescript
interface CorsOptions {
/** 允许的来源字符串、字符串数组、true反射或 '*' */
origin?: string | string[] | boolean
/** 允许的 HTTP 方法 */
methods?: string[]
/** 允许的请求头 */
allowedHeaders?: string[]
/** 是否允许携带凭证 */
credentials?: boolean
/** 预检请求缓存时间(秒) */
maxAge?: number
}
```
## 路由合并
文件路由和内联路由可以同时使用,内联路由优先级更高:
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http',
httpPrefix: '/api',
// 内联路由会与文件路由合并
http: {
'/health': (req, res) => res.json({ status: 'ok' }),
'/api/special': (req, res) => res.json({ special: true })
}
})
```
## 与 WebSocket 共用端口
HTTP 路由与 WebSocket 服务自动共用同一端口:
```typescript
const server = await createServer({
port: 3000,
// WebSocket 相关配置
apiDir: './src/api',
msgDir: './src/msg',
// HTTP 相关配置
httpDir: './src/http',
httpPrefix: '/api',
cors: true
})
await server.start()
// 同一端口 3000
// - WebSocket: ws://localhost:3000
// - HTTP API: http://localhost:3000/api/*
```
## 完整示例
### 游戏服务器登录 API
```typescript
// src/http/auth/login.ts
import { defineHttp } from '@esengine/server'
import { createJwtAuthProvider } from '@esengine/server/auth'
interface LoginRequest {
username: string
password: string
}
interface LoginResponse {
token: string
userId: string
expiresAt: number
}
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600
})
export default defineHttp<LoginRequest>({
method: 'POST',
async handler(req, res) {
const { username, password } = req.body as LoginRequest
// 验证用户
const user = await db.users.findByUsername(username)
if (!user || !await verifyPassword(password, user.passwordHash)) {
res.error(401, '用户名或密码错误')
return
}
// 生成 JWT
const token = jwtProvider.sign({
sub: user.id,
name: user.username,
roles: user.roles
})
const response: LoginResponse = {
token,
userId: user.id,
expiresAt: Date.now() + 3600 * 1000
}
res.json(response)
}
})
```
### 游戏数据查询 API
```typescript
// src/http/game/leaderboard.ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
async handler(req, res) {
const limit = parseInt(req.query.limit ?? '10')
const offset = parseInt(req.query.offset ?? '0')
const players = await db.players.findMany({
sort: { score: 'desc' },
limit,
offset
})
res.json({
data: players,
pagination: { limit, offset }
})
}
})
```
## 中间件
### 中间件类型
中间件是在路由处理前后执行的函数:
```typescript
type HttpMiddleware = (
req: HttpRequest,
res: HttpResponse,
next: () => Promise<void>
) => void | Promise<void>
```
### 内置中间件
```typescript
import {
requestLogger,
bodyLimit,
responseTime,
requestId,
securityHeaders
} from '@esengine/server'
const server = await createServer({
port: 3000,
http: { /* ... */ },
// 全局中间件通过 createHttpRouter 配置
})
```
#### requestLogger - 请求日志
```typescript
import { requestLogger } from '@esengine/server'
// 记录请求和响应时间
requestLogger()
// 同时记录请求体
requestLogger({ logBody: true })
```
#### bodyLimit - 请求体大小限制
```typescript
import { bodyLimit } from '@esengine/server'
// 限制请求体为 1MB
bodyLimit(1024 * 1024)
```
#### responseTime - 响应时间头
```typescript
import { responseTime } from '@esengine/server'
// 自动添加 X-Response-Time 响应头
responseTime()
```
#### requestId - 请求 ID
```typescript
import { requestId } from '@esengine/server'
// 自动生成并添加 X-Request-ID 响应头
requestId()
// 自定义头名称
requestId('X-Trace-ID')
```
#### securityHeaders - 安全头
```typescript
import { securityHeaders } from '@esengine/server'
// 添加常用安全响应头
securityHeaders()
// 自定义配置
securityHeaders({
hidePoweredBy: true,
frameOptions: 'DENY',
noSniff: true
})
```
### 自定义中间件
```typescript
import type { HttpMiddleware } from '@esengine/server'
// 认证中间件
const authMiddleware: HttpMiddleware = async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) {
res.error(401, 'Unauthorized')
return // 不调用 next(),终止请求
}
// 验证 token...
(req as any).userId = 'decoded-user-id'
await next() // 继续执行后续中间件和处理器
}
```
### 使用中间件
#### 使用 createHttpRouter
```typescript
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
const router = createHttpRouter({
'/api/users': (req, res) => res.json([]),
'/api/admin': {
GET: {
handler: (req, res) => res.json({ admin: true }),
middlewares: [adminAuthMiddleware] // 路由级中间件
}
}
}, {
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // 全局中间件
timeout: 30000 // 全局超时 30 秒
})
```
## 请求超时
### 全局超时
```typescript
import { createHttpRouter } from '@esengine/server'
const router = createHttpRouter({
'/api/data': async (req, res) => {
// 如果处理超过 30 秒,自动返回 408 Request Timeout
await someSlowOperation()
res.json({ data: 'result' })
}
}, {
timeout: 30000 // 30 秒
})
```
### 路由级超时
```typescript
const router = createHttpRouter({
'/api/quick': (req, res) => res.json({ fast: true }),
'/api/slow': {
POST: {
handler: async (req, res) => {
await verySlowOperation()
res.json({ done: true })
},
timeout: 120000 // 这个路由允许 2 分钟
}
}
}, {
timeout: 10000 // 全局 10 秒(被路由级覆盖)
})
```
## 最佳实践
1. **使用 defineHttp** - 获得更好的类型提示和代码组织
2. **统一错误处理** - 使用 `res.error()` 返回一致的错误格式
3. **启用 CORS** - 前后端分离时必须配置
4. **目录组织** - 按功能模块组织 HTTP 路由文件
5. **验证输入** - 始终验证 `req.body``req.query` 的内容
6. **状态码规范** - 遵循 HTTP 状态码规范200、201、400、401、404、500 等)
7. **使用中间件** - 通过中间件实现认证、日志、限流等横切关注点
8. **设置超时** - 避免慢请求阻塞服务器

View File

@@ -147,7 +147,11 @@ service.on('chat', (data) => {
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
- [状态同步](/modules/network/sync/) - 插值、预测和快照
- [分布式房间](/modules/network/distributed/) - 多服务器房间管理和玩家路由
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
- [增量压缩](/modules/network/delta/) - 状态增量同步
- [API 参考](/modules/network/api/) - 完整 API 文档
## 服务令牌
@@ -159,10 +163,14 @@ import {
NetworkServiceToken,
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken
NetworkInputSystemToken,
NetworkPredictionSystemToken,
NetworkAOISystemToken,
} from '@esengine/network';
const networkService = services.get(NetworkServiceToken);
const predictionSystem = services.get(NetworkPredictionSystemToken);
const aoiSystem = services.get(NetworkAOISystemToken);
```
## 蓝图节点

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

@@ -79,10 +79,47 @@ await server.start()
| `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 共用端口,适用于登录、注册等场景。
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP 路由目录
httpPrefix: '/api', // 路由前缀
cors: true,
// 或内联定义
http: {
'/health': (req, res) => res.json({ status: 'ok' })
}
})
```
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
export default defineHttp<{ username: string; password: string }>({
method: 'POST',
handler(req, res) {
const { username, password } = req.body
// 验证并返回 token...
res.json({ token: '...' })
}
})
```
> 详细文档请参考 [HTTP 路由](/modules/network/http)
## Room 系统
Room 是游戏房间的基类,管理玩家和游戏状态。
@@ -243,6 +280,122 @@ class GameRoom extends Room {
}
```
## Schema 验证
使用内置的 Schema 验证系统进行运行时类型验证:
### 基础用法
```typescript
import { s, defineApiWithSchema } from '@esengine/server'
// 定义 Schema
const MoveSchema = s.object({
x: s.number(),
y: s.number(),
speed: s.number().optional()
})
// 类型自动推断
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
// 使用 Schema 定义 API自动验证
export default defineApiWithSchema(MoveSchema, {
handler(req, ctx) {
// req 已验证,类型安全
console.log(req.x, req.y)
}
})
```
### 验证器类型
| 类型 | 示例 | 描述 |
|------|------|------|
| `s.string()` | `s.string().min(1).max(50)` | 字符串,支持长度限制 |
| `s.number()` | `s.number().min(0).int()` | 数字,支持范围和整数限制 |
| `s.boolean()` | `s.boolean()` | 布尔值 |
| `s.literal()` | `s.literal('admin')` | 字面量类型 |
| `s.object()` | `s.object({ name: s.string() })` | 对象 |
| `s.array()` | `s.array(s.number())` | 数组 |
| `s.enum()` | `s.enum(['a', 'b'] as const)` | 枚举 |
| `s.union()` | `s.union([s.string(), s.number()])` | 联合类型 |
| `s.record()` | `s.record(s.any())` | 记录类型 |
### 修饰符
```typescript
// 可选字段
s.string().optional()
// 默认值
s.number().default(0)
// 可为 null
s.string().nullable()
// 字符串验证
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
// 数字验证
s.number().min(0).max(100).int().positive()
// 数组验证
s.array(s.string()).min(1).max(10).nonempty()
// 对象验证
s.object({ ... }).strict() // 不允许额外字段
s.object({ ... }).partial() // 所有字段可选
s.object({ ... }).pick('name', 'age') // 选择字段
s.object({ ... }).omit('password') // 排除字段
```
### 消息验证
```typescript
import { s, defineMsgWithSchema } from '@esengine/server'
const InputSchema = s.object({
keys: s.array(s.string()),
timestamp: s.number()
})
export default defineMsgWithSchema(InputSchema, {
handler(msg, ctx) {
// msg 已验证
console.log(msg.keys, msg.timestamp)
}
})
```
### 手动验证
```typescript
import { s, parse, safeParse, createGuard } from '@esengine/server'
const UserSchema = s.object({
name: s.string(),
age: s.number().int().min(0)
})
// 抛出错误
const user = parse(UserSchema, data)
// 返回结果对象
const result = safeParse(UserSchema, data)
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}
// 类型守卫
const isUser = createGuard(UserSchema)
if (isUser(data)) {
// data 是 User 类型
}
```
## 协议定义
`src/shared/protocol.ts` 中定义客户端和服务端共享的类型:
@@ -311,6 +464,93 @@ client.send('RoomMessage', {
})
```
## 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. **合理设置 Tick 频率**

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

@@ -13,6 +13,7 @@
"packages/network-ext/*",
"packages/editor/*",
"packages/editor/plugins/*",
"packages/devtools/*",
"packages/rust/*",
"packages/tools/*"
],
@@ -74,6 +75,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,48 @@
# @esengine/node-editor
## 1.3.0
### Minor Changes
- [`3bfb8a1`](https://github.com/esengine/esengine/commit/3bfb8a1c9baba18373717910d29f266a71c1f63e) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): add box selection and variable node error states
- Add box selection: drag on empty canvas to select multiple nodes
- Support Ctrl+drag for additive selection (add to existing selection)
- Add error state styling for invalid variable references (red border, warning icon)
- Support dynamic node title via `data.displayTitle`
- Support hiding inputs via `data.hiddenInputs` array
## 1.2.2
### Patch Changes
- [#435](https://github.com/esengine/esengine/pull/435) [`c2acd14`](https://github.com/esengine/esengine/commit/c2acd14fce83af6cd116b3f2e40607229ccc3d6e) Thanks [@esengine](https://github.com/esengine)! - fix(node-editor): 修复节点收缩后连线不显示的问题
- 节点收缩时,连线会连接到节点头部(输入引脚在左侧,输出引脚在右侧)
- 展开后连线会自动恢复到正确位置
## 1.2.1
### Patch Changes
- [#433](https://github.com/esengine/esengine/pull/433) [`2e84942`](https://github.com/esengine/esengine/commit/2e84942ea14c5326620398add05840fa8bea16f8) Thanks [@esengine](https://github.com/esengine)! - fix(node-editor): 修复节点收缩后连线不显示的问题
- 节点收缩时,连线会连接到节点头部(输入引脚在左侧,输出引脚在右侧)
- 展开后连线会自动恢复到正确位置
## 1.2.0
### Minor Changes
- [#430](https://github.com/esengine/esengine/pull/430) [`caf3be7`](https://github.com/esengine/esengine/commit/caf3be72cdcc730492c63abe5f1715893f3579ac) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): 添加 Shadow DOM 样式注入支持 | Add Shadow DOM style injection support
**@esengine/node-editor**
- 新增 `nodeEditorCssText` 导出,包含所有编辑器样式的 CSS 文本 | Added `nodeEditorCssText` export containing all editor styles as CSS text
- 新增 `injectNodeEditorStyles(root)` 函数,支持将样式注入到 Shadow DOM | Added `injectNodeEditorStyles(root)` function for injecting styles into Shadow DOM
- 支持在 Cocos Creator 等使用 Shadow DOM 的环境中使用 | Support usage in Shadow DOM environments like Cocos Creator
## 1.1.0
### Minor Changes
- [#426](https://github.com/esengine/esengine/pull/426) [`6970394`](https://github.com/esengine/esengine/commit/6970394717ab8f743b0a41e248e3404a3b6fc7dc) Thanks [@esengine](https://github.com/esengine)! - feat: 独立发布节点编辑器 | Standalone node editor release
- 移动到 packages/devtools 目录 | Move to packages/devtools directory
- 清理依赖,使包可独立使用 | Clean dependencies for standalone use
- 可用于 Cocos Creator / LayaAir 插件开发 | Available for Cocos/Laya plugin development

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/node-editor",
"version": "1.0.0",
"version": "1.3.0",
"description": "Universal node-based visual editor for blueprint, shader graph, and state machine",
"main": "dist/index.js",
"module": "dist/index.js",
@@ -9,7 +9,8 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles": {
"import": "./dist/styles/index.css"
@@ -30,17 +31,18 @@
"blueprint",
"shader-graph",
"state-machine",
"ecs",
"game-engine"
"react"
],
"author": "yhh",
"author": "ESEngine Team",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0"
},
"devDependencies": {
"react": "^18.3.1",
"zustand": "^5.0.8",
"@types/node": "^20.19.17",
"@types/react": "^18.3.12",
"@vitejs/plugin-react": "^4.7.0",
"react": "^18.3.1",
"rimraf": "^5.0.0",
"typescript": "^5.8.3",
"vite": "^6.0.7",
@@ -56,7 +58,6 @@
"repository": {
"type": "git",
"url": "https://github.com/esengine/esengine.git",
"directory": "packages/node-editor"
},
"private": true
"directory": "packages/devtools/node-editor"
}
}

View File

@@ -50,6 +50,15 @@ export interface GraphCanvasProps {
/** Canvas context menu callback (画布右键菜单回调) */
onContextMenu?: (position: Position, e: React.MouseEvent) => void;
/** Canvas mouse down callback for box selection (画布鼠标按下回调,用于框选) */
onMouseDown?: (position: Position, e: React.MouseEvent) => void;
/** Canvas mouse move callback (画布鼠标移动回调) */
onCanvasMouseMove?: (position: Position, e: React.MouseEvent) => void;
/** Canvas mouse up callback (画布鼠标释放回调) */
onCanvasMouseUp?: (position: Position, e: React.MouseEvent) => void;
/** Children to render (要渲染的子元素) */
children?: React.ReactNode;
}
@@ -75,6 +84,9 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
onZoomChange,
onClick,
onContextMenu,
onMouseDown: onMouseDownProp,
onCanvasMouseMove,
onCanvasMouseUp,
children
}) => {
const containerRef = useRef<HTMLDivElement>(null);
@@ -132,22 +144,30 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
}, [zoom, pan, minZoom, maxZoom, updateZoom, updatePan]);
/**
* Handles mouse down for panning
*
* Handles mouse down for panning or box selection
*
*/
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Middle mouse button or space + left click for panning
// 中键或空格+左键平移
// Middle mouse button or Alt + left click for panning
// 中键或 Alt+左键平移
if (e.button === 1 || (e.button === 0 && e.altKey)) {
e.preventDefault();
setIsPanning(true);
lastMousePos.current = new Position(e.clientX, e.clientY);
} else if (e.button === 0) {
// Left click on canvas background - start box selection
// 左键点击画布背景 - 开始框选
const target = e.target as HTMLElement;
if (target === containerRef.current || target.classList.contains('ne-canvas-content')) {
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onMouseDownProp?.(canvasPos, e);
}
}
}, []);
}, [screenToCanvas, onMouseDownProp]);
/**
* Handles mouse move for panning
*
* Handles mouse move for panning or box selection
*
*/
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isPanning) {
@@ -157,13 +177,27 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
const newPan = new Position(pan.x + dx, pan.y + dy);
updatePan(newPan);
}
}, [isPanning, pan, updatePan]);
// Always call canvas mouse move for box selection
// 始终调用画布鼠标移动回调用于框选
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onCanvasMouseMove?.(canvasPos, e);
}, [isPanning, pan, updatePan, screenToCanvas, onCanvasMouseMove]);
/**
* Handles mouse up to stop panning
*
* Handles mouse up to stop panning or box selection
*
*/
const handleMouseUp = useCallback(() => {
const handleMouseUp = useCallback((e: React.MouseEvent) => {
setIsPanning(false);
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onCanvasMouseUp?.(canvasPos, e);
}, [screenToCanvas, onCanvasMouseUp]);
/**
* Handles mouse leave to stop panning
*
*/
const handleMouseLeave = useCallback(() => {
setIsPanning(false);
}, []);
@@ -276,7 +310,7 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onContextMenu={handleContextMenu}
>

View File

@@ -1,4 +1,4 @@
import React, { useRef, useCallback, useState, useMemo } from 'react';
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
import { Graph } from '../../domain/models/Graph';
import { GraphNode, NodeTemplate } from '../../domain/models/GraphNode';
import { Connection } from '../../domain/models/Connection';
@@ -80,6 +80,16 @@ interface ConnectionDragState {
isValid?: boolean;
}
/**
* Box selection state
*
*/
interface BoxSelectState {
startPos: Position;
currentPos: Position;
additive: boolean;
}
/**
* NodeEditor - Complete node graph editor component
* NodeEditor -
@@ -126,6 +136,30 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
const [dragState, setDragState] = useState<DragState | null>(null);
const [connectionDrag, setConnectionDrag] = useState<ConnectionDragState | null>(null);
const [hoveredPin, setHoveredPin] = useState<Pin | null>(null);
const [boxSelectState, setBoxSelectState] = useState<BoxSelectState | null>(null);
// Track if box selection just ended to prevent click from clearing selection
// 跟踪框选是否刚刚结束,以防止 click 清除选择
const boxSelectJustEndedRef = useRef(false);
// Force re-render after mount to ensure connections are drawn correctly
// 挂载后强制重渲染以确保连接线正确绘制
const [, forceUpdate] = useState(0);
// Track collapsed state to force connection re-render
// 跟踪折叠状态以强制连接线重渲染
const collapsedNodesKey = useMemo(() => {
return graph.nodes.map(n => `${n.id}:${n.isCollapsed}`).join(',');
}, [graph.nodes]);
useEffect(() => {
// Use requestAnimationFrame to wait for DOM to be fully rendered
// 使用 requestAnimationFrame 等待 DOM 完全渲染
const rafId = requestAnimationFrame(() => {
forceUpdate(n => n + 1);
});
return () => cancelAnimationFrame(rafId);
}, [graph.id, collapsedNodesKey]);
/**
* Converts screen coordinates to canvas coordinates
@@ -146,21 +180,51 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
*
*
* DOM
*
*/
const getPinPosition = useCallback((pinId: string): Position | undefined => {
// First, find which node this pin belongs to
// 首先查找该引脚属于哪个节点
let ownerNode: GraphNode | undefined;
for (const node of graph.nodes) {
if (node.allPins.some(p => p.id === pinId)) {
ownerNode = node;
break;
}
}
if (!ownerNode) return undefined;
// Find the pin element and its parent node
const pinElement = containerRef.current?.querySelector(`[data-pin-id="${pinId}"]`) as HTMLElement;
if (!pinElement) return undefined;
// If pin element not found (e.g., node is collapsed), use node header position
// 如果找不到引脚元素(例如节点已收缩),使用节点头部位置
if (!pinElement) {
const nodeElement = containerRef.current?.querySelector(`[data-node-id="${ownerNode.id}"]`) as HTMLElement;
if (!nodeElement) return undefined;
const nodeRect = nodeElement.getBoundingClientRect();
const { zoom } = transformRef.current;
// Find the pin to determine if it's input or output
const pin = ownerNode.allPins.find(p => p.id === pinId);
const isOutput = pin?.isOutput ?? false;
// For collapsed nodes, position at the right side for outputs, left side for inputs
// 对于收缩的节点,输出引脚在右侧,输入引脚在左侧
const headerHeight = 28; // Approximate header height
const relativeX = isOutput ? nodeRect.width / zoom : 0;
const relativeY = headerHeight / 2;
return new Position(
ownerNode.position.x + relativeX,
ownerNode.position.y + relativeY
);
}
const nodeElement = pinElement.closest('[data-node-id]') as HTMLElement;
if (!nodeElement) return undefined;
const nodeId = nodeElement.getAttribute('data-node-id');
if (!nodeId) return undefined;
const node = graph.getNode(nodeId);
if (!node) return undefined;
// Get pin position relative to node element (in unscaled pixels)
const nodeRect = nodeElement.getBoundingClientRect();
const pinRect = pinElement.getBoundingClientRect();
@@ -172,8 +236,8 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
// Final position = node position + relative position
return new Position(
node.position.x + relativeX,
node.position.y + relativeY
ownerNode.position.x + relativeX,
ownerNode.position.y + relativeY
);
}, [graph]);
@@ -428,6 +492,12 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
*
*/
const handleCanvasClick = useCallback((_position: Position, _e: React.MouseEvent) => {
// Skip if box selection just ended (click fires after mouseup)
// 如果框选刚刚结束则跳过click 在 mouseup 之后触发)
if (boxSelectJustEndedRef.current) {
boxSelectJustEndedRef.current = false;
return;
}
if (!readOnly) {
onSelectionChange?.(new Set(), new Set());
}
@@ -441,6 +511,79 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onCanvasContextMenu?.(position, e);
}, [onCanvasContextMenu]);
/**
* Handles box selection start
*
*/
const handleBoxSelectStart = useCallback((position: Position, e: React.MouseEvent) => {
if (readOnly) return;
setBoxSelectState({
startPos: position,
currentPos: position,
additive: e.ctrlKey || e.metaKey
});
}, [readOnly]);
/**
* Handles box selection move
*
*/
const handleBoxSelectMove = useCallback((position: Position) => {
if (boxSelectState) {
setBoxSelectState(prev => prev ? { ...prev, currentPos: position } : null);
}
}, [boxSelectState]);
/**
* Handles box selection end
*
*/
const handleBoxSelectEnd = useCallback(() => {
if (!boxSelectState) return;
const { startPos, currentPos, additive } = boxSelectState;
// Calculate selection box bounds
const minX = Math.min(startPos.x, currentPos.x);
const maxX = Math.max(startPos.x, currentPos.x);
const minY = Math.min(startPos.y, currentPos.y);
const maxY = Math.max(startPos.y, currentPos.y);
// Find nodes within the selection box
const nodesInBox: string[] = [];
const nodeWidth = 200; // Approximate node width
const nodeHeight = 100; // Approximate node height
for (const node of graph.nodes) {
const nodeLeft = node.position.x;
const nodeTop = node.position.y;
const nodeRight = nodeLeft + nodeWidth;
const nodeBottom = nodeTop + nodeHeight;
// Check if node intersects with selection box
const intersects = !(nodeRight < minX || nodeLeft > maxX || nodeBottom < minY || nodeTop > maxY);
if (intersects) {
nodesInBox.push(node.id);
}
}
// Update selection
if (additive) {
// Add to existing selection
const newSelection = new Set(selectedNodeIds);
nodesInBox.forEach(id => newSelection.add(id));
onSelectionChange?.(newSelection, new Set());
} else {
// Replace selection
onSelectionChange?.(new Set(nodesInBox), new Set());
}
// Mark that box selection just ended to prevent click from clearing selection
// 标记框选刚刚结束,以防止 click 清除选择
boxSelectJustEndedRef.current = true;
setBoxSelectState(null);
}, [boxSelectState, graph.nodes, selectedNodeIds, onSelectionChange]);
/**
* Handles pin value change
*
@@ -497,6 +640,9 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onContextMenu={handleCanvasContextMenu}
onPanChange={handlePanChange}
onZoomChange={handleZoomChange}
onMouseDown={handleBoxSelectStart}
onCanvasMouseMove={handleBoxSelectMove}
onCanvasMouseUp={handleBoxSelectEnd}
>
{/* Connection layer (连接层) */}
<ConnectionLayer
@@ -531,6 +677,19 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onToggleCollapse={handleToggleCollapse}
/>
))}
{/* Box selection overlay (框选覆盖层) */}
{boxSelectState && (
<div
className="ne-selection-box"
style={{
left: Math.min(boxSelectState.startPos.x, boxSelectState.currentPos.x),
top: Math.min(boxSelectState.startPos.y, boxSelectState.currentPos.y),
width: Math.abs(boxSelectState.currentPos.x - boxSelectState.startPos.x),
height: Math.abs(boxSelectState.currentPos.y - boxSelectState.startPos.y)
}}
/>
)}
</GraphCanvas>
</div>
);

View File

@@ -64,6 +64,8 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
return draggingFromPin.canConnectTo(pin);
}, [draggingFromPin]);
const hasError = Boolean(node.data.invalidVariable);
const classNames = useMemo(() => {
const classes = ['ne-node'];
if (isSelected) classes.push('selected');
@@ -71,8 +73,9 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
if (node.isCollapsed) classes.push('collapsed');
if (node.category === 'comment') classes.push('comment');
if (executionState !== 'idle') classes.push(executionState);
if (hasError) classes.push('has-error');
return classes.join(' ');
}, [isSelected, isDragging, node.isCollapsed, node.category, executionState]);
}, [isSelected, isDragging, node.isCollapsed, node.category, executionState, hasError]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
@@ -102,8 +105,10 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
: undefined;
// Separate exec pins from data pins
// Also filter out pins listed in data.hiddenInputs
const hiddenInputs = (node.data.hiddenInputs as string[]) || [];
const inputExecPins = node.inputPins.filter(p => p.isExec && !p.hidden);
const inputDataPins = node.inputPins.filter(p => !p.isExec && !p.hidden);
const inputDataPins = node.inputPins.filter(p => !p.isExec && !p.hidden && !hiddenInputs.includes(p.name));
const outputExecPins = node.outputPins.filter(p => p.isExec && !p.hidden);
const outputDataPins = node.outputPins.filter(p => !p.isExec && !p.hidden);
@@ -129,13 +134,17 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
className={`ne-node-header ${node.category}`}
style={headerStyle}
>
{/* Diamond icon for event nodes, or custom icon */}
{/* Warning icon for invalid nodes, or diamond/custom icon */}
<span className="ne-node-header-icon">
{node.icon && renderIcon ? renderIcon(node.icon) : null}
{hasError ? (
<span className="ne-node-warning-icon" title={`Variable '${node.data.variableName}' not found`}></span>
) : (
node.icon && renderIcon ? renderIcon(node.icon) : null
)}
</span>
<span className="ne-node-header-title">
{node.title}
{(node.data.displayTitle as string) || node.title}
{node.subtitle && (
<span className="ne-node-header-subtitle">
{node.subtitle}

View File

@@ -10,6 +10,9 @@
// Import styles (导入样式)
import './styles/index.css';
// CSS utilities for Shadow DOM (Shadow DOM 的 CSS 工具)
export { nodeEditorCssText, injectNodeEditorStyles } from './styles/cssText';
// Domain models (领域模型)
export {
// Models

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