Compare commits
21 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdbbf8a80a | ||
|
|
b28169b186 | ||
|
|
d21caa974e | ||
|
|
a08a84b7db | ||
|
|
449bd420a6 | ||
|
|
1f297ac769 | ||
|
|
4cf868a769 | ||
|
|
afdeb00b4d | ||
|
|
764ce67742 | ||
|
|
61a13baca2 | ||
|
|
1cfa64aa0f | ||
|
|
3b978384c7 | ||
|
|
10c3891abd | ||
|
|
18af48a0fc | ||
|
|
d4cef828e1 | ||
|
|
2d46ccf896 | ||
|
|
fb8bde6485 | ||
|
|
30437dc5d5 | ||
|
|
9f84c2f870 | ||
|
|
e9ea52d9b3 | ||
|
|
0662b07445 |
22
README.md
22
README.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
||||
| **Timer** | Timer and cooldown systems | No |
|
||||
| **Spatial** | Spatial indexing and queries (QuadTree, Grid) | No |
|
||||
| **Pathfinding** | A* and navigation mesh pathfinding | No |
|
||||
| **Network** | Client/server networking with TSRPC | No |
|
||||
| **Procgen** | Procedural generation (noise, random, sampling) | No |
|
||||
| **RPC** | High-performance RPC communication framework | No |
|
||||
| **Server** | Game server framework with rooms, auth, rate limiting | No |
|
||||
| **Network** | Client networking with prediction, AOI, delta compression | No |
|
||||
| **Transaction** | Game transaction system with Redis/Memory storage | No |
|
||||
| **World Streaming** | Open world chunk loading and streaming | No |
|
||||
|
||||
> All framework modules can be used standalone with any rendering engine.
|
||||
|
||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # State machines
|
||||
npm install @esengine/timer # Timers & cooldowns
|
||||
npm install @esengine/spatial # Spatial indexing
|
||||
npm install @esengine/pathfinding # Pathfinding
|
||||
npm install @esengine/network # Networking
|
||||
npm install @esengine/procgen # Procedural generation
|
||||
npm install @esengine/rpc # RPC framework
|
||||
npm install @esengine/server # Game server
|
||||
npm install @esengine/network # Client networking
|
||||
npm install @esengine/transaction # Transaction system
|
||||
npm install @esengine/world-streaming # World streaming
|
||||
```
|
||||
|
||||
### ESEngine Runtime (Optional)
|
||||
@@ -218,6 +228,7 @@ If you want a complete engine solution with rendering:
|
||||
A visual editor built with Tauri for scene management:
|
||||
|
||||
- Download from [Releases](https://github.com/esengine/esengine/releases)
|
||||
- [Build from source](./packages/editor/editor-app/README.md)
|
||||
- Supports behavior tree editing, tilemap painting, visual scripting
|
||||
|
||||
## Project Structure
|
||||
@@ -235,7 +246,11 @@ esengine/
|
||||
│ │ ├── spatial/ # Spatial queries
|
||||
│ │ ├── pathfinding/ # Pathfinding
|
||||
│ │ ├── procgen/ # Procedural generation
|
||||
│ │ └── network/ # Networking
|
||||
│ │ ├── rpc/ # RPC framework
|
||||
│ │ ├── server/ # Game server
|
||||
│ │ ├── network/ # Client networking
|
||||
│ │ ├── transaction/ # Transaction system
|
||||
│ │ └── world-streaming/ # World streaming
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine runtime
|
||||
│ ├── rendering/ # Rendering modules
|
||||
@@ -267,6 +282,7 @@ pnpm test
|
||||
|
||||
- [ECS Framework Guide](./packages/framework/core/README.md)
|
||||
- [Behavior Tree Guide](./packages/framework/behavior-tree/README.md)
|
||||
- [Editor Setup Guide](./packages/editor/editor-app/README.md) ([中文](./packages/editor/editor-app/README_CN.md))
|
||||
- [API Reference](https://esengine.cn/api/README)
|
||||
|
||||
## Community
|
||||
|
||||
22
README_CN.md
22
README_CN.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
||||
| **定时器** | 定时器和冷却系统 | 否 |
|
||||
| **空间索引** | 空间查询(四叉树、网格) | 否 |
|
||||
| **寻路** | A* 和导航网格寻路 | 否 |
|
||||
| **网络** | 客户端/服务端网络通信 (TSRPC) | 否 |
|
||||
| **程序化生成** | 噪声、随机、采样等生成算法 | 否 |
|
||||
| **RPC** | 高性能 RPC 通信框架 | 否 |
|
||||
| **服务端** | 游戏服务器框架,支持房间、认证、速率限制 | 否 |
|
||||
| **网络** | 客户端网络,支持预测、AOI、增量压缩 | 否 |
|
||||
| **事务系统** | 游戏事务系统,支持 Redis/内存存储 | 否 |
|
||||
| **世界流送** | 开放世界分块加载和流送 | 否 |
|
||||
|
||||
> 所有框架模块都可以独立使用,无需依赖特定渲染引擎。
|
||||
|
||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # 状态机
|
||||
npm install @esengine/timer # 定时器和冷却
|
||||
npm install @esengine/spatial # 空间索引
|
||||
npm install @esengine/pathfinding # 寻路
|
||||
npm install @esengine/network # 网络
|
||||
npm install @esengine/procgen # 程序化生成
|
||||
npm install @esengine/rpc # RPC 框架
|
||||
npm install @esengine/server # 游戏服务器
|
||||
npm install @esengine/network # 客户端网络
|
||||
npm install @esengine/transaction # 事务系统
|
||||
npm install @esengine/world-streaming # 世界流送
|
||||
```
|
||||
|
||||
### ESEngine 运行时(可选)
|
||||
@@ -218,6 +228,7 @@ npm install @esengine/network # 网络
|
||||
基于 Tauri 构建的可视化编辑器:
|
||||
|
||||
- 从 [Releases](https://github.com/esengine/esengine/releases) 下载
|
||||
- [从源码构建](./packages/editor/editor-app/README.md)
|
||||
- 支持行为树编辑、Tilemap 绘制、可视化脚本
|
||||
|
||||
## 项目结构
|
||||
@@ -235,7 +246,11 @@ esengine/
|
||||
│ │ ├── spatial/ # 空间查询
|
||||
│ │ ├── pathfinding/ # 寻路
|
||||
│ │ ├── procgen/ # 程序化生成
|
||||
│ │ └── network/ # 网络
|
||||
│ │ ├── rpc/ # RPC 框架
|
||||
│ │ ├── server/ # 游戏服务器
|
||||
│ │ ├── network/ # 客户端网络
|
||||
│ │ ├── transaction/ # 事务系统
|
||||
│ │ └── world-streaming/ # 世界流送
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine 运行时
|
||||
│ ├── rendering/ # 渲染模块
|
||||
@@ -267,6 +282,7 @@ pnpm test
|
||||
|
||||
- [ECS 框架指南](./packages/framework/core/README.md)
|
||||
- [行为树指南](./packages/framework/behavior-tree/README.md)
|
||||
- [编辑器启动指南](./packages/editor/editor-app/README_CN.md) ([English](./packages/editor/editor-app/README.md))
|
||||
- [API 参考](https://esengine.cn/api/README)
|
||||
|
||||
## 社区
|
||||
|
||||
@@ -255,6 +255,9 @@ export default defineConfig({
|
||||
translations: { en: 'RPC' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/rpc', translations: { en: 'Overview' } },
|
||||
{ label: '服务端', slug: 'modules/rpc/server', translations: { en: 'Server' } },
|
||||
{ label: '客户端', slug: 'modules/rpc/client', translations: { en: 'Client' } },
|
||||
{ label: '编解码', slug: 'modules/rpc/codec', translations: { en: 'Codec' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -264,10 +267,37 @@ export default defineConfig({
|
||||
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
|
||||
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
|
||||
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
|
||||
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
|
||||
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
|
||||
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
|
||||
{ label: '客户端预测', slug: 'modules/network/prediction', translations: { en: 'Prediction' } },
|
||||
{ label: 'AOI 兴趣区域', slug: 'modules/network/aoi', translations: { en: 'AOI' } },
|
||||
{ label: '增量压缩', slug: 'modules/network/delta', translations: { en: 'Delta Compression' } },
|
||||
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '事务系统',
|
||||
translations: { en: 'Transaction' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/transaction', translations: { en: 'Overview' } },
|
||||
{ label: '核心概念', slug: 'modules/transaction/core', translations: { en: 'Core Concepts' } },
|
||||
{ label: '存储层', slug: 'modules/transaction/storage', translations: { en: 'Storage Layer' } },
|
||||
{ label: '操作', slug: 'modules/transaction/operations', translations: { en: 'Operations' } },
|
||||
{ label: '分布式事务', slug: 'modules/transaction/distributed', translations: { en: 'Distributed' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '世界流式加载',
|
||||
translations: { en: 'World Streaming' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/world-streaming', translations: { en: 'Overview' } },
|
||||
{ label: '区块管理', slug: 'modules/world-streaming/chunk-manager', translations: { en: 'Chunk Manager' } },
|
||||
{ label: '流式系统', slug: 'modules/world-streaming/streaming-system', translations: { en: 'Streaming System' } },
|
||||
{ label: '序列化', slug: 'modules/world-streaming/serialization', translations: { en: 'Serialization' } },
|
||||
{ label: '实际示例', slug: 'modules/world-streaming/examples', translations: { en: 'Examples' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -292,6 +322,8 @@ export default defineConfig({
|
||||
{ label: '@esengine/fsm', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/fsm/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/timer', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/timer/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/network', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/network/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/transaction', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/transaction/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/rpc', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/rpc/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
{ label: '@esengine/cli', link: 'https://github.com/esengine/esengine/blob/master/packages/tools/cli/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ ESEngine provides a rich set of modules that can be imported as needed.
|
||||
| [Timer](/en/modules/timer/) | `@esengine/timer` | Timer and cooldown system |
|
||||
| [Spatial](/en/modules/spatial/) | `@esengine/spatial` | Spatial queries, AOI management |
|
||||
| [Pathfinding](/en/modules/pathfinding/) | `@esengine/pathfinding` | A* pathfinding, NavMesh navigation |
|
||||
| [World Streaming](/en/modules/world-streaming/) | `@esengine/world-streaming` | Chunk-based world streaming for open worlds |
|
||||
|
||||
### Tools
|
||||
|
||||
@@ -33,6 +34,7 @@ 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 |
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: "Area of Interest (AOI)"
|
||||
description: "View range based network entity filtering"
|
||||
---
|
||||
|
||||
AOI (Area of Interest) is a key technique in large-scale multiplayer games for optimizing network bandwidth. By only synchronizing entities within a player's view range, network traffic can be significantly reduced.
|
||||
|
||||
## NetworkAOISystem
|
||||
|
||||
`NetworkAOISystem` provides grid-based area of interest management.
|
||||
|
||||
### Enable AOI
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enableAOI: true,
|
||||
aoiConfig: {
|
||||
cellSize: 100, // Grid cell size
|
||||
defaultViewRange: 500, // Default view range
|
||||
enabled: true,
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### Adding Observers
|
||||
|
||||
Each player that needs to receive sync data must be added as an observer:
|
||||
|
||||
```typescript
|
||||
// Add observer when player joins
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
// ... setup components
|
||||
|
||||
// Add player as AOI observer
|
||||
networkPlugin.addAOIObserver(
|
||||
spawn.netId, // Network ID
|
||||
spawn.pos.x, // Initial X position
|
||||
spawn.pos.y, // Initial Y position
|
||||
600 // View range (optional)
|
||||
);
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// Remove observer when player leaves
|
||||
networkPlugin.removeAOIObserver(playerNetId);
|
||||
```
|
||||
|
||||
### Updating Observer Position
|
||||
|
||||
When a player moves, update their AOI position:
|
||||
|
||||
```typescript
|
||||
// Update in game loop or sync callback
|
||||
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||
```
|
||||
|
||||
## AOI Configuration
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `cellSize` | `number` | 100 | Grid cell size |
|
||||
| `defaultViewRange` | `number` | 500 | Default view range |
|
||||
| `enabled` | `boolean` | true | Whether AOI is enabled |
|
||||
|
||||
### Grid Size Recommendations
|
||||
|
||||
Grid size should be set based on game view range:
|
||||
|
||||
```typescript
|
||||
// Recommendation: cellSize = defaultViewRange / 3 to / 5
|
||||
aoiConfig: {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500, // Grid is about 1/5 of view range
|
||||
}
|
||||
```
|
||||
|
||||
## Query Interface
|
||||
|
||||
### Get Visible Entities
|
||||
|
||||
```typescript
|
||||
// Get all entities visible to player
|
||||
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||
console.log('Visible entities:', visibleEntities);
|
||||
```
|
||||
|
||||
### Check Visibility
|
||||
|
||||
```typescript
|
||||
// Check if player can see an entity
|
||||
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||
// Target is in view
|
||||
}
|
||||
```
|
||||
|
||||
## Event Listening
|
||||
|
||||
The AOI system triggers events when entities enter/exit view:
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||
// Can send entity's initial state here
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||
// Can cleanup resources here
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Filtering
|
||||
|
||||
AOI is most commonly used server-side to filter sync data for each client:
|
||||
|
||||
```typescript
|
||||
// Server-side example
|
||||
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private aoiSystem = createNetworkAOISystem({
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
});
|
||||
|
||||
// Player joins
|
||||
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.addObserver(playerId, x, y);
|
||||
}
|
||||
|
||||
// Player moves
|
||||
onPlayerMove(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||
}
|
||||
|
||||
// Send sync data
|
||||
broadcastSync(allEntities: EntitySyncState[]) {
|
||||
for (const playerId of this.players) {
|
||||
// Filter using AOI
|
||||
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||
playerId,
|
||||
allEntities
|
||||
);
|
||||
|
||||
// Send only visible entities
|
||||
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Game World │
|
||||
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||
│ │ │ │ E │ │ │ │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ E = Enemy entity │
|
||||
│ │ │ P │ ● │ │ │ P = Player │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ ● = Player view center │
|
||||
│ │ │ │ E │ E │ │ ○ = View range │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||
│ │ │ │ │ │ E │ Player only sees E in view│
|
||||
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||
│ │
|
||||
│ View range (circle): Contains 3 enemies │
|
||||
│ Grid optimization: Only check cells covered by view │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Grid Optimization
|
||||
|
||||
AOI uses spatial grid to accelerate queries:
|
||||
|
||||
1. **Add Entity**: Calculate grid cell based on position
|
||||
2. **View Detection**: Only check cells covered by view range
|
||||
3. **Move Update**: Update cell assignment when crossing cells
|
||||
4. **Event Trigger**: Detect enter/exit view
|
||||
|
||||
## Dynamic View Range
|
||||
|
||||
Different player types can have different view ranges:
|
||||
|
||||
```typescript
|
||||
// Regular player
|
||||
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||
|
||||
// VIP player (larger view)
|
||||
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||
|
||||
// Adjust view range at runtime
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
if (aoiSystem) {
|
||||
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Server-Side Usage
|
||||
|
||||
AOI filtering should be done server-side; clients should not trust their own AOI judgment:
|
||||
|
||||
```typescript
|
||||
// Filter on server before sending
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
sendToClient(playerId, filtered);
|
||||
```
|
||||
|
||||
### 2. Edge Handling
|
||||
|
||||
Add buffer zone at view edge to prevent flickering:
|
||||
|
||||
```typescript
|
||||
// Add immediately when entering view
|
||||
// Remove with delay when exiting (keep for 1-2 extra seconds)
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'exit') {
|
||||
setTimeout(() => {
|
||||
// Re-check if really exited
|
||||
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||
removeFromClient(event.observerNetId, event.targetNetId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Large Entities
|
||||
|
||||
Large entities (like bosses) may need special handling:
|
||||
|
||||
```typescript
|
||||
// Boss is always visible to everyone
|
||||
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
|
||||
// Add boss entity
|
||||
const bossState = entities.find(e => e.netId === bossNetId);
|
||||
if (bossState && !filtered.includes(bossState)) {
|
||||
filtered.push(bossState);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Performance Considerations
|
||||
|
||||
```typescript
|
||||
// Large-scale game recommended config
|
||||
aoiConfig: {
|
||||
cellSize: 200, // Larger grid reduces cell count
|
||||
defaultViewRange: 800, // Set based on actual view
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
console.log('AOI enabled:', aoiSystem.enabled);
|
||||
console.log('Observer count:', aoiSystem.observerCount);
|
||||
|
||||
// Get visible entities for specific player
|
||||
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||
console.log('Visible entities:', visible.length);
|
||||
}
|
||||
```
|
||||
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
||||
---
|
||||
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)
|
||||
```
|
||||
|
||||
### Session Provider
|
||||
|
||||
Use server-side sessions for stateful authentication:
|
||||
|
||||
```typescript
|
||||
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||
|
||||
// Custom storage implementation
|
||||
const storage: ISessionStorage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return await redis.get(key)
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await redis.set(key, value)
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return await redis.del(key) > 0
|
||||
},
|
||||
}
|
||||
|
||||
const sessionProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 86400000, // 24 hours in ms
|
||||
|
||||
// Optional: validate user on each request
|
||||
validateUser: (user) => !user.banned,
|
||||
})
|
||||
|
||||
// Create session (for login endpoints)
|
||||
const sessionId = await sessionProvider.createSession(user, {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
})
|
||||
|
||||
// Revoke session (for logout)
|
||||
await sessionProvider.revoke(sessionId)
|
||||
```
|
||||
|
||||
## Server Auth Mixin
|
||||
|
||||
The `withAuth` function wraps your server to add authentication:
|
||||
|
||||
```typescript
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
const server = withAuth(baseServer, {
|
||||
// Required: auth provider
|
||||
provider: jwtProvider,
|
||||
|
||||
// Required: extract credentials from request
|
||||
extractCredentials: (req) => {
|
||||
// From query string
|
||||
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||
|
||||
// Or from headers
|
||||
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||
},
|
||||
|
||||
// Optional: handle auth failure
|
||||
onAuthFailed: (conn, error) => {
|
||||
console.log(`Auth failed: ${error}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Accessing Auth Context
|
||||
|
||||
After authentication, the auth context is available on connections:
|
||||
|
||||
```typescript
|
||||
import { getAuthContext } from '@esengine/server/auth'
|
||||
|
||||
server.onConnect = (conn) => {
|
||||
const auth = getAuthContext(conn)
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
console.log(`User ${auth.userId} connected`)
|
||||
console.log(`Roles: ${auth.roles}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Room Auth Mixin
|
||||
|
||||
The `withRoomAuth` function adds authentication checks to rooms:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
// Require authentication to join
|
||||
requireAuth: true,
|
||||
|
||||
// Optional: require specific roles
|
||||
allowedRoles: ['player', 'premium'],
|
||||
|
||||
// Optional: role check mode ('any' or 'all')
|
||||
roleCheckMode: 'any',
|
||||
}) {
|
||||
// player has .auth and .user properties
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} joined`)
|
||||
console.log(`Is premium: ${player.auth.hasRole('premium')}`)
|
||||
}
|
||||
|
||||
// Optional: custom auth validation
|
||||
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||
// Additional validation logic
|
||||
if (player.auth.hasRole('banned')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name ?? 'Guest',
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPlayer Interface
|
||||
|
||||
Players in auth rooms have additional properties:
|
||||
|
||||
```typescript
|
||||
interface AuthPlayer<TUser> extends Player {
|
||||
// Full auth context
|
||||
readonly auth: IAuthContext<TUser>
|
||||
|
||||
// User info (shortcut for auth.user)
|
||||
readonly user: TUser | null
|
||||
}
|
||||
```
|
||||
|
||||
### Room Auth Helpers
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRoomAuth<User>(Room) {
|
||||
someMethod() {
|
||||
// Get player by user ID
|
||||
const player = this.getPlayerByUserId('user-123')
|
||||
|
||||
// Get all players with a role
|
||||
const admins = this.getPlayersByRole('admin')
|
||||
|
||||
// Get player with auth info
|
||||
const authPlayer = this.getAuthPlayer(playerId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Decorators
|
||||
|
||||
### @requireAuth
|
||||
|
||||
Mark message handlers as requiring authentication:
|
||||
|
||||
```typescript
|
||||
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||
|
||||
class GameRoom extends withRoomAuth(Room) {
|
||||
@requireAuth()
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||
// Only authenticated players can trade
|
||||
}
|
||||
|
||||
@requireAuth({ allowGuest: true })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: ChatData, player: AuthPlayer) {
|
||||
// Guests can also chat
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @requireRole
|
||||
|
||||
Require specific roles for message handlers:
|
||||
|
||||
```typescript
|
||||
class AdminRoom extends withRoomAuth(Room) {
|
||||
@requireRole('admin')
|
||||
@onMessage('Ban')
|
||||
handleBan(data: BanData, player: AuthPlayer) {
|
||||
// Only admins can ban
|
||||
}
|
||||
|
||||
@requireRole(['moderator', 'admin'])
|
||||
@onMessage('Mute')
|
||||
handleMute(data: MuteData, player: AuthPlayer) {
|
||||
// Moderators OR admins can mute
|
||||
}
|
||||
|
||||
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
@onMessage('SpecialFeature')
|
||||
handleSpecial(data: any, player: AuthPlayer) {
|
||||
// Requires BOTH verified AND premium roles
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Context API
|
||||
|
||||
The auth context provides various methods for checking authentication state:
|
||||
|
||||
```typescript
|
||||
interface IAuthContext<TUser> {
|
||||
// Authentication state
|
||||
readonly isAuthenticated: boolean
|
||||
readonly user: TUser | null
|
||||
readonly userId: string | null
|
||||
readonly roles: ReadonlyArray<string>
|
||||
readonly authenticatedAt: number | null
|
||||
readonly expiresAt: number | null
|
||||
|
||||
// Role checking
|
||||
hasRole(role: string): boolean
|
||||
hasAnyRole(roles: string[]): boolean
|
||||
hasAllRoles(roles: string[]): boolean
|
||||
}
|
||||
```
|
||||
|
||||
The `AuthContext` class (implementation) also provides:
|
||||
|
||||
```typescript
|
||||
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||
// Set authentication from result
|
||||
setAuthenticated(result: AuthResult<TUser>): void
|
||||
|
||||
// Clear authentication state
|
||||
clear(): void
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use the mock auth provider for unit tests:
|
||||
|
||||
```typescript
|
||||
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||
|
||||
// Create mock provider with preset users
|
||||
const mockProvider = createMockAuthProvider({
|
||||
users: [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
],
|
||||
autoCreate: true, // Create users for unknown tokens
|
||||
})
|
||||
|
||||
// Use in tests
|
||||
const server = withAuth(testServer, {
|
||||
provider: mockProvider,
|
||||
extractCredentials: (req) => req.headers['x-token'],
|
||||
})
|
||||
|
||||
// Verify with user ID as token
|
||||
const result = await mockProvider.verify('1')
|
||||
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
|
||||
// Add/remove users dynamically
|
||||
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||
mockProvider.removeUser('3')
|
||||
|
||||
// Revoke tokens
|
||||
await mockProvider.revoke('1')
|
||||
|
||||
// Reset to initial state
|
||||
mockProvider.clear()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Auth errors include error codes for programmatic handling:
|
||||
|
||||
```typescript
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS' // Invalid username/password
|
||||
| 'INVALID_TOKEN' // Token is malformed or invalid
|
||||
| 'EXPIRED_TOKEN' // Token has expired
|
||||
| 'USER_NOT_FOUND' // User lookup failed
|
||||
| 'ACCOUNT_DISABLED' // User account is disabled
|
||||
| 'RATE_LIMITED' // Too many requests
|
||||
| 'INSUFFICIENT_PERMISSIONS' // Insufficient permissions
|
||||
|
||||
// In your auth failure handler
|
||||
const server = withAuth(baseServer, {
|
||||
provider: jwtProvider,
|
||||
extractCredentials,
|
||||
onAuthFailed: (conn, error) => {
|
||||
switch (error.errorCode) {
|
||||
case 'EXPIRED_TOKEN':
|
||||
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||
break
|
||||
case 'INVALID_TOKEN':
|
||||
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||
break
|
||||
default:
|
||||
conn.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example with JWT authentication:
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
import {
|
||||
withAuth,
|
||||
withRoomAuth,
|
||||
createJwtAuthProvider,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
type AuthPlayer,
|
||||
} from '@esengine/server/auth'
|
||||
|
||||
// Types
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// JWT Provider
|
||||
const jwtProvider = createJwtAuthProvider<User>({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) ?? [],
|
||||
}),
|
||||
})
|
||||
|
||||
// Create authenticated server
|
||||
const server = withAuth(
|
||||
await createServer({ port: 3000 }),
|
||||
{
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
return new URL(req.url ?? '', 'http://localhost')
|
||||
.searchParams.get('token')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Game Room with auth
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['player'],
|
||||
}) {
|
||||
onCreate() {
|
||||
console.log('Game room created')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} joined!`)
|
||||
this.broadcast('PlayerJoined', {
|
||||
id: player.id,
|
||||
name: player.user?.name,
|
||||
})
|
||||
}
|
||||
|
||||
@requireAuth()
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
// Handle movement
|
||||
}
|
||||
|
||||
@requireRole('admin')
|
||||
@onMessage('Kick')
|
||||
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
const target = this.getPlayer(data.playerId)
|
||||
if (target) {
|
||||
this.kick(target, 'Kicked by admin')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Secure your secrets**: Never hardcode JWT secrets. Use environment variables.
|
||||
|
||||
2. **Set reasonable expiration**: Balance security and user experience when setting token TTL.
|
||||
|
||||
3. **Validate on critical actions**: Use `@requireAuth` on sensitive message handlers.
|
||||
|
||||
4. **Use role-based access**: Implement proper role hierarchy for admin functions.
|
||||
|
||||
5. **Handle token refresh**: Implement token refresh logic for long sessions.
|
||||
|
||||
6. **Log auth events**: Track login attempts and failures for security monitoring.
|
||||
|
||||
7. **Test auth flows**: Use `MockAuthProvider` to test authentication scenarios.
|
||||
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: "State Delta Compression"
|
||||
description: "Reduce network bandwidth with incremental sync"
|
||||
---
|
||||
|
||||
State delta compression reduces network bandwidth by only sending fields that have changed. For frequently synchronized game state, this can significantly reduce data transmission.
|
||||
|
||||
## StateDeltaCompressor
|
||||
|
||||
The `StateDeltaCompressor` class is used to compress and decompress state deltas.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||
|
||||
// Create compressor
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.01, // Position change threshold
|
||||
rotationThreshold: 0.001, // Rotation change threshold (radians)
|
||||
velocityThreshold: 0.1, // Velocity change threshold
|
||||
fullSnapshotInterval: 60, // Full snapshot interval (frames)
|
||||
});
|
||||
|
||||
// Compress sync data
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||
],
|
||||
};
|
||||
|
||||
const deltaData = compressor.compress(syncData);
|
||||
// deltaData only contains changed fields
|
||||
|
||||
// Decompress delta data
|
||||
const fullData = compressor.decompress(deltaData);
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `positionThreshold` | `number` | 0.01 | Position change threshold |
|
||||
| `rotationThreshold` | `number` | 0.001 | Rotation change threshold (radians) |
|
||||
| `velocityThreshold` | `number` | 0.1 | Velocity change threshold |
|
||||
| `fullSnapshotInterval` | `number` | 60 | Full snapshot interval (frames) |
|
||||
|
||||
## Delta Flags
|
||||
|
||||
Bit flags indicate which fields have changed:
|
||||
|
||||
```typescript
|
||||
import { DeltaFlags } from '@esengine/network';
|
||||
|
||||
// Flag definitions
|
||||
DeltaFlags.NONE // 0 - No change
|
||||
DeltaFlags.POSITION // 1 - Position changed
|
||||
DeltaFlags.ROTATION // 2 - Rotation changed
|
||||
DeltaFlags.VELOCITY // 4 - Velocity changed
|
||||
DeltaFlags.ANGULAR_VELOCITY // 8 - Angular velocity changed
|
||||
DeltaFlags.CUSTOM // 16 - Custom data changed
|
||||
```
|
||||
|
||||
## Data Format
|
||||
|
||||
### Full State
|
||||
|
||||
```typescript
|
||||
interface EntitySyncState {
|
||||
netId: number;
|
||||
pos?: { x: number; y: number };
|
||||
rot?: number;
|
||||
vel?: { x: number; y: number };
|
||||
angVel?: number;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### Delta State
|
||||
|
||||
```typescript
|
||||
interface EntityDeltaState {
|
||||
netId: number;
|
||||
flags: number; // Change flags
|
||||
pos?: { x: number; y: number }; // Only present when POSITION flag set
|
||||
rot?: number; // Only present when ROTATION flag set
|
||||
vel?: { x: number; y: number }; // Only present when VELOCITY flag set
|
||||
angVel?: number; // Only present when ANGULAR_VELOCITY flag set
|
||||
custom?: Record<string, unknown>; // Only present when CUSTOM flag set
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Frame 1 (full snapshot):
|
||||
Entity 1: pos=(100, 200), rot=0
|
||||
|
||||
Frame 2 (delta):
|
||||
Entity 1: flags=POSITION, pos=(101, 200) // Only X changed
|
||||
|
||||
Frame 3 (delta):
|
||||
Entity 1: flags=0 // No change, not sent
|
||||
|
||||
Frame 4 (delta):
|
||||
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||
|
||||
Frame 60 (forced full snapshot):
|
||||
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||
```
|
||||
|
||||
## Server-Side Usage
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// Broadcast state updates
|
||||
broadcastState(entities: EntitySyncState[]) {
|
||||
const syncData: SyncData = {
|
||||
frame: this.currentFrame,
|
||||
timestamp: Date.now(),
|
||||
entities,
|
||||
};
|
||||
|
||||
// Compress data
|
||||
const deltaData = this.compressor.compress(syncData);
|
||||
|
||||
// Send delta data
|
||||
this.broadcast('sync', deltaData);
|
||||
}
|
||||
|
||||
// Cleanup when player leaves
|
||||
onPlayerLeave(netId: number) {
|
||||
this.compressor.removeEntity(netId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Usage
|
||||
|
||||
```typescript
|
||||
class GameClient {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// Receive delta data
|
||||
onSyncReceived(deltaData: DeltaSyncData) {
|
||||
// Decompress to full state
|
||||
const fullData = this.compressor.decompress(deltaData);
|
||||
|
||||
// Apply state
|
||||
for (const entity of fullData.entities) {
|
||||
this.applyEntityState(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Bandwidth Savings Example
|
||||
|
||||
Assume each entity has the following data:
|
||||
|
||||
| Field | Size (bytes) |
|
||||
|-------|-------------|
|
||||
| netId | 4 |
|
||||
| pos.x | 8 |
|
||||
| pos.y | 8 |
|
||||
| rot | 8 |
|
||||
| vel.x | 8 |
|
||||
| vel.y | 8 |
|
||||
| angVel | 8 |
|
||||
| **Total** | **52** |
|
||||
|
||||
With delta compression:
|
||||
|
||||
| Scenario | Original | Compressed | Savings |
|
||||
|----------|----------|------------|---------|
|
||||
| Only position changed | 52 | 4+1+16 = 21 | 60% |
|
||||
| Only rotation changed | 52 | 4+1+8 = 13 | 75% |
|
||||
| Stationary | 52 | 0 | 100% |
|
||||
| Position + rotation changed | 52 | 4+1+24 = 29 | 44% |
|
||||
|
||||
## Forcing Full Snapshot
|
||||
|
||||
Some situations require sending full snapshots:
|
||||
|
||||
```typescript
|
||||
// When new player joins
|
||||
compressor.forceFullSnapshot();
|
||||
const data = compressor.compress(syncData);
|
||||
// This will send full state
|
||||
|
||||
// On reconnection
|
||||
compressor.clear(); // Clear history
|
||||
compressor.forceFullSnapshot();
|
||||
```
|
||||
|
||||
## Custom Data
|
||||
|
||||
Support for syncing custom game data:
|
||||
|
||||
```typescript
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{
|
||||
netId: 1,
|
||||
pos: { x: 100, y: 200 },
|
||||
custom: {
|
||||
health: 80,
|
||||
mana: 50,
|
||||
buffs: ['speed', 'shield'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Custom data is also delta compressed
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Set Appropriate Thresholds
|
||||
|
||||
```typescript
|
||||
// High precision games (e.g., competitive)
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.001,
|
||||
rotationThreshold: 0.0001,
|
||||
});
|
||||
|
||||
// Casual games
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.1,
|
||||
rotationThreshold: 0.01,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Adjust Full Snapshot Interval
|
||||
|
||||
```typescript
|
||||
// High reliability (unstable network)
|
||||
fullSnapshotInterval: 30, // Full snapshot every 30 frames
|
||||
|
||||
// Low bandwidth priority
|
||||
fullSnapshotInterval: 120, // Full snapshot every 120 frames
|
||||
```
|
||||
|
||||
### 3. Combine with AOI
|
||||
|
||||
```typescript
|
||||
// Filter with AOI first, then delta compress
|
||||
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
### 4. Handle Entity Removal
|
||||
|
||||
```typescript
|
||||
// Clean up compressor state when entity despawns
|
||||
function onEntityDespawn(netId: number) {
|
||||
compressor.removeEntity(netId);
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Other Features
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Game State │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ AOI Filter │ ← Only process entities in view
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Delta Compress │ ← Only send changed fields
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Network Send │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
const compressor = createStateDeltaCompressor();
|
||||
|
||||
// Check compression efficiency
|
||||
const original = syncData;
|
||||
const compressed = compressor.compress(original);
|
||||
|
||||
console.log('Original entities:', original.entities.length);
|
||||
console.log('Compressed entities:', compressed.entities.length);
|
||||
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||
|
||||
// View each entity's changes
|
||||
for (const delta of compressed.entities) {
|
||||
console.log(`Entity ${delta.netId}:`, {
|
||||
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -147,7 +147,10 @@ service.on('chat', (data) => {
|
||||
|
||||
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
|
||||
- [Server Side](/en/modules/network/server/) - GameServer and Room management
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation, prediction and snapshots
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
|
||||
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
|
||||
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization
|
||||
- [Delta Compression](/en/modules/network/delta/) - State delta synchronization
|
||||
- [API Reference](/en/modules/network/api/) - Complete API documentation
|
||||
|
||||
## Service Tokens
|
||||
@@ -159,10 +162,14 @@ import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from '@esengine/network';
|
||||
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
254
docs/src/content/docs/en/modules/network/prediction.md
Normal file
254
docs/src/content/docs/en/modules/network/prediction.md
Normal 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);
|
||||
}
|
||||
```
|
||||
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: "Rate Limiting"
|
||||
description: "Protect your game server from abuse with configurable rate limiting"
|
||||
---
|
||||
|
||||
The `@esengine/server` package includes a pluggable rate limiting system to protect against DDoS attacks, message flooding, and other abuse.
|
||||
|
||||
## Installation
|
||||
|
||||
Rate limiting is included in the server package:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage } from '@esengine/server'
|
||||
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20,
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
}) {
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: Player) {
|
||||
// Protected by rate limit (10 msg/s default)
|
||||
}
|
||||
|
||||
@rateLimit({ messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) {
|
||||
// Stricter limit for trading
|
||||
}
|
||||
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
// No rate limit for heartbeat
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit Strategies
|
||||
|
||||
### Token Bucket (Default)
|
||||
|
||||
The token bucket algorithm allows burst traffic while maintaining long-term rate limits. Tokens are added at a fixed rate, and each request consumes tokens.
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'token-bucket',
|
||||
messagesPerSecond: 10, // Refill rate
|
||||
burstSize: 20, // Bucket capacity
|
||||
}) { }
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
```
|
||||
Config: rate=10/s, burstSize=20
|
||||
|
||||
[0s] Bucket full: 20 tokens
|
||||
[0s] 15 messages → allowed, 5 remaining
|
||||
[0.5s] Refill 5 tokens → 10 tokens
|
||||
[0.5s] 8 messages → allowed, 2 remaining
|
||||
[0.6s] Refill 1 token → 3 tokens
|
||||
[0.6s] 5 messages → 3 allowed, 2 rejected
|
||||
```
|
||||
|
||||
**Best for:** Most general use cases, balances burst tolerance with protection.
|
||||
|
||||
### Sliding Window
|
||||
|
||||
The sliding window algorithm precisely tracks requests within a time window. More accurate than fixed window but uses slightly more memory.
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'sliding-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**Best for:** When you need precise rate limiting without burst tolerance.
|
||||
|
||||
### Fixed Window
|
||||
|
||||
The fixed window algorithm divides time into fixed intervals and counts requests per interval. Simple and memory-efficient but allows 2x burst at window boundaries.
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'fixed-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**Best for:** Simple scenarios where boundary burst is acceptable.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Room Configuration
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
// Messages allowed per second (default: 10)
|
||||
messagesPerSecond: 10,
|
||||
|
||||
// Burst capacity / bucket size (default: 20)
|
||||
burstSize: 20,
|
||||
|
||||
// Strategy: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// Callback when rate limited
|
||||
onLimited: (player, messageType, result) => {
|
||||
player.send('RateLimited', {
|
||||
type: messageType,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// Disconnect on rate limit (default: false)
|
||||
disconnectOnLimit: false,
|
||||
|
||||
// Disconnect after N consecutive limits (0 = never)
|
||||
maxConsecutiveLimits: 10,
|
||||
|
||||
// Custom key function (default: player.id)
|
||||
getKey: (player) => player.id,
|
||||
|
||||
// Cleanup interval in ms (default: 60000)
|
||||
cleanupInterval: 60000,
|
||||
}) { }
|
||||
```
|
||||
|
||||
### Per-Message Configuration
|
||||
|
||||
Use decorators to configure rate limits for specific messages:
|
||||
|
||||
```typescript
|
||||
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
// Custom rate limit for this message
|
||||
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) { }
|
||||
|
||||
// This message costs 5 tokens
|
||||
@rateLimit({ cost: 5 })
|
||||
@onMessage('ExpensiveAction')
|
||||
handleExpensive(data: any, player: Player) { }
|
||||
|
||||
// Exempt from rate limiting
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) { }
|
||||
|
||||
// Alternative: specify message type explicitly
|
||||
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||
@onMessage('SpecialAction')
|
||||
handleSpecial(data: any, player: Player) { }
|
||||
}
|
||||
```
|
||||
|
||||
## Combining with Authentication
|
||||
|
||||
Rate limiting works seamlessly with the authentication system:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth } from '@esengine/server/auth'
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
// Apply both mixins
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth(Room, { requireAuth: true }),
|
||||
{ messagesPerSecond: 10 }
|
||||
) {
|
||||
onJoin(player: AuthPlayer) {
|
||||
console.log(`${player.user?.name} joined with rate limit protection`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit Result
|
||||
|
||||
When a message is rate limited, the callback receives a result object:
|
||||
|
||||
```typescript
|
||||
interface RateLimitResult {
|
||||
// Whether the request was allowed
|
||||
allowed: boolean
|
||||
|
||||
// Remaining quota
|
||||
remaining: number
|
||||
|
||||
// When the quota resets (timestamp)
|
||||
resetAt: number
|
||||
|
||||
// How long to wait before retrying (ms)
|
||||
retryAfter?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Accessing Rate Limit Context
|
||||
|
||||
You can access the rate limit context for any player:
|
||||
|
||||
```typescript
|
||||
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
someMethod(player: Player) {
|
||||
const context = this.getRateLimitContext(player)
|
||||
|
||||
// Check without consuming
|
||||
const status = context?.check()
|
||||
console.log(`Remaining: ${status?.remaining}`)
|
||||
|
||||
// Get consecutive limit count
|
||||
console.log(`Consecutive limits: ${context?.consecutiveLimitCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Or use the standalone function
|
||||
const context = getPlayerRateLimitContext(player)
|
||||
```
|
||||
|
||||
## Custom Strategies
|
||||
|
||||
You can use the strategies directly for custom implementations:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createTokenBucketStrategy,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
// Create strategy directly
|
||||
const strategy = createTokenBucketStrategy({
|
||||
rate: 10, // tokens per second
|
||||
capacity: 20, // max tokens
|
||||
})
|
||||
|
||||
// Check and consume
|
||||
const result = strategy.consume('player-123')
|
||||
if (result.allowed) {
|
||||
// Process message
|
||||
} else {
|
||||
// Rate limited, wait result.retryAfter ms
|
||||
}
|
||||
|
||||
// Check without consuming
|
||||
const status = strategy.getStatus('player-123')
|
||||
|
||||
// Reset a key
|
||||
strategy.reset('player-123')
|
||||
|
||||
// Cleanup expired records
|
||||
strategy.cleanup()
|
||||
```
|
||||
|
||||
## Rate Limit Context
|
||||
|
||||
The `RateLimitContext` class manages rate limiting for a single player:
|
||||
|
||||
```typescript
|
||||
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||
|
||||
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||
const context = new RateLimitContext('player-123', strategy)
|
||||
|
||||
// Check without consuming
|
||||
context.check()
|
||||
|
||||
// Consume quota
|
||||
context.consume()
|
||||
|
||||
// Consume with cost
|
||||
context.consume(undefined, 5)
|
||||
|
||||
// Consume for specific message type
|
||||
context.consume('Trade')
|
||||
|
||||
// Set per-message strategy
|
||||
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||
|
||||
// Reset
|
||||
context.reset()
|
||||
|
||||
// Get consecutive limit count
|
||||
console.log(context.consecutiveLimitCount)
|
||||
```
|
||||
|
||||
## Room Lifecycle Hook
|
||||
|
||||
You can override the `onRateLimited` hook for custom handling:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
// Log the event
|
||||
console.log(`Player ${player.id} rate limited on ${messageType}`)
|
||||
|
||||
// Send custom error
|
||||
player.send('SystemMessage', {
|
||||
type: 'warning',
|
||||
message: `Slow down! Try again in ${result.retryAfter}ms`,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with token bucket**: It's the most flexible algorithm for games.
|
||||
|
||||
2. **Set appropriate limits**: Consider your game's mechanics:
|
||||
- Movement messages: Higher limits (20-60/s)
|
||||
- Chat messages: Lower limits (1-5/s)
|
||||
- Trade/purchase: Very low limits (0.5-1/s)
|
||||
|
||||
3. **Use burst capacity**: Allow short bursts for responsive gameplay:
|
||||
```typescript
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30, // Allow 3s worth of burst
|
||||
```
|
||||
|
||||
4. **Exempt critical messages**: Don't rate limit heartbeats or system messages:
|
||||
```typescript
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() { }
|
||||
```
|
||||
|
||||
5. **Combine with auth**: Rate limit by user ID for authenticated users:
|
||||
```typescript
|
||||
getKey: (player) => player.auth?.userId ?? player.id
|
||||
```
|
||||
|
||||
6. **Monitor and adjust**: Log rate limit events to tune your limits:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
metrics.increment('rate_limit', { messageType: type })
|
||||
}
|
||||
```
|
||||
|
||||
7. **Graceful degradation**: Send informative errors instead of just disconnecting:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'Too many requests',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage, type Player } from '@esengine/server'
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
import {
|
||||
withRateLimit,
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
type RateLimitResult,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
premium: boolean
|
||||
}
|
||||
|
||||
// Combine auth and rate limit
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||
{
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30,
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// Use user ID for rate limiting
|
||||
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||
|
||||
// Handle rate limits
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
messageType: type,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// Disconnect after 20 consecutive rate limits
|
||||
maxConsecutiveLimits: 20,
|
||||
}
|
||||
) {
|
||||
onCreate() {
|
||||
console.log('Room created with auth + rate limit protection')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||
}
|
||||
|
||||
// High-frequency movement (default rate limit)
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||
}
|
||||
|
||||
// Low-frequency trading (strict limit)
|
||||
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||
// Process trade...
|
||||
}
|
||||
|
||||
// Chat with moderate limit
|
||||
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
|
||||
// System messages - no limit
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
player.send('Pong', { time: Date.now() })
|
||||
}
|
||||
|
||||
// Custom rate limit handling
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
console.warn(`[RateLimit] Player ${player.id} limited on ${messageType}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -311,6 +311,93 @@ client.send('RoomMessage', {
|
||||
})
|
||||
```
|
||||
|
||||
## ECSRoom
|
||||
|
||||
`ECSRoom` is a room base class with ECS World support, suitable for games that need ECS architecture.
|
||||
|
||||
### Server Startup
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { createServer } from '@esengine/server';
|
||||
import { GameRoom } from './rooms/GameRoom.js';
|
||||
|
||||
// Initialize Core
|
||||
Core.create();
|
||||
|
||||
// Global game loop
|
||||
setInterval(() => Core.update(1/60), 16);
|
||||
|
||||
// Create server
|
||||
const server = await createServer({ port: 3000 });
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### Define ECSRoom
|
||||
|
||||
```typescript
|
||||
import { ECSRoom, Player } from '@esengine/server/ecs';
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
// Define sync component
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
}
|
||||
|
||||
// Define room
|
||||
class GameRoom extends ECSRoom {
|
||||
onCreate() {
|
||||
this.addSystem(new MovementSystem());
|
||||
}
|
||||
|
||||
onJoin(player: Player) {
|
||||
const entity = this.createPlayerEntity(player.id);
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = player.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ECSRoom API
|
||||
|
||||
```typescript
|
||||
abstract class ECSRoom<TState, TPlayerData> extends Room<TState, TPlayerData> {
|
||||
protected readonly world: World; // ECS World
|
||||
protected readonly scene: Scene; // Main scene
|
||||
|
||||
// Scene management
|
||||
protected addSystem(system: EntitySystem): void;
|
||||
protected createEntity(name?: string): Entity;
|
||||
protected createPlayerEntity(playerId: string, name?: string): Entity;
|
||||
protected getPlayerEntity(playerId: string): Entity | undefined;
|
||||
protected destroyPlayerEntity(playerId: string): void;
|
||||
|
||||
// State sync
|
||||
protected sendFullState(player: Player): void;
|
||||
protected broadcastSpawn(entity: Entity, prefabType?: string): void;
|
||||
protected broadcastDelta(): void;
|
||||
}
|
||||
```
|
||||
|
||||
### @sync Decorator
|
||||
|
||||
Mark component fields that need network synchronization:
|
||||
|
||||
| Type | Description | Bytes |
|
||||
|------|-------------|-------|
|
||||
| `"boolean"` | Boolean | 1 |
|
||||
| `"int8"` / `"uint8"` | 8-bit integer | 1 |
|
||||
| `"int16"` / `"uint16"` | 16-bit integer | 2 |
|
||||
| `"int32"` / `"uint32"` | 32-bit integer | 4 |
|
||||
| `"float32"` | 32-bit float | 4 |
|
||||
| `"float64"` | 64-bit float | 8 |
|
||||
| `"string"` | String | Variable |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Set Appropriate Tick Rate**
|
||||
|
||||
@@ -1,8 +1,176 @@
|
||||
---
|
||||
title: "State Sync"
|
||||
description: "Interpolation, prediction and snapshot buffers"
|
||||
description: "Component sync, interpolation, prediction and snapshot buffers"
|
||||
---
|
||||
|
||||
## @NetworkEntity Decorator
|
||||
|
||||
The `@NetworkEntity` decorator marks components for automatic spawn/despawn broadcasting. When an entity containing this component is created or destroyed, ECSRoom automatically broadcasts the corresponding message to all clients.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync, NetworkEntity } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Enemy')
|
||||
@NetworkEntity('Enemy')
|
||||
class EnemyComponent extends Component {
|
||||
@sync('float32') x: number = 0;
|
||||
@sync('float32') y: number = 0;
|
||||
@sync('uint16') health: number = 100;
|
||||
}
|
||||
```
|
||||
|
||||
When adding this component to an entity, ECSRoom automatically broadcasts the spawn message:
|
||||
|
||||
```typescript
|
||||
// Server-side
|
||||
const entity = scene.createEntity('Enemy');
|
||||
entity.addComponent(new EnemyComponent()); // Auto-broadcasts spawn
|
||||
|
||||
// Destroying auto-broadcasts despawn
|
||||
entity.destroy(); // Auto-broadcasts despawn
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```typescript
|
||||
@NetworkEntity('Bullet', {
|
||||
autoSpawn: true, // Auto-broadcast spawn (default true)
|
||||
autoDespawn: false // Disable auto-broadcast despawn
|
||||
})
|
||||
class BulletComponent extends Component { }
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `autoSpawn` | `boolean` | `true` | Auto-broadcast spawn when component is added |
|
||||
| `autoDespawn` | `boolean` | `true` | Auto-broadcast despawn when entity is destroyed |
|
||||
|
||||
### Initialization Order
|
||||
|
||||
When using `@NetworkEntity`, initialize data **before** adding the component:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: Initialize first, then add
|
||||
const comp = new PlayerComponent();
|
||||
comp.playerId = player.id;
|
||||
comp.x = 100;
|
||||
comp.y = 200;
|
||||
entity.addComponent(comp); // Data is correct at spawn
|
||||
|
||||
// ❌ Wrong: Add first, then initialize
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.playerId = player.id; // Data has default values at spawn
|
||||
```
|
||||
|
||||
### Simplified GameRoom
|
||||
|
||||
With `@NetworkEntity`, GameRoom becomes much cleaner:
|
||||
|
||||
```typescript
|
||||
// No manual callbacks needed
|
||||
class GameRoom extends ECSRoom {
|
||||
private setupSystems(): void {
|
||||
// Enemy spawn system (auto-broadcasts spawn)
|
||||
this.addSystem(new EnemySpawnSystem());
|
||||
|
||||
// Enemy AI system
|
||||
const enemyAI = new EnemyAISystem();
|
||||
enemyAI.onDeath((enemy) => {
|
||||
enemy.destroy(); // Auto-broadcasts despawn
|
||||
});
|
||||
this.addSystem(enemyAI);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ECSRoom Configuration
|
||||
|
||||
You can disable the auto network entity feature in ECSRoom:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends ECSRoom {
|
||||
constructor() {
|
||||
super({
|
||||
enableAutoNetworkEntity: false // Disable auto-broadcasting
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component Sync System
|
||||
|
||||
ECS component state synchronization based on `@sync` decorator.
|
||||
|
||||
### Define Sync Component
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
|
||||
// Fields without @sync won't be synced
|
||||
localData: any;
|
||||
}
|
||||
```
|
||||
|
||||
### Server-side Encoding
|
||||
|
||||
```typescript
|
||||
import { ComponentSyncSystem } from '@esengine/network';
|
||||
|
||||
const syncSystem = new ComponentSyncSystem({}, true);
|
||||
scene.addSystem(syncSystem);
|
||||
|
||||
// Encode all entities (initial connection)
|
||||
const fullData = syncSystem.encodeAllEntities(true);
|
||||
sendToClient(fullData);
|
||||
|
||||
// Encode delta (only send changes)
|
||||
const deltaData = syncSystem.encodeDelta();
|
||||
if (deltaData) {
|
||||
broadcast(deltaData);
|
||||
}
|
||||
```
|
||||
|
||||
### Client-side Decoding
|
||||
|
||||
```typescript
|
||||
const syncSystem = new ComponentSyncSystem();
|
||||
scene.addSystem(syncSystem);
|
||||
|
||||
// Register component types
|
||||
syncSystem.registerComponent(PlayerComponent);
|
||||
|
||||
// Listen for sync events
|
||||
syncSystem.addSyncListener((event) => {
|
||||
if (event.type === 'entitySpawned') {
|
||||
console.log('New entity:', event.entityId);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply state
|
||||
syncSystem.applySnapshot(data);
|
||||
```
|
||||
|
||||
### Sync Types
|
||||
|
||||
| Type | Description | Bytes |
|
||||
|------|-------------|-------|
|
||||
| `"boolean"` | Boolean | 1 |
|
||||
| `"int8"` / `"uint8"` | 8-bit integer | 1 |
|
||||
| `"int16"` / `"uint16"` | 16-bit integer | 2 |
|
||||
| `"int32"` / `"uint32"` | 32-bit integer | 4 |
|
||||
| `"float32"` | 32-bit float | 4 |
|
||||
| `"float64"` | 64-bit float | 8 |
|
||||
| `"string"` | String | Variable |
|
||||
|
||||
## Snapshot Buffer
|
||||
|
||||
Stores server state snapshots for interpolation:
|
||||
|
||||
251
docs/src/content/docs/en/modules/rpc/client.md
Normal file
251
docs/src/content/docs/en/modules/rpc/client.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: "RPC Client API"
|
||||
description: "RpcClient for connecting to RPC servers"
|
||||
---
|
||||
|
||||
The `RpcClient` class provides a type-safe WebSocket client for RPC communication.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('Connected'),
|
||||
onDisconnect: (reason) => console.log('Disconnected:', reason),
|
||||
onError: (error) => console.error('Error:', error),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
```
|
||||
|
||||
## Constructor Options
|
||||
|
||||
```typescript
|
||||
interface RpcClientOptions {
|
||||
// Codec for serialization (default: json())
|
||||
codec?: Codec;
|
||||
|
||||
// API call timeout in ms (default: 30000)
|
||||
timeout?: number;
|
||||
|
||||
// Auto reconnect on disconnect (default: true)
|
||||
autoReconnect?: boolean;
|
||||
|
||||
// Reconnect interval in ms (default: 3000)
|
||||
reconnectInterval?: number;
|
||||
|
||||
// Custom WebSocket factory (for WeChat Mini Games, etc.)
|
||||
webSocketFactory?: (url: string) => WebSocketAdapter;
|
||||
|
||||
// Callbacks
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: (reason?: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## Connection
|
||||
|
||||
### Connect
|
||||
|
||||
```typescript
|
||||
// Connect returns a promise
|
||||
await client.connect();
|
||||
|
||||
// Or chain
|
||||
client.connect().then(() => {
|
||||
console.log('Ready');
|
||||
});
|
||||
```
|
||||
|
||||
### Check Status
|
||||
|
||||
```typescript
|
||||
// Connection status: 'connecting' | 'open' | 'closing' | 'closed'
|
||||
console.log(client.status);
|
||||
|
||||
// Convenience boolean
|
||||
if (client.isConnected) {
|
||||
// Safe to call APIs
|
||||
}
|
||||
```
|
||||
|
||||
### Disconnect
|
||||
|
||||
```typescript
|
||||
// Manually disconnect (disables auto-reconnect)
|
||||
client.disconnect();
|
||||
```
|
||||
|
||||
## Calling APIs
|
||||
|
||||
APIs use request-response pattern with full type safety:
|
||||
|
||||
```typescript
|
||||
// Define protocol
|
||||
const protocol = rpc.define({
|
||||
api: {
|
||||
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
|
||||
getProfile: rpc.api<{ userId: string }, { name: string; level: number }>(),
|
||||
},
|
||||
msg: {}
|
||||
});
|
||||
|
||||
// Call with type inference
|
||||
const { userId, token } = await client.call('login', { username: 'player1' });
|
||||
const profile = await client.call('getProfile', { userId });
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/client';
|
||||
|
||||
try {
|
||||
await client.call('login', { username: 'player1' });
|
||||
} catch (error) {
|
||||
if (error instanceof RpcError) {
|
||||
switch (error.code) {
|
||||
case ErrorCode.TIMEOUT:
|
||||
console.log('Request timed out');
|
||||
break;
|
||||
case ErrorCode.CONNECTION_CLOSED:
|
||||
console.log('Not connected');
|
||||
break;
|
||||
case ErrorCode.NOT_FOUND:
|
||||
console.log('API not found');
|
||||
break;
|
||||
default:
|
||||
console.log('Server error:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sending Messages
|
||||
|
||||
Messages are fire-and-forget (no response):
|
||||
|
||||
```typescript
|
||||
// Send message to server
|
||||
client.send('playerMove', { x: 100, y: 200 });
|
||||
client.send('chat', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
## Receiving Messages
|
||||
|
||||
Listen for server-pushed messages:
|
||||
|
||||
```typescript
|
||||
// Subscribe to message
|
||||
client.on('newMessage', (data) => {
|
||||
console.log(`${data.from}: ${data.text}`);
|
||||
});
|
||||
|
||||
client.on('playerJoined', (data) => {
|
||||
console.log(`${data.name} joined the game`);
|
||||
});
|
||||
|
||||
// Unsubscribe specific handler
|
||||
const handler = (data) => console.log(data);
|
||||
client.on('event', handler);
|
||||
client.off('event', handler);
|
||||
|
||||
// Unsubscribe all handlers for a message
|
||||
client.off('event');
|
||||
|
||||
// One-time listener
|
||||
client.once('gameStart', (data) => {
|
||||
console.log('Game started!');
|
||||
});
|
||||
```
|
||||
|
||||
## Custom WebSocket (Platform Adapters)
|
||||
|
||||
For platforms like WeChat Mini Games:
|
||||
|
||||
```typescript
|
||||
// WeChat Mini Games adapter
|
||||
const wxWebSocketFactory = (url: string) => {
|
||||
const ws = wx.connectSocket({ url });
|
||||
|
||||
return {
|
||||
get readyState() { return ws.readyState; },
|
||||
send: (data) => ws.send({ data }),
|
||||
close: (code, reason) => ws.close({ code, reason }),
|
||||
set onopen(fn) { ws.onOpen(fn); },
|
||||
set onclose(fn) { ws.onClose((e) => fn({ code: e.code, reason: e.reason })); },
|
||||
set onerror(fn) { ws.onError(fn); },
|
||||
set onmessage(fn) { ws.onMessage((e) => fn({ data: e.data })); },
|
||||
};
|
||||
};
|
||||
|
||||
const client = new RpcClient(protocol, 'wss://game.example.com', {
|
||||
webSocketFactory: wxWebSocketFactory,
|
||||
});
|
||||
```
|
||||
|
||||
## Convenience Function
|
||||
|
||||
```typescript
|
||||
import { connect } from '@esengine/rpc/client';
|
||||
|
||||
// Connect and return client in one call
|
||||
const client = await connect(protocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('Connected'),
|
||||
});
|
||||
|
||||
const result = await client.call('join', { name: 'Alice' });
|
||||
```
|
||||
|
||||
## Full Example
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
class GameClient {
|
||||
private client: RpcClient<typeof gameProtocol>;
|
||||
private userId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => this.onConnected(),
|
||||
onDisconnect: () => this.onDisconnected(),
|
||||
onError: (e) => console.error('RPC Error:', e),
|
||||
});
|
||||
|
||||
// Setup message handlers
|
||||
this.client.on('gameState', (state) => this.updateState(state));
|
||||
this.client.on('playerJoined', (p) => this.addPlayer(p));
|
||||
this.client.on('playerLeft', (p) => this.removePlayer(p));
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
private async onConnected() {
|
||||
const { userId, token } = await this.client.call('login', {
|
||||
username: localStorage.getItem('username') || 'Guest',
|
||||
});
|
||||
this.userId = userId;
|
||||
console.log('Logged in as', userId);
|
||||
}
|
||||
|
||||
private onDisconnected() {
|
||||
console.log('Disconnected, will auto-reconnect...');
|
||||
}
|
||||
|
||||
async move(x: number, y: number) {
|
||||
if (!this.client.isConnected) return;
|
||||
this.client.send('move', { x, y });
|
||||
}
|
||||
|
||||
async chat(text: string) {
|
||||
await this.client.call('sendChat', { text });
|
||||
}
|
||||
}
|
||||
```
|
||||
160
docs/src/content/docs/en/modules/rpc/codec.md
Normal file
160
docs/src/content/docs/en/modules/rpc/codec.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "RPC Codecs"
|
||||
description: "Serialization codecs for RPC communication"
|
||||
---
|
||||
|
||||
Codecs handle serialization and deserialization of RPC messages. Two built-in codecs are available.
|
||||
|
||||
## Built-in Codecs
|
||||
|
||||
### JSON Codec (Default)
|
||||
|
||||
Human-readable, widely compatible:
|
||||
|
||||
```typescript
|
||||
import { json } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: json(),
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Human-readable (easy debugging)
|
||||
- No additional dependencies
|
||||
- Universal browser support
|
||||
|
||||
**Cons:**
|
||||
- Larger message size
|
||||
- Slower serialization
|
||||
|
||||
### MessagePack Codec
|
||||
|
||||
Binary format, more efficient:
|
||||
|
||||
```typescript
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: msgpack(),
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Smaller message size (~30-50% smaller)
|
||||
- Faster serialization
|
||||
- Supports binary data natively
|
||||
|
||||
**Cons:**
|
||||
- Not human-readable
|
||||
- Requires msgpack library
|
||||
|
||||
## Codec Interface
|
||||
|
||||
```typescript
|
||||
interface Codec {
|
||||
/**
|
||||
* Encode packet to wire format
|
||||
*/
|
||||
encode(packet: unknown): string | Uint8Array;
|
||||
|
||||
/**
|
||||
* Decode wire format to packet
|
||||
*/
|
||||
decode(data: string | Uint8Array): unknown;
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Codec
|
||||
|
||||
Create your own codec for special needs:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
// Example: Compressed JSON codec
|
||||
const compressedJson: () => Codec = () => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
const json = JSON.stringify(packet);
|
||||
return compress(new TextEncoder().encode(json));
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
const decompressed = decompress(bytes);
|
||||
return JSON.parse(new TextDecoder().decode(decompressed));
|
||||
},
|
||||
});
|
||||
|
||||
// Use custom codec
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: compressedJson(),
|
||||
});
|
||||
```
|
||||
|
||||
## Protocol Buffers Codec
|
||||
|
||||
For production games, consider Protocol Buffers:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
const protobuf = (schema: ProtobufSchema): Codec => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
return schema.Packet.encode(packet).finish();
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
return schema.Packet.decode(bytes);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Matching Client and Server
|
||||
|
||||
Both client and server must use the same codec:
|
||||
|
||||
```typescript
|
||||
// shared/codec.ts
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
export const gameCodec = msgpack();
|
||||
|
||||
// client.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const client = new RpcClient(protocol, url, { codec: gameCodec });
|
||||
|
||||
// server.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
codec: gameCodec,
|
||||
api: { /* ... */ },
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Codec | Encode Speed | Decode Speed | Size |
|
||||
|-------|-------------|--------------|------|
|
||||
| JSON | Medium | Medium | Large |
|
||||
| MessagePack | Fast | Fast | Small |
|
||||
| Protobuf | Fastest | Fastest | Smallest |
|
||||
|
||||
For most games, MessagePack provides a good balance. Use Protobuf for high-performance requirements.
|
||||
|
||||
## Text Encoding Utilities
|
||||
|
||||
For custom codecs, utilities are provided:
|
||||
|
||||
```typescript
|
||||
import { textEncode, textDecode } from '@esengine/rpc/codec';
|
||||
|
||||
// Works on all platforms (browser, Node.js, WeChat)
|
||||
const bytes = textEncode('Hello'); // Uint8Array
|
||||
const text = textDecode(bytes); // 'Hello'
|
||||
```
|
||||
350
docs/src/content/docs/en/modules/rpc/server.md
Normal file
350
docs/src/content/docs/en/modules/rpc/server.md
Normal file
@@ -0,0 +1,350 @@
|
||||
---
|
||||
title: "RPC Server API"
|
||||
description: "RpcServer for handling client connections"
|
||||
---
|
||||
|
||||
The `serve` function creates a type-safe RPC server that handles client connections and API calls.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { serve } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
console.log(`${input.username} connected from ${conn.ip}`);
|
||||
return { userId: conn.id, token: generateToken() };
|
||||
},
|
||||
sendChat: async (input, conn) => {
|
||||
server.broadcast('newMessage', {
|
||||
from: conn.id,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onStart: (port) => console.log(`Server started on port ${port}`),
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
## Server Options
|
||||
|
||||
```typescript
|
||||
interface ServeOptions<P, TConnData> {
|
||||
// Required
|
||||
port: number;
|
||||
api: ApiHandlers<P, TConnData>;
|
||||
|
||||
// Optional
|
||||
msg?: MsgHandlers<P, TConnData>;
|
||||
codec?: Codec;
|
||||
createConnData?: () => TConnData;
|
||||
|
||||
// Callbacks
|
||||
onConnect?: (conn: Connection<TConnData>) => void | Promise<void>;
|
||||
onDisconnect?: (conn: Connection<TConnData>, reason?: string) => void | Promise<void>;
|
||||
onError?: (error: Error, conn?: Connection<TConnData>) => void;
|
||||
onStart?: (port: number) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## API Handlers
|
||||
|
||||
Each API handler receives the input and connection context:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
// Sync handler
|
||||
ping: (input, conn) => {
|
||||
return { pong: true, time: Date.now() };
|
||||
},
|
||||
|
||||
// Async handler
|
||||
getProfile: async (input, conn) => {
|
||||
const user = await database.findUser(input.userId);
|
||||
return { name: user.name, level: user.level };
|
||||
},
|
||||
|
||||
// Access connection context
|
||||
getMyInfo: (input, conn) => {
|
||||
return {
|
||||
connectionId: conn.id,
|
||||
ip: conn.ip,
|
||||
data: conn.data,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Throwing Errors
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/server';
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
const user = await database.findUser(input.username);
|
||||
|
||||
if (!user) {
|
||||
throw new RpcError(ErrorCode.NOT_FOUND, 'User not found');
|
||||
}
|
||||
|
||||
if (!await verifyPassword(input.password, user.hash)) {
|
||||
throw new RpcError('AUTH_FAILED', 'Invalid password');
|
||||
}
|
||||
|
||||
return { userId: user.id, token: generateToken() };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Message Handlers
|
||||
|
||||
Handle messages sent by clients:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: { /* ... */ },
|
||||
msg: {
|
||||
playerMove: (data, conn) => {
|
||||
// Update player position
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
}
|
||||
|
||||
// Broadcast to others
|
||||
server.broadcast('playerMoved', {
|
||||
playerId: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
},
|
||||
|
||||
chat: async (data, conn) => {
|
||||
// Async handlers work too
|
||||
await logChat(conn.id, data.text);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Connection Context
|
||||
|
||||
The `Connection` object provides access to client info:
|
||||
|
||||
```typescript
|
||||
interface Connection<TData> {
|
||||
// Unique connection ID
|
||||
readonly id: string;
|
||||
|
||||
// Client IP address
|
||||
readonly ip: string;
|
||||
|
||||
// Connection status
|
||||
readonly isOpen: boolean;
|
||||
|
||||
// Custom data attached to this connection
|
||||
data: TData;
|
||||
|
||||
// Close the connection
|
||||
close(reason?: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Connection Data
|
||||
|
||||
Store per-connection state:
|
||||
|
||||
```typescript
|
||||
interface PlayerData {
|
||||
playerId: string;
|
||||
username: string;
|
||||
room: string | null;
|
||||
}
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({
|
||||
playerId: '',
|
||||
username: '',
|
||||
room: null,
|
||||
} as PlayerData),
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
// Store data on connection
|
||||
conn.data.playerId = generateId();
|
||||
conn.data.username = input.username;
|
||||
return { playerId: conn.data.playerId };
|
||||
},
|
||||
joinRoom: async (input, conn) => {
|
||||
conn.data.room = input.roomId;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onDisconnect: (conn) => {
|
||||
console.log(`${conn.data.username} left room ${conn.data.room}`);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Sending Messages
|
||||
|
||||
### To Single Connection
|
||||
|
||||
```typescript
|
||||
server.send(conn, 'notification', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
### Broadcast to All
|
||||
|
||||
```typescript
|
||||
// To everyone
|
||||
server.broadcast('announcement', { text: 'Server restart in 5 minutes' });
|
||||
|
||||
// Exclude sender
|
||||
server.broadcast('playerMoved', { id: conn.id, x, y }, { exclude: conn });
|
||||
|
||||
// Exclude multiple
|
||||
server.broadcast('gameEvent', data, { exclude: [conn1, conn2] });
|
||||
```
|
||||
|
||||
### To Specific Group
|
||||
|
||||
```typescript
|
||||
// Custom broadcasting
|
||||
function broadcastToRoom(roomId: string, name: string, data: any) {
|
||||
for (const conn of server.connections) {
|
||||
if (conn.data.room === roomId) {
|
||||
server.send(conn, name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToRoom('room1', 'roomMessage', { text: 'Hello room!' });
|
||||
```
|
||||
|
||||
## Server Lifecycle
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, { /* ... */ });
|
||||
|
||||
// Start
|
||||
await server.start();
|
||||
console.log('Server running');
|
||||
|
||||
// Access connections
|
||||
console.log(`${server.connections.length} clients connected`);
|
||||
|
||||
// Stop (closes all connections)
|
||||
await server.stop();
|
||||
console.log('Server stopped');
|
||||
```
|
||||
|
||||
## Full Example
|
||||
|
||||
```typescript
|
||||
import { serve, RpcError } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
interface PlayerData {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const players = new Map<string, PlayerData>();
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({ id: '', name: '', x: 0, y: 0 }),
|
||||
|
||||
api: {
|
||||
join: async (input, conn) => {
|
||||
const player: PlayerData = {
|
||||
id: conn.id,
|
||||
name: input.name,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
players.set(conn.id, player);
|
||||
conn.data = player;
|
||||
|
||||
// Notify others
|
||||
server.broadcast('playerJoined', {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
}, { exclude: conn });
|
||||
|
||||
// Send current state to new player
|
||||
return {
|
||||
playerId: player.id,
|
||||
players: Array.from(players.values()),
|
||||
};
|
||||
},
|
||||
|
||||
chat: async (input, conn) => {
|
||||
server.broadcast('chatMessage', {
|
||||
from: conn.data.name,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { sent: true };
|
||||
},
|
||||
},
|
||||
|
||||
msg: {
|
||||
move: (data, conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
|
||||
server.broadcast('playerMoved', {
|
||||
id: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
onConnect: (conn) => {
|
||||
console.log(`Client connected: ${conn.id} from ${conn.ip}`);
|
||||
},
|
||||
|
||||
onDisconnect: (conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
players.delete(conn.id);
|
||||
server.broadcast('playerLeft', { id: conn.id });
|
||||
console.log(`${player.name} disconnected`);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (error, conn) => {
|
||||
console.error(`Error from ${conn?.id}:`, error);
|
||||
},
|
||||
|
||||
onStart: (port) => {
|
||||
console.log(`Game server running on ws://localhost:${port}`);
|
||||
},
|
||||
});
|
||||
|
||||
server.start();
|
||||
```
|
||||
261
docs/src/content/docs/en/modules/transaction/core.md
Normal file
261
docs/src/content/docs/en/modules/transaction/core.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
title: "Core Concepts"
|
||||
description: "Transaction system core concepts: context, manager, and Saga pattern"
|
||||
---
|
||||
|
||||
## Transaction State
|
||||
|
||||
A transaction can be in the following states:
|
||||
|
||||
```typescript
|
||||
type TransactionState =
|
||||
| 'pending' // Waiting to execute
|
||||
| 'executing' // Executing
|
||||
| 'committed' // Committed
|
||||
| 'rolledback' // Rolled back
|
||||
| 'failed' // Failed
|
||||
```
|
||||
|
||||
## TransactionContext
|
||||
|
||||
The transaction context encapsulates transaction state, operations, and execution logic.
|
||||
|
||||
### Creating Transactions
|
||||
|
||||
```typescript
|
||||
import { TransactionManager } from '@esengine/transaction';
|
||||
|
||||
const manager = new TransactionManager();
|
||||
|
||||
// Method 1: Manual management with begin()
|
||||
const tx = manager.begin({ timeout: 5000 });
|
||||
tx.addOperation(op1);
|
||||
tx.addOperation(op2);
|
||||
const result = await tx.execute();
|
||||
|
||||
// Method 2: Automatic management with run()
|
||||
const result = await manager.run((tx) => {
|
||||
tx.addOperation(op1);
|
||||
tx.addOperation(op2);
|
||||
});
|
||||
```
|
||||
|
||||
### Chaining Operations
|
||||
|
||||
```typescript
|
||||
const result = await manager.run((tx) => {
|
||||
tx.addOperation(new CurrencyOperation({ ... }))
|
||||
.addOperation(new InventoryOperation({ ... }))
|
||||
.addOperation(new InventoryOperation({ ... }));
|
||||
});
|
||||
```
|
||||
|
||||
### Context Data
|
||||
|
||||
Operations can share data through the context:
|
||||
|
||||
```typescript
|
||||
class CustomOperation extends BaseOperation<MyData, MyResult> {
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
|
||||
// Read data set by previous operations
|
||||
const previousResult = ctx.get<number>('previousValue');
|
||||
|
||||
// Set data for subsequent operations
|
||||
ctx.set('myResult', { value: 123 });
|
||||
|
||||
return this.success({ ... });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TransactionManager
|
||||
|
||||
The transaction manager is responsible for creating, executing, and recovering transactions.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```typescript
|
||||
interface TransactionManagerConfig {
|
||||
storage?: ITransactionStorage; // Storage instance
|
||||
defaultTimeout?: number; // Default timeout (ms)
|
||||
serverId?: string; // Server ID (for distributed)
|
||||
autoRecover?: boolean; // Auto-recover pending transactions
|
||||
}
|
||||
|
||||
const manager = new TransactionManager({
|
||||
storage: new RedisStorage({ client: redis }),
|
||||
defaultTimeout: 10000,
|
||||
serverId: 'server-1',
|
||||
autoRecover: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Distributed Locking
|
||||
|
||||
```typescript
|
||||
// Acquire lock
|
||||
const token = await manager.acquireLock('player:123:inventory', 10000);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Perform operations
|
||||
await doSomething();
|
||||
} finally {
|
||||
// Release lock
|
||||
await manager.releaseLock('player:123:inventory', token);
|
||||
}
|
||||
}
|
||||
|
||||
// Or use withLock for convenience
|
||||
await manager.withLock('player:123:inventory', async () => {
|
||||
await doSomething();
|
||||
}, 10000);
|
||||
```
|
||||
|
||||
### Transaction Recovery
|
||||
|
||||
Recover pending transactions after server restart:
|
||||
|
||||
```typescript
|
||||
const manager = new TransactionManager({
|
||||
storage: new RedisStorage({ client: redis }),
|
||||
serverId: 'server-1',
|
||||
});
|
||||
|
||||
// Recover pending transactions
|
||||
const recoveredCount = await manager.recover();
|
||||
console.log(`Recovered ${recoveredCount} transactions`);
|
||||
```
|
||||
|
||||
## Saga Pattern
|
||||
|
||||
The transaction system uses the Saga pattern. Each operation must implement `execute` and `compensate` methods:
|
||||
|
||||
```typescript
|
||||
interface ITransactionOperation<TData, TResult> {
|
||||
readonly name: string;
|
||||
readonly data: TData;
|
||||
|
||||
// Validate preconditions
|
||||
validate(ctx: ITransactionContext): Promise<boolean>;
|
||||
|
||||
// Forward execution
|
||||
execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>;
|
||||
|
||||
// Compensate (rollback)
|
||||
compensate(ctx: ITransactionContext): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Execution Flow
|
||||
|
||||
```
|
||||
Begin Transaction
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ validate(op1) │──fail──► Return failure
|
||||
└─────────────────────┘
|
||||
│success
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ execute(op1) │──fail──┐
|
||||
└─────────────────────┘ │
|
||||
│success │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ validate(op2) │──fail──┤
|
||||
└─────────────────────┘ │
|
||||
│success │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ execute(op2) │──fail──┤
|
||||
└─────────────────────┘ │
|
||||
│success ▼
|
||||
▼ ┌─────────────────────┐
|
||||
Commit Transaction │ compensate(op1) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
Return failure (rolled back)
|
||||
```
|
||||
|
||||
### Custom Operations
|
||||
|
||||
```typescript
|
||||
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
|
||||
|
||||
interface UpgradeData {
|
||||
playerId: string;
|
||||
itemId: string;
|
||||
targetLevel: number;
|
||||
}
|
||||
|
||||
interface UpgradeResult {
|
||||
newLevel: number;
|
||||
}
|
||||
|
||||
class UpgradeOperation extends BaseOperation<UpgradeData, UpgradeResult> {
|
||||
readonly name = 'upgrade';
|
||||
|
||||
private _previousLevel: number = 0;
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
// Validate item exists and can be upgraded
|
||||
const item = await this.getItem(ctx);
|
||||
return item !== null && item.level < this.data.targetLevel;
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<UpgradeResult>> {
|
||||
const item = await this.getItem(ctx);
|
||||
if (!item) {
|
||||
return this.failure('Item not found', 'ITEM_NOT_FOUND');
|
||||
}
|
||||
|
||||
this._previousLevel = item.level;
|
||||
item.level = this.data.targetLevel;
|
||||
await this.saveItem(ctx, item);
|
||||
|
||||
return this.success({ newLevel: item.level });
|
||||
}
|
||||
|
||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||
const item = await this.getItem(ctx);
|
||||
if (item) {
|
||||
item.level = this._previousLevel;
|
||||
await this.saveItem(ctx, item);
|
||||
}
|
||||
}
|
||||
|
||||
private async getItem(ctx: ITransactionContext) {
|
||||
// Get item from storage
|
||||
}
|
||||
|
||||
private async saveItem(ctx: ITransactionContext, item: any) {
|
||||
// Save item to storage
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Transaction Result
|
||||
|
||||
```typescript
|
||||
interface TransactionResult<T = unknown> {
|
||||
success: boolean; // Whether succeeded
|
||||
transactionId: string; // Transaction ID
|
||||
results: OperationResult[]; // Operation results
|
||||
data?: T; // Final data
|
||||
error?: string; // Error message
|
||||
duration: number; // Execution time (ms)
|
||||
}
|
||||
|
||||
const result = await manager.run((tx) => { ... });
|
||||
|
||||
console.log(`Transaction ${result.transactionId}`);
|
||||
console.log(`Success: ${result.success}`);
|
||||
console.log(`Duration: ${result.duration}ms`);
|
||||
|
||||
if (!result.success) {
|
||||
console.log(`Error: ${result.error}`);
|
||||
}
|
||||
```
|
||||
355
docs/src/content/docs/en/modules/transaction/distributed.md
Normal file
355
docs/src/content/docs/en/modules/transaction/distributed.md
Normal file
@@ -0,0 +1,355 @@
|
||||
---
|
||||
title: "Distributed Transactions"
|
||||
description: "Saga orchestrator and cross-server transaction support"
|
||||
---
|
||||
|
||||
## Saga Orchestrator
|
||||
|
||||
`SagaOrchestrator` is used to orchestrate distributed transactions across servers.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { SagaOrchestrator, RedisStorage } from '@esengine/transaction';
|
||||
|
||||
const orchestrator = new SagaOrchestrator({
|
||||
storage: new RedisStorage({ client: redis }),
|
||||
timeout: 30000,
|
||||
serverId: 'orchestrator-1',
|
||||
});
|
||||
|
||||
const result = await orchestrator.execute([
|
||||
{
|
||||
name: 'deduct_currency',
|
||||
serverId: 'game-server-1',
|
||||
data: { playerId: 'player1', amount: 100 },
|
||||
execute: async (data) => {
|
||||
// Call game server API to deduct currency
|
||||
const response = await gameServerApi.deductCurrency(data);
|
||||
return { success: response.ok };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
// Call game server API to restore currency
|
||||
await gameServerApi.addCurrency(data);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_item',
|
||||
serverId: 'inventory-server-1',
|
||||
data: { playerId: 'player1', itemId: 'sword' },
|
||||
execute: async (data) => {
|
||||
const response = await inventoryServerApi.addItem(data);
|
||||
return { success: response.ok };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await inventoryServerApi.removeItem(data);
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (result.success) {
|
||||
console.log('Saga completed successfully');
|
||||
} else {
|
||||
console.log('Saga failed:', result.error);
|
||||
console.log('Completed steps:', result.completedSteps);
|
||||
console.log('Failed at:', result.failedStep);
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```typescript
|
||||
interface SagaOrchestratorConfig {
|
||||
storage?: ITransactionStorage; // Storage instance
|
||||
timeout?: number; // Timeout in milliseconds
|
||||
serverId?: string; // Orchestrator server ID
|
||||
}
|
||||
```
|
||||
|
||||
### Saga Step
|
||||
|
||||
```typescript
|
||||
interface SagaStep<T = unknown> {
|
||||
name: string; // Step name
|
||||
serverId?: string; // Target server ID
|
||||
data: T; // Step data
|
||||
execute: (data: T) => Promise<OperationResult>; // Execute function
|
||||
compensate: (data: T) => Promise<void>; // Compensate function
|
||||
}
|
||||
```
|
||||
|
||||
### Saga Result
|
||||
|
||||
```typescript
|
||||
interface SagaResult {
|
||||
success: boolean; // Whether succeeded
|
||||
sagaId: string; // Saga ID
|
||||
completedSteps: string[]; // Completed steps
|
||||
failedStep?: string; // Failed step
|
||||
error?: string; // Error message
|
||||
duration: number; // Execution time (ms)
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```
|
||||
Start Saga
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Step 1: execute │──fail──┐
|
||||
└─────────────────────┘ │
|
||||
│success │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ Step 2: execute │──fail──┤
|
||||
└─────────────────────┘ │
|
||||
│success │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ Step 3: execute │──fail──┤
|
||||
└─────────────────────┘ │
|
||||
│success ▼
|
||||
▼ ┌─────────────────────┐
|
||||
Saga Complete │ Step 2: compensate │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Step 1: compensate │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
Saga Failed (compensated)
|
||||
```
|
||||
|
||||
## Saga Logs
|
||||
|
||||
The orchestrator records detailed execution logs:
|
||||
|
||||
```typescript
|
||||
interface SagaLog {
|
||||
id: string; // Saga ID
|
||||
state: SagaLogState; // State
|
||||
steps: SagaStepLog[]; // Step logs
|
||||
createdAt: number; // Creation time
|
||||
updatedAt: number; // Update time
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type SagaLogState =
|
||||
| 'pending' // Waiting to execute
|
||||
| 'running' // Executing
|
||||
| 'completed' // Completed
|
||||
| 'compensating' // Compensating
|
||||
| 'compensated' // Compensated
|
||||
| 'failed' // Failed
|
||||
|
||||
interface SagaStepLog {
|
||||
name: string; // Step name
|
||||
serverId?: string; // Server ID
|
||||
state: SagaStepState; // State
|
||||
startedAt?: number; // Start time
|
||||
completedAt?: number; // Completion time
|
||||
error?: string; // Error message
|
||||
}
|
||||
|
||||
type SagaStepState =
|
||||
| 'pending' // Waiting to execute
|
||||
| 'executing' // Executing
|
||||
| 'completed' // Completed
|
||||
| 'compensating' // Compensating
|
||||
| 'compensated' // Compensated
|
||||
| 'failed' // Failed
|
||||
```
|
||||
|
||||
### Query Saga Logs
|
||||
|
||||
```typescript
|
||||
const log = await orchestrator.getSagaLog('saga_xxx');
|
||||
|
||||
if (log) {
|
||||
console.log('Saga state:', log.state);
|
||||
for (const step of log.steps) {
|
||||
console.log(` ${step.name}: ${step.state}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Server Transaction Examples
|
||||
|
||||
### Scenario: Cross-Server Purchase
|
||||
|
||||
A player purchases an item on a game server, with currency on an account server and items on an inventory server.
|
||||
|
||||
```typescript
|
||||
const orchestrator = new SagaOrchestrator({
|
||||
storage: redisStorage,
|
||||
serverId: 'purchase-orchestrator',
|
||||
});
|
||||
|
||||
async function crossServerPurchase(
|
||||
playerId: string,
|
||||
itemId: string,
|
||||
price: number
|
||||
): Promise<SagaResult> {
|
||||
return orchestrator.execute([
|
||||
// Step 1: Deduct balance on account server
|
||||
{
|
||||
name: 'deduct_balance',
|
||||
serverId: 'account-server',
|
||||
data: { playerId, amount: price },
|
||||
execute: async (data) => {
|
||||
const result = await accountService.deduct(data.playerId, data.amount);
|
||||
return { success: result.ok, error: result.error };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await accountService.refund(data.playerId, data.amount);
|
||||
},
|
||||
},
|
||||
|
||||
// Step 2: Add item on inventory server
|
||||
{
|
||||
name: 'add_item',
|
||||
serverId: 'inventory-server',
|
||||
data: { playerId, itemId },
|
||||
execute: async (data) => {
|
||||
const result = await inventoryService.addItem(data.playerId, data.itemId);
|
||||
return { success: result.ok, error: result.error };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await inventoryService.removeItem(data.playerId, data.itemId);
|
||||
},
|
||||
},
|
||||
|
||||
// Step 3: Record purchase log
|
||||
{
|
||||
name: 'log_purchase',
|
||||
serverId: 'log-server',
|
||||
data: { playerId, itemId, price, timestamp: Date.now() },
|
||||
execute: async (data) => {
|
||||
await logService.recordPurchase(data);
|
||||
return { success: true };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await logService.cancelPurchase(data);
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### Scenario: Cross-Server Trade
|
||||
|
||||
Two players on different servers trade with each other.
|
||||
|
||||
```typescript
|
||||
async function crossServerTrade(
|
||||
playerA: { id: string; server: string; items: string[] },
|
||||
playerB: { id: string; server: string; items: string[] }
|
||||
): Promise<SagaResult> {
|
||||
const steps: SagaStep[] = [];
|
||||
|
||||
// Remove items from player A
|
||||
for (const itemId of playerA.items) {
|
||||
steps.push({
|
||||
name: `remove_${playerA.id}_${itemId}`,
|
||||
serverId: playerA.server,
|
||||
data: { playerId: playerA.id, itemId },
|
||||
execute: async (data) => {
|
||||
return await inventoryService.removeItem(data.playerId, data.itemId);
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await inventoryService.addItem(data.playerId, data.itemId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add items to player B (from A)
|
||||
for (const itemId of playerA.items) {
|
||||
steps.push({
|
||||
name: `add_${playerB.id}_${itemId}`,
|
||||
serverId: playerB.server,
|
||||
data: { playerId: playerB.id, itemId },
|
||||
execute: async (data) => {
|
||||
return await inventoryService.addItem(data.playerId, data.itemId);
|
||||
},
|
||||
compensate: async (data) => {
|
||||
await inventoryService.removeItem(data.playerId, data.itemId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Similarly handle player B's items...
|
||||
|
||||
return orchestrator.execute(steps);
|
||||
}
|
||||
```
|
||||
|
||||
## Recovering Incomplete Sagas
|
||||
|
||||
Recover incomplete Sagas after server restart:
|
||||
|
||||
```typescript
|
||||
const orchestrator = new SagaOrchestrator({
|
||||
storage: redisStorage,
|
||||
serverId: 'my-orchestrator',
|
||||
});
|
||||
|
||||
// Recover incomplete Sagas (will execute compensation)
|
||||
const recoveredCount = await orchestrator.recover();
|
||||
console.log(`Recovered ${recoveredCount} sagas`);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Idempotency
|
||||
|
||||
Ensure all operations are idempotent:
|
||||
|
||||
```typescript
|
||||
{
|
||||
execute: async (data) => {
|
||||
// Use unique ID to ensure idempotency
|
||||
const result = await service.process(data.requestId, data);
|
||||
return { success: result.ok };
|
||||
},
|
||||
compensate: async (data) => {
|
||||
// Compensation must also be idempotent
|
||||
await service.rollback(data.requestId);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Timeout Handling
|
||||
|
||||
Set appropriate timeout values:
|
||||
|
||||
```typescript
|
||||
const orchestrator = new SagaOrchestrator({
|
||||
timeout: 60000, // Cross-server operations need longer timeout
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Monitoring and Alerts
|
||||
|
||||
Log Saga execution results:
|
||||
|
||||
```typescript
|
||||
const result = await orchestrator.execute(steps);
|
||||
|
||||
if (!result.success) {
|
||||
// Send alert
|
||||
alertService.send({
|
||||
type: 'saga_failed',
|
||||
sagaId: result.sagaId,
|
||||
failedStep: result.failedStep,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
// Log details
|
||||
const log = await orchestrator.getSagaLog(result.sagaId);
|
||||
logger.error('Saga failed', { log });
|
||||
}
|
||||
```
|
||||
238
docs/src/content/docs/en/modules/transaction/index.md
Normal file
238
docs/src/content/docs/en/modules/transaction/index.md
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: "Transaction System"
|
||||
description: "Game transaction system with distributed support for shop purchases, player trading, and more"
|
||||
---
|
||||
|
||||
`@esengine/transaction` provides comprehensive game transaction capabilities based on the Saga pattern, supporting shop purchases, player trading, multi-step tasks, and distributed transactions with Redis/MongoDB.
|
||||
|
||||
## Overview
|
||||
|
||||
The transaction system solves common data consistency problems in games:
|
||||
|
||||
| Scenario | Problem | Solution |
|
||||
|----------|---------|----------|
|
||||
| Shop Purchase | Payment succeeded but item not delivered | Atomic transaction with auto-rollback |
|
||||
| Player Trade | One party transferred items but other didn't receive | Saga compensation mechanism |
|
||||
| Cross-Server | Data inconsistency across servers | Distributed lock + transaction log |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/transaction
|
||||
```
|
||||
|
||||
Optional dependencies (install based on storage needs):
|
||||
```bash
|
||||
npm install ioredis # Redis storage
|
||||
npm install mongodb # MongoDB storage
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Transaction Layer │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ TransactionManager - Manages transaction lifecycle │
|
||||
│ TransactionContext - Encapsulates operations and state │
|
||||
│ SagaOrchestrator - Distributed Saga orchestrator │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Storage Layer │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ MemoryStorage - In-memory (dev/test) │
|
||||
│ RedisStorage - Redis (distributed lock + cache) │
|
||||
│ MongoStorage - MongoDB (persistent log) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Operation Layer │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ CurrencyOperation - Currency operations │
|
||||
│ InventoryOperation - Inventory operations │
|
||||
│ TradeOperation - Trade operations │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TransactionManager,
|
||||
MemoryStorage,
|
||||
CurrencyOperation,
|
||||
InventoryOperation,
|
||||
} from '@esengine/transaction';
|
||||
|
||||
// Create transaction manager
|
||||
const manager = new TransactionManager({
|
||||
storage: new MemoryStorage(),
|
||||
defaultTimeout: 10000,
|
||||
});
|
||||
|
||||
// Execute transaction
|
||||
const result = await manager.run((tx) => {
|
||||
// Deduct gold
|
||||
tx.addOperation(new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
}));
|
||||
|
||||
// Add item
|
||||
tx.addOperation(new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player1',
|
||||
itemId: 'sword_001',
|
||||
quantity: 1,
|
||||
}));
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log('Purchase successful!');
|
||||
} else {
|
||||
console.log('Purchase failed:', result.error);
|
||||
}
|
||||
```
|
||||
|
||||
### Player Trading
|
||||
|
||||
```typescript
|
||||
import { TradeOperation } from '@esengine/transaction';
|
||||
|
||||
const result = await manager.run((tx) => {
|
||||
tx.addOperation(new TradeOperation({
|
||||
tradeId: 'trade_001',
|
||||
partyA: {
|
||||
playerId: 'player1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
}));
|
||||
}, { timeout: 30000 });
|
||||
```
|
||||
|
||||
### Using Redis Storage
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { TransactionManager, RedisStorage } from '@esengine/transaction';
|
||||
|
||||
const redis = new Redis('redis://localhost:6379');
|
||||
const storage = new RedisStorage({ client: redis });
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
```
|
||||
|
||||
### Using MongoDB Storage
|
||||
|
||||
```typescript
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { TransactionManager, MongoStorage } from '@esengine/transaction';
|
||||
|
||||
const client = new MongoClient('mongodb://localhost:27017');
|
||||
await client.connect();
|
||||
const db = client.db('game');
|
||||
|
||||
const storage = new MongoStorage({ db });
|
||||
await storage.ensureIndexes();
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
```
|
||||
|
||||
## Room Integration
|
||||
|
||||
```typescript
|
||||
import { Room } from '@esengine/server';
|
||||
import { withTransactions, CurrencyOperation, RedisStorage } from '@esengine/transaction';
|
||||
|
||||
class GameRoom extends withTransactions(Room, {
|
||||
storage: new RedisStorage({ client: redisClient }),
|
||||
}) {
|
||||
@onMessage('Buy')
|
||||
async handleBuy(data: { itemId: string }, player: Player) {
|
||||
const result = await this.runTransaction((tx) => {
|
||||
tx.addOperation(new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: player.id,
|
||||
currency: 'gold',
|
||||
amount: getItemPrice(data.itemId),
|
||||
}));
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
player.send('buy_success', { itemId: data.itemId });
|
||||
} else {
|
||||
player.send('buy_failed', { error: result.error });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Core Concepts](/en/modules/transaction/core/) - Transaction context, manager, Saga pattern
|
||||
- [Storage Layer](/en/modules/transaction/storage/) - MemoryStorage, RedisStorage, MongoStorage
|
||||
- [Operations](/en/modules/transaction/operations/) - Currency, inventory, trade operations
|
||||
- [Distributed Transactions](/en/modules/transaction/distributed/) - Saga orchestrator, cross-server transactions
|
||||
- [API Reference](/en/modules/transaction/api/) - Complete API documentation
|
||||
|
||||
## Service Tokens
|
||||
|
||||
For dependency injection:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TransactionManagerToken,
|
||||
TransactionStorageToken,
|
||||
} from '@esengine/transaction';
|
||||
|
||||
const manager = services.get(TransactionManagerToken);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Operation Granularity
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Fine-grained operations, easy to rollback
|
||||
tx.addOperation(new CurrencyOperation({ type: 'deduct', ... }));
|
||||
tx.addOperation(new InventoryOperation({ type: 'add', ... }));
|
||||
|
||||
// ❌ Bad: Coarse-grained operation, hard to partially rollback
|
||||
tx.addOperation(new ComplexPurchaseOperation({ ... }));
|
||||
```
|
||||
|
||||
### 2. Timeout Settings
|
||||
|
||||
```typescript
|
||||
// Simple operations: short timeout
|
||||
await manager.run(tx => { ... }, { timeout: 5000 });
|
||||
|
||||
// Complex trades: longer timeout
|
||||
await manager.run(tx => { ... }, { timeout: 30000 });
|
||||
|
||||
// Cross-server: even longer timeout
|
||||
await manager.run(tx => { ... }, { timeout: 60000, distributed: true });
|
||||
```
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
```typescript
|
||||
const result = await manager.run((tx) => { ... });
|
||||
|
||||
if (!result.success) {
|
||||
// Log the error
|
||||
logger.error('Transaction failed', {
|
||||
transactionId: result.transactionId,
|
||||
error: result.error,
|
||||
duration: result.duration,
|
||||
});
|
||||
|
||||
// Notify user
|
||||
player.send('error', { message: getErrorMessage(result.error) });
|
||||
}
|
||||
```
|
||||
313
docs/src/content/docs/en/modules/transaction/operations.md
Normal file
313
docs/src/content/docs/en/modules/transaction/operations.md
Normal file
@@ -0,0 +1,313 @@
|
||||
---
|
||||
title: "Operations"
|
||||
description: "Built-in transaction operations: currency, inventory, trade"
|
||||
---
|
||||
|
||||
## BaseOperation
|
||||
|
||||
Base class for all operations, providing a common implementation template.
|
||||
|
||||
```typescript
|
||||
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
|
||||
|
||||
class MyOperation extends BaseOperation<MyData, MyResult> {
|
||||
readonly name = 'myOperation';
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
// Validate preconditions
|
||||
return true;
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
|
||||
// Execute operation
|
||||
return this.success({ result: 'ok' });
|
||||
// or
|
||||
return this.failure('Something went wrong', 'ERROR_CODE');
|
||||
}
|
||||
|
||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||
// Rollback operation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CurrencyOperation
|
||||
|
||||
Handles currency addition and deduction.
|
||||
|
||||
### Deduct Currency
|
||||
|
||||
```typescript
|
||||
import { CurrencyOperation } from '@esengine/transaction';
|
||||
|
||||
tx.addOperation(new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
reason: 'purchase_item',
|
||||
}));
|
||||
```
|
||||
|
||||
### Add Currency
|
||||
|
||||
```typescript
|
||||
tx.addOperation(new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: 'player1',
|
||||
currency: 'diamond',
|
||||
amount: 50,
|
||||
reason: 'daily_reward',
|
||||
}));
|
||||
```
|
||||
|
||||
### Operation Data
|
||||
|
||||
```typescript
|
||||
interface CurrencyOperationData {
|
||||
type: 'add' | 'deduct'; // Operation type
|
||||
playerId: string; // Player ID
|
||||
currency: string; // Currency type
|
||||
amount: number; // Amount
|
||||
reason?: string; // Reason/source
|
||||
}
|
||||
```
|
||||
|
||||
### Operation Result
|
||||
|
||||
```typescript
|
||||
interface CurrencyOperationResult {
|
||||
beforeBalance: number; // Balance before operation
|
||||
afterBalance: number; // Balance after operation
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Data Provider
|
||||
|
||||
```typescript
|
||||
interface ICurrencyProvider {
|
||||
getBalance(playerId: string, currency: string): Promise<number>;
|
||||
setBalance(playerId: string, currency: string, amount: number): Promise<void>;
|
||||
}
|
||||
|
||||
class MyCurrencyProvider implements ICurrencyProvider {
|
||||
async getBalance(playerId: string, currency: string): Promise<number> {
|
||||
// Get balance from database
|
||||
return await db.getCurrency(playerId, currency);
|
||||
}
|
||||
|
||||
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
|
||||
// Save to database
|
||||
await db.setCurrency(playerId, currency, amount);
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom provider
|
||||
const op = new CurrencyOperation({ ... });
|
||||
op.setProvider(new MyCurrencyProvider());
|
||||
tx.addOperation(op);
|
||||
```
|
||||
|
||||
## InventoryOperation
|
||||
|
||||
Handles item addition, removal, and updates.
|
||||
|
||||
### Add Item
|
||||
|
||||
```typescript
|
||||
import { InventoryOperation } from '@esengine/transaction';
|
||||
|
||||
tx.addOperation(new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player1',
|
||||
itemId: 'sword_001',
|
||||
quantity: 1,
|
||||
properties: { enchant: 'fire' },
|
||||
}));
|
||||
```
|
||||
|
||||
### Remove Item
|
||||
|
||||
```typescript
|
||||
tx.addOperation(new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: 'player1',
|
||||
itemId: 'potion_hp',
|
||||
quantity: 5,
|
||||
}));
|
||||
```
|
||||
|
||||
### Update Item
|
||||
|
||||
```typescript
|
||||
tx.addOperation(new InventoryOperation({
|
||||
type: 'update',
|
||||
playerId: 'player1',
|
||||
itemId: 'sword_001',
|
||||
quantity: 1, // Optional, keeps original if not provided
|
||||
properties: { enchant: 'lightning', level: 5 },
|
||||
}));
|
||||
```
|
||||
|
||||
### Operation Data
|
||||
|
||||
```typescript
|
||||
interface InventoryOperationData {
|
||||
type: 'add' | 'remove' | 'update'; // Operation type
|
||||
playerId: string; // Player ID
|
||||
itemId: string; // Item ID
|
||||
quantity: number; // Quantity
|
||||
properties?: Record<string, unknown>; // Item properties
|
||||
reason?: string; // Reason/source
|
||||
}
|
||||
```
|
||||
|
||||
### Operation Result
|
||||
|
||||
```typescript
|
||||
interface InventoryOperationResult {
|
||||
beforeItem?: ItemData; // Item before operation
|
||||
afterItem?: ItemData; // Item after operation
|
||||
}
|
||||
|
||||
interface ItemData {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Data Provider
|
||||
|
||||
```typescript
|
||||
interface IInventoryProvider {
|
||||
getItem(playerId: string, itemId: string): Promise<ItemData | null>;
|
||||
setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void>;
|
||||
hasCapacity?(playerId: string, count: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
class MyInventoryProvider implements IInventoryProvider {
|
||||
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
|
||||
return await db.getItem(playerId, itemId);
|
||||
}
|
||||
|
||||
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
|
||||
if (item) {
|
||||
await db.saveItem(playerId, itemId, item);
|
||||
} else {
|
||||
await db.deleteItem(playerId, itemId);
|
||||
}
|
||||
}
|
||||
|
||||
async hasCapacity(playerId: string, count: number): Promise<boolean> {
|
||||
const current = await db.getItemCount(playerId);
|
||||
const max = await db.getMaxCapacity(playerId);
|
||||
return current + count <= max;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TradeOperation
|
||||
|
||||
Handles item and currency exchange between players.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { TradeOperation } from '@esengine/transaction';
|
||||
|
||||
tx.addOperation(new TradeOperation({
|
||||
tradeId: 'trade_001',
|
||||
partyA: {
|
||||
playerId: 'player1',
|
||||
items: [{ itemId: 'sword', quantity: 1 }],
|
||||
currencies: [{ currency: 'diamond', amount: 10 }],
|
||||
},
|
||||
partyB: {
|
||||
playerId: 'player2',
|
||||
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
},
|
||||
reason: 'player_trade',
|
||||
}));
|
||||
```
|
||||
|
||||
### Operation Data
|
||||
|
||||
```typescript
|
||||
interface TradeOperationData {
|
||||
tradeId: string; // Trade ID
|
||||
partyA: TradeParty; // Trade initiator
|
||||
partyB: TradeParty; // Trade receiver
|
||||
reason?: string; // Reason/note
|
||||
}
|
||||
|
||||
interface TradeParty {
|
||||
playerId: string; // Player ID
|
||||
items?: TradeItem[]; // Items to give
|
||||
currencies?: TradeCurrency[]; // Currencies to give
|
||||
}
|
||||
|
||||
interface TradeItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface TradeCurrency {
|
||||
currency: string;
|
||||
amount: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Execution Flow
|
||||
|
||||
TradeOperation internally generates the following sub-operation sequence:
|
||||
|
||||
```
|
||||
1. Remove partyA's items
|
||||
2. Add items to partyB (from partyA)
|
||||
3. Deduct partyA's currencies
|
||||
4. Add currencies to partyB (from partyA)
|
||||
5. Remove partyB's items
|
||||
6. Add items to partyA (from partyB)
|
||||
7. Deduct partyB's currencies
|
||||
8. Add currencies to partyA (from partyB)
|
||||
```
|
||||
|
||||
If any step fails, all previous operations are rolled back.
|
||||
|
||||
### Using Custom Providers
|
||||
|
||||
```typescript
|
||||
const op = new TradeOperation({ ... });
|
||||
op.setProvider({
|
||||
currencyProvider: new MyCurrencyProvider(),
|
||||
inventoryProvider: new MyInventoryProvider(),
|
||||
});
|
||||
tx.addOperation(op);
|
||||
```
|
||||
|
||||
## Factory Functions
|
||||
|
||||
Each operation class provides a factory function:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createCurrencyOperation,
|
||||
createInventoryOperation,
|
||||
createTradeOperation,
|
||||
} from '@esengine/transaction';
|
||||
|
||||
tx.addOperation(createCurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: 'player1',
|
||||
currency: 'gold',
|
||||
amount: 100,
|
||||
}));
|
||||
|
||||
tx.addOperation(createInventoryOperation({
|
||||
type: 'add',
|
||||
playerId: 'player1',
|
||||
itemId: 'sword',
|
||||
quantity: 1,
|
||||
}));
|
||||
```
|
||||
234
docs/src/content/docs/en/modules/transaction/storage.md
Normal file
234
docs/src/content/docs/en/modules/transaction/storage.md
Normal file
@@ -0,0 +1,234 @@
|
||||
---
|
||||
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 factory pattern with lazy connection.
|
||||
|
||||
```typescript
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { MongoStorage } from '@esengine/transaction';
|
||||
|
||||
// Factory pattern: lazy connection, connects on first operation
|
||||
const storage = new MongoStorage({
|
||||
factory: async () => {
|
||||
const client = new MongoClient('mongodb://localhost:27017');
|
||||
await client.connect();
|
||||
return client;
|
||||
},
|
||||
database: 'game',
|
||||
transactionCollection: 'transactions', // Transaction log collection
|
||||
dataCollection: 'transaction_data', // Business data collection
|
||||
lockCollection: 'transaction_locks', // Lock collection
|
||||
});
|
||||
|
||||
// Create indexes (run on first startup)
|
||||
await storage.ensureIndexes();
|
||||
|
||||
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 MongoStorage({ ... });
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
|
||||
- ✅ Persistent storage
|
||||
- ✅ Supports complex queries
|
||||
- ✅ Transaction logs are traceable
|
||||
- ✅ Suitable for audit requirements
|
||||
- ❌ Slightly lower performance than Redis
|
||||
- ❌ Requires MongoDB server
|
||||
|
||||
### Index Structure
|
||||
|
||||
```javascript
|
||||
// transactions collection
|
||||
{ state: 1 }
|
||||
{ 'metadata.serverId': 1 }
|
||||
{ createdAt: 1 }
|
||||
|
||||
// transaction_locks collection
|
||||
{ expireAt: 1 } // TTL index
|
||||
|
||||
// transaction_data collection
|
||||
{ expireAt: 1 } // TTL index
|
||||
```
|
||||
|
||||
### Distributed Lock Implementation
|
||||
|
||||
Uses MongoDB unique index for distributed locking:
|
||||
|
||||
```typescript
|
||||
// Acquire lock
|
||||
db.transaction_locks.insertOne({
|
||||
_id: 'player:123',
|
||||
token: '<token>',
|
||||
expireAt: new Date(Date.now() + 10000)
|
||||
});
|
||||
|
||||
// If key exists, check if expired
|
||||
db.transaction_locks.updateOne(
|
||||
{ _id: 'player:123', expireAt: { $lt: new Date() } },
|
||||
{ $set: { token: '<token>', expireAt: new Date(Date.now() + 10000) } }
|
||||
);
|
||||
```
|
||||
|
||||
## Storage Selection Guide
|
||||
|
||||
| Scenario | Recommended Storage | Reason |
|
||||
|----------|---------------------|--------|
|
||||
| Development/Testing | MemoryStorage | No dependencies, fast startup |
|
||||
| Single-machine Production | RedisStorage | High performance, simple |
|
||||
| Distributed System | RedisStorage | True distributed locking |
|
||||
| Audit Required | MongoStorage | Persistent logs |
|
||||
| Mixed Requirements | Redis + Mongo | Redis for locks, Mongo for logs |
|
||||
|
||||
## Custom Storage
|
||||
|
||||
Implement `ITransactionStorage` interface to create custom storage:
|
||||
|
||||
```typescript
|
||||
import { ITransactionStorage, TransactionLog, TransactionState } from '@esengine/transaction';
|
||||
|
||||
class MyCustomStorage implements ITransactionStorage {
|
||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||
// Implement distributed lock acquisition
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
// Implement distributed lock release
|
||||
}
|
||||
|
||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||
// Save transaction log
|
||||
}
|
||||
|
||||
// ... implement other methods
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: "Chunk Manager API"
|
||||
description: "ChunkManager handles chunk lifecycle, loading queue, and spatial queries"
|
||||
---
|
||||
|
||||
The `ChunkManager` is the core service responsible for managing chunk lifecycle, including loading, unloading, and spatial queries.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
// Create manager with 512-unit chunks
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// Set data provider for loading chunks
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// Set event callbacks
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
console.log(`Chunk (${coord.x}, ${coord.y}) loaded with ${entities.length} entities`);
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
console.log(`Chunk (${coord.x}, ${coord.y}) unloaded`);
|
||||
},
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`Failed to load chunk (${coord.x}, ${coord.y}):`, error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Loading and Unloading
|
||||
|
||||
### Request Loading
|
||||
|
||||
```typescript
|
||||
import { EChunkPriority } from '@esengine/world-streaming';
|
||||
|
||||
// Request with priority
|
||||
chunkManager.requestLoad({ x: 0, y: 0 }, EChunkPriority.Immediate);
|
||||
chunkManager.requestLoad({ x: 1, y: 0 }, EChunkPriority.High);
|
||||
chunkManager.requestLoad({ x: 2, y: 0 }, EChunkPriority.Normal);
|
||||
chunkManager.requestLoad({ x: 3, y: 0 }, EChunkPriority.Low);
|
||||
chunkManager.requestLoad({ x: 4, y: 0 }, EChunkPriority.Prefetch);
|
||||
```
|
||||
|
||||
### Priority Levels
|
||||
|
||||
| Priority | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `Immediate` | 0 | Current chunk (player standing on) |
|
||||
| `High` | 1 | Adjacent chunks |
|
||||
| `Normal` | 2 | Nearby chunks |
|
||||
| `Low` | 3 | Distant visible chunks |
|
||||
| `Prefetch` | 4 | Movement direction prefetch |
|
||||
|
||||
### Request Unloading
|
||||
|
||||
```typescript
|
||||
// Request unload with 3 second delay
|
||||
chunkManager.requestUnload({ x: 5, y: 5 }, 3000);
|
||||
|
||||
// Cancel pending unload (player moved back)
|
||||
chunkManager.cancelUnload({ x: 5, y: 5 });
|
||||
```
|
||||
|
||||
### Process Queues
|
||||
|
||||
```typescript
|
||||
// In your update loop or system
|
||||
await chunkManager.processLoads(2); // Load up to 2 chunks per frame
|
||||
chunkManager.processUnloads(1); // Unload up to 1 chunk per frame
|
||||
```
|
||||
|
||||
## Spatial Queries
|
||||
|
||||
### Coordinate Conversion
|
||||
|
||||
```typescript
|
||||
// World position to chunk coordinates
|
||||
const coord = chunkManager.worldToChunk(1500, 2300);
|
||||
// Result: { x: 2, y: 4 } for 512-unit chunks
|
||||
|
||||
// Get chunk bounds in world space
|
||||
const bounds = chunkManager.getChunkBounds({ x: 2, y: 4 });
|
||||
// Result: { minX: 1024, minY: 2048, maxX: 1536, maxY: 2560 }
|
||||
```
|
||||
|
||||
### Chunk Queries
|
||||
|
||||
```typescript
|
||||
// Check if chunk is loaded
|
||||
if (chunkManager.isChunkLoaded({ x: 0, y: 0 })) {
|
||||
const chunk = chunkManager.getChunk({ x: 0, y: 0 });
|
||||
console.log('Entities:', chunk.entities.length);
|
||||
}
|
||||
|
||||
// Get missing chunks in radius
|
||||
const missing = chunkManager.getMissingChunks({ x: 0, y: 0 }, 2);
|
||||
for (const coord of missing) {
|
||||
chunkManager.requestLoad(coord);
|
||||
}
|
||||
|
||||
// Get chunks outside radius (for unloading)
|
||||
const outside = chunkManager.getChunksOutsideRadius({ x: 0, y: 0 }, 4);
|
||||
for (const coord of outside) {
|
||||
chunkManager.requestUnload(coord, 3000);
|
||||
}
|
||||
|
||||
// Iterate all loaded chunks
|
||||
chunkManager.forEachChunk((info, coord) => {
|
||||
console.log(`Chunk (${coord.x}, ${coord.y}): ${info.state}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Statistics
|
||||
|
||||
```typescript
|
||||
console.log('Loaded chunks:', chunkManager.loadedChunkCount);
|
||||
console.log('Pending loads:', chunkManager.pendingLoadCount);
|
||||
console.log('Pending unloads:', chunkManager.pendingUnloadCount);
|
||||
console.log('Chunk size:', chunkManager.chunkSize);
|
||||
```
|
||||
|
||||
## Chunk States
|
||||
|
||||
```typescript
|
||||
import { EChunkState } from '@esengine/world-streaming';
|
||||
|
||||
// Chunk lifecycle states
|
||||
EChunkState.Unloaded // Not in memory
|
||||
EChunkState.Loading // Being loaded
|
||||
EChunkState.Loaded // Ready for use
|
||||
EChunkState.Unloading // Being removed
|
||||
EChunkState.Failed // Load failed
|
||||
```
|
||||
|
||||
## Data Provider Interface
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class MyChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// Load from database, file, or procedural generation
|
||||
const data = await fetchChunkFromServer(coord);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// Save modified chunks
|
||||
await saveChunkToServer(data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```typescript
|
||||
// Unload all chunks
|
||||
chunkManager.clear();
|
||||
|
||||
// Full disposal (implements IService)
|
||||
chunkManager.dispose();
|
||||
```
|
||||
330
docs/src/content/docs/en/modules/world-streaming/examples.md
Normal file
330
docs/src/content/docs/en/modules/world-streaming/examples.md
Normal file
@@ -0,0 +1,330 @@
|
||||
---
|
||||
title: "Examples"
|
||||
description: "Practical examples of world streaming"
|
||||
---
|
||||
|
||||
## Infinite Procedural World
|
||||
|
||||
An infinite world with procedural resource generation.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
// Procedural world generator
|
||||
class WorldGenerator implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
private nextEntityId = 1;
|
||||
|
||||
constructor(seed: number = 12345) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const rng = this.createChunkRNG(coord);
|
||||
const entities = [];
|
||||
|
||||
// Generate 5-15 resources per chunk
|
||||
const resourceCount = 5 + Math.floor(rng() * 10);
|
||||
|
||||
for (let i = 0; i < resourceCount; i++) {
|
||||
const type = this.randomResourceType(rng);
|
||||
|
||||
entities.push({
|
||||
name: `Resource_${this.nextEntityId++}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
ResourceNode: {
|
||||
type,
|
||||
amount: this.getResourceAmount(type, rng),
|
||||
regenRate: this.getRegenRate(type)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { coord, entities, version: 1 };
|
||||
}
|
||||
|
||||
async saveChunkData(_data: IChunkData): Promise<void> {
|
||||
// Procedural - no persistence needed
|
||||
}
|
||||
|
||||
private createChunkRNG(coord: IChunkCoord) {
|
||||
let seed = this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private randomResourceType(rng: () => number) {
|
||||
const types = ['energyWell', 'oreVein', 'crystalDeposit'];
|
||||
const weights = [0.5, 0.35, 0.15];
|
||||
|
||||
let random = rng();
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
random -= weights[i];
|
||||
if (random <= 0) return types[i];
|
||||
}
|
||||
return types[0];
|
||||
}
|
||||
|
||||
private getResourceAmount(type: string, rng: () => number) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 300 + Math.floor(rng() * 200);
|
||||
case 'oreVein': return 500 + Math.floor(rng() * 300);
|
||||
case 'crystalDeposit': return 100 + Math.floor(rng() * 100);
|
||||
default: return 100;
|
||||
}
|
||||
}
|
||||
|
||||
private getRegenRate(type: string) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 2;
|
||||
case 'oreVein': return 1;
|
||||
case 'crystalDeposit': return 0.2;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(new WorldGenerator(12345));
|
||||
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
```
|
||||
|
||||
## MMO Server Chunks
|
||||
|
||||
Server-side chunk management for MMO with database persistence.
|
||||
|
||||
```typescript
|
||||
class ServerChunkProvider implements IChunkDataProvider {
|
||||
private db: Database;
|
||||
private cache = new Map<string, IChunkData>();
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
|
||||
// Check cache
|
||||
if (this.cache.has(key)) {
|
||||
return this.cache.get(key)!;
|
||||
}
|
||||
|
||||
// Load from database
|
||||
const row = await this.db.query(
|
||||
'SELECT data FROM chunks WHERE x = ? AND y = ?',
|
||||
[coord.x, coord.y]
|
||||
);
|
||||
|
||||
if (row) {
|
||||
const data = JSON.parse(row.data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Generate new chunk
|
||||
const data = this.generateChunk(coord);
|
||||
await this.saveChunkData(data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `${data.coord.x},${data.coord.y}`;
|
||||
this.cache.set(key, data);
|
||||
|
||||
await this.db.query(
|
||||
`INSERT INTO chunks (x, y, data) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE data = VALUES(data)`,
|
||||
[data.coord.x, data.coord.y, JSON.stringify(data)]
|
||||
);
|
||||
}
|
||||
|
||||
private generateChunk(coord: IChunkCoord): IChunkData {
|
||||
// Procedural generation for new chunks
|
||||
return { coord, entities: [], version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// Per-player chunk loading on server
|
||||
class PlayerChunkManager {
|
||||
private chunkManager: ChunkManager;
|
||||
private playerChunks = new Map<string, Set<string>>();
|
||||
|
||||
async updatePlayerPosition(playerId: string, x: number, y: number) {
|
||||
const centerCoord = this.chunkManager.worldToChunk(x, y);
|
||||
const loadRadius = 2;
|
||||
|
||||
const newChunks = new Set<string>();
|
||||
|
||||
// Load chunks around player
|
||||
for (let dx = -loadRadius; dx <= loadRadius; dx++) {
|
||||
for (let dy = -loadRadius; dy <= loadRadius; dy++) {
|
||||
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
newChunks.add(key);
|
||||
|
||||
if (!this.chunkManager.isChunkLoaded(coord)) {
|
||||
await this.chunkManager.requestLoad(coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track player's loaded chunks
|
||||
this.playerChunks.set(playerId, newChunks);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tile-Based World
|
||||
|
||||
Tilemap integration with chunk streaming.
|
||||
|
||||
```typescript
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
|
||||
class TilemapChunkProvider implements IChunkDataProvider {
|
||||
private tilemapData: number[][]; // Full tilemap
|
||||
private tileSize = 32;
|
||||
private chunkTiles = 16; // 16x16 tiles per chunk
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const startTileX = coord.x * this.chunkTiles;
|
||||
const startTileY = coord.y * this.chunkTiles;
|
||||
|
||||
// Extract tiles for this chunk
|
||||
const tiles: number[][] = [];
|
||||
for (let y = 0; y < this.chunkTiles; y++) {
|
||||
const row: number[] = [];
|
||||
for (let x = 0; x < this.chunkTiles; x++) {
|
||||
const tileX = startTileX + x;
|
||||
const tileY = startTileY + y;
|
||||
row.push(this.getTile(tileX, tileY));
|
||||
}
|
||||
tiles.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities: [{
|
||||
name: `TileChunk_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 0, y: 0 },
|
||||
components: {
|
||||
TilemapChunk: { tiles }
|
||||
}
|
||||
}],
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private getTile(x: number, y: number): number {
|
||||
if (x < 0 || y < 0 || y >= this.tilemapData.length) {
|
||||
return 0; // Out of bounds = empty
|
||||
}
|
||||
return this.tilemapData[y]?.[x] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom serializer for tilemap
|
||||
class TilemapSerializer extends ChunkSerializer {
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.TilemapChunk) {
|
||||
const data = components.TilemapChunk as { tiles: number[][] };
|
||||
const tilemap = entity.addComponent(new TilemapComponent());
|
||||
tilemap.loadTiles(data.tiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Loading Events
|
||||
|
||||
React to chunk loading for gameplay.
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// Enable physics
|
||||
for (const entity of entities) {
|
||||
const collider = entity.getComponent(ColliderComponent);
|
||||
collider?.enable();
|
||||
}
|
||||
|
||||
// Spawn NPCs for loaded chunks
|
||||
npcManager.spawnForChunk(coord);
|
||||
|
||||
// Update fog of war
|
||||
fogOfWar.revealChunk(coord);
|
||||
|
||||
// Notify clients (server)
|
||||
broadcast('ChunkLoaded', { coord, entityCount: entities.length });
|
||||
},
|
||||
|
||||
onChunkUnloaded: (coord) => {
|
||||
// Save NPC states
|
||||
npcManager.saveAndRemoveForChunk(coord);
|
||||
|
||||
// Update fog
|
||||
fogOfWar.hideChunk(coord);
|
||||
|
||||
// Notify clients
|
||||
broadcast('ChunkUnloaded', { coord });
|
||||
},
|
||||
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`Failed to load chunk ${coord.x},${coord.y}:`, error);
|
||||
|
||||
// Retry after delay
|
||||
setTimeout(() => {
|
||||
chunkManager.requestLoad(coord);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
```typescript
|
||||
// Adjust based on device performance
|
||||
function configureForDevice(loader: ChunkLoaderComponent) {
|
||||
const memory = navigator.deviceMemory ?? 4;
|
||||
const cores = navigator.hardwareConcurrency ?? 4;
|
||||
|
||||
if (memory <= 2 || cores <= 2) {
|
||||
// Low-end device
|
||||
loader.loadRadius = 1;
|
||||
loader.unloadRadius = 2;
|
||||
loader.maxLoadsPerFrame = 1;
|
||||
loader.bEnablePrefetch = false;
|
||||
} else if (memory <= 4) {
|
||||
// Mid-range
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 3;
|
||||
loader.maxLoadsPerFrame = 2;
|
||||
} else {
|
||||
// High-end
|
||||
loader.loadRadius = 3;
|
||||
loader.unloadRadius = 5;
|
||||
loader.maxLoadsPerFrame = 4;
|
||||
loader.prefetchRadius = 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
158
docs/src/content/docs/en/modules/world-streaming/index.md
Normal file
158
docs/src/content/docs/en/modules/world-streaming/index.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: "World Streaming"
|
||||
description: "Chunk-based world streaming for open world games"
|
||||
---
|
||||
|
||||
`@esengine/world-streaming` provides chunk-based world streaming and management for open world games. It handles dynamic loading/unloading of world chunks based on player position.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/world-streaming
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
StreamingAnchorComponent,
|
||||
ChunkLoaderComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// Create chunk manager (512 unit chunks)
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// Add streaming system
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// Create loader entity with config
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
|
||||
// Create player as streaming anchor
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// Update anchor position each frame
|
||||
function update() {
|
||||
anchor.x = player.position.x;
|
||||
anchor.y = player.position.y;
|
||||
}
|
||||
```
|
||||
|
||||
### Procedural Generation
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class ProceduralChunkProvider implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// Use deterministic random based on seed + coord
|
||||
const chunkSeed = this.hashCoord(coord);
|
||||
const rng = this.createRNG(chunkSeed);
|
||||
|
||||
// Generate chunk content
|
||||
const entities = this.generateEntities(coord, rng);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// Optional: persist modified chunks
|
||||
}
|
||||
|
||||
private hashCoord(coord: IChunkCoord): number {
|
||||
return this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
}
|
||||
|
||||
private createRNG(seed: number) {
|
||||
// Simple seeded random
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord, rng: () => number) {
|
||||
// Generate resources, trees, etc.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Use provider
|
||||
chunkManager.setDataProvider(new ProceduralChunkProvider(12345));
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Chunk Lifecycle
|
||||
|
||||
```
|
||||
Unloaded → Loading → Loaded → Unloading → Unloaded
|
||||
↓ ↓
|
||||
Failed (on error)
|
||||
```
|
||||
|
||||
### Streaming Anchor
|
||||
|
||||
`StreamingAnchorComponent` marks entities as chunk loading anchors. The system loads chunks around all anchors and unloads chunks outside the combined range.
|
||||
|
||||
```typescript
|
||||
// StreamingAnchorComponent implements IPositionable
|
||||
interface IPositionable {
|
||||
readonly position: { x: number; y: number };
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Property | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `chunkSize` | 512 | Chunk size in world units |
|
||||
| `loadRadius` | 2 | Chunks to load around anchor |
|
||||
| `unloadRadius` | 4 | Chunks to unload beyond this |
|
||||
| `maxLoadsPerFrame` | 2 | Max async loads per frame |
|
||||
| `unloadDelay` | 3000 | MS before unloading |
|
||||
| `bEnablePrefetch` | true | Prefetch in movement direction |
|
||||
|
||||
## Module Setup (Optional)
|
||||
|
||||
For quick setup, use the module helper:
|
||||
|
||||
```typescript
|
||||
import { worldStreamingModule } from '@esengine/world-streaming';
|
||||
|
||||
const chunkManager = worldStreamingModule.setup(
|
||||
scene,
|
||||
services,
|
||||
componentRegistry,
|
||||
{ chunkSize: 256, bEnableCulling: true }
|
||||
);
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Chunk Manager API](./chunk-manager) - Loading queue, chunk lifecycle
|
||||
- [Streaming System](./streaming-system) - Anchor-based loading
|
||||
- [Serialization](./serialization) - Custom chunk serialization
|
||||
- [Examples](./examples) - Procedural worlds, MMO chunks
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: "Chunk Serialization"
|
||||
description: "Custom serialization for chunk data"
|
||||
---
|
||||
|
||||
The `ChunkSerializer` handles converting between entity data and chunk storage format.
|
||||
|
||||
## Default Serializer
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer, ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
const serializer = new ChunkSerializer();
|
||||
const chunkManager = new ChunkManager(512, serializer);
|
||||
```
|
||||
|
||||
## Custom Serializer
|
||||
|
||||
Override `ChunkSerializer` for custom serialization logic:
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer } from '@esengine/world-streaming';
|
||||
import type { Entity, IScene } from '@esengine/ecs-framework';
|
||||
import type { IChunkCoord, IChunkData, IChunkBounds } from '@esengine/world-streaming';
|
||||
|
||||
class GameChunkSerializer extends ChunkSerializer {
|
||||
/**
|
||||
* Get position from entity
|
||||
* Override to use your position component
|
||||
*/
|
||||
protected getPositionable(entity: Entity) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
return { position: { x: transform.x, y: transform.y } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set position on entity after deserialization
|
||||
*/
|
||||
protected setEntityPosition(entity: Entity, x: number, y: number): void {
|
||||
const transform = entity.addComponent(new TransformComponent());
|
||||
transform.x = x;
|
||||
transform.y = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize components
|
||||
*/
|
||||
protected serializeComponents(entity: Entity): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
const resource = entity.getComponent(ResourceComponent);
|
||||
if (resource) {
|
||||
data.ResourceComponent = {
|
||||
type: resource.type,
|
||||
amount: resource.amount,
|
||||
maxAmount: resource.maxAmount
|
||||
};
|
||||
}
|
||||
|
||||
const npc = entity.getComponent(NPCComponent);
|
||||
if (npc) {
|
||||
data.NPCComponent = {
|
||||
id: npc.id,
|
||||
state: npc.state
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize components
|
||||
*/
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.ResourceComponent) {
|
||||
const data = components.ResourceComponent as any;
|
||||
const resource = entity.addComponent(new ResourceComponent());
|
||||
resource.type = data.type;
|
||||
resource.amount = data.amount;
|
||||
resource.maxAmount = data.maxAmount;
|
||||
}
|
||||
|
||||
if (components.NPCComponent) {
|
||||
const data = components.NPCComponent as any;
|
||||
const npc = entity.addComponent(new NPCComponent());
|
||||
npc.id = data.id;
|
||||
npc.state = data.state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter which components to serialize
|
||||
*/
|
||||
protected shouldSerializeComponent(componentName: string): boolean {
|
||||
const include = ['ResourceComponent', 'NPCComponent', 'BuildingComponent'];
|
||||
return include.includes(componentName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Chunk Data Format
|
||||
|
||||
```typescript
|
||||
interface IChunkData {
|
||||
coord: IChunkCoord; // Chunk coordinates
|
||||
entities: ISerializedEntity[]; // Entity data
|
||||
version: number; // Data version
|
||||
}
|
||||
|
||||
interface ISerializedEntity {
|
||||
name: string; // Entity name
|
||||
localPosition: { x: number; y: number }; // Position within chunk
|
||||
components: Record<string, unknown>; // Component data
|
||||
}
|
||||
|
||||
interface IChunkCoord {
|
||||
x: number; // Chunk X coordinate
|
||||
y: number; // Chunk Y coordinate
|
||||
}
|
||||
```
|
||||
|
||||
## Data Provider with Serialization
|
||||
|
||||
```typescript
|
||||
class DatabaseChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `chunk_${coord.x}_${coord.y}`;
|
||||
const json = await database.get(key);
|
||||
|
||||
if (!json) return null;
|
||||
return JSON.parse(json) as IChunkData;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `chunk_${data.coord.x}_${data.coord.y}`;
|
||||
await database.set(key, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Procedural Generation with Serializer
|
||||
|
||||
```typescript
|
||||
class ProceduralProvider implements IChunkDataProvider {
|
||||
private serializer: GameChunkSerializer;
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const entities = this.generateEntities(coord);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord): ISerializedEntity[] {
|
||||
const entities: ISerializedEntity[] = [];
|
||||
const rng = this.createRNG(coord);
|
||||
|
||||
// Generate trees
|
||||
const treeCount = Math.floor(rng() * 10);
|
||||
for (let i = 0; i < treeCount; i++) {
|
||||
entities.push({
|
||||
name: `Tree_${coord.x}_${coord.y}_${i}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
TreeComponent: { type: 'oak', health: 100 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate resources
|
||||
if (rng() > 0.7) {
|
||||
entities.push({
|
||||
name: `Resource_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 256, y: 256 },
|
||||
components: {
|
||||
ResourceComponent: {
|
||||
type: 'iron',
|
||||
amount: 500,
|
||||
maxAmount: 500
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Version Migration
|
||||
|
||||
```typescript
|
||||
class VersionedSerializer extends ChunkSerializer {
|
||||
private static readonly CURRENT_VERSION = 2;
|
||||
|
||||
deserialize(data: IChunkData, scene: IScene): Entity[] {
|
||||
// Migrate old data
|
||||
if (data.version < 2) {
|
||||
data = this.migrateV1toV2(data);
|
||||
}
|
||||
|
||||
return super.deserialize(data, scene);
|
||||
}
|
||||
|
||||
private migrateV1toV2(data: IChunkData): IChunkData {
|
||||
// Convert old component format
|
||||
for (const entity of data.entities) {
|
||||
if (entity.components.OldResource) {
|
||||
entity.components.ResourceComponent = entity.components.OldResource;
|
||||
delete entity.components.OldResource;
|
||||
}
|
||||
}
|
||||
|
||||
data.version = 2;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: "Streaming System"
|
||||
description: "ChunkStreamingSystem manages automatic chunk loading based on anchor positions"
|
||||
---
|
||||
|
||||
The `ChunkStreamingSystem` automatically manages chunk loading and unloading based on `StreamingAnchorComponent` positions.
|
||||
|
||||
## Setup
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// Create and configure chunk manager
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// Create streaming system
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// Create loader entity with configuration
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
```
|
||||
|
||||
## Streaming Anchor
|
||||
|
||||
The `StreamingAnchorComponent` marks entities as chunk loading anchors. Chunks are loaded around all anchors.
|
||||
|
||||
```typescript
|
||||
// Create player as streaming anchor
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// Update position each frame
|
||||
function update() {
|
||||
anchor.x = player.worldX;
|
||||
anchor.y = player.worldY;
|
||||
}
|
||||
```
|
||||
|
||||
### Anchor Properties
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `x` | number | 0 | World X position |
|
||||
| `y` | number | 0 | World Y position |
|
||||
| `weight` | number | 1.0 | Load radius multiplier |
|
||||
| `bEnablePrefetch` | boolean | true | Enable prefetch for this anchor |
|
||||
|
||||
### Multiple Anchors
|
||||
|
||||
```typescript
|
||||
// Main player - full load radius
|
||||
const playerAnchor = player.addComponent(new StreamingAnchorComponent());
|
||||
playerAnchor.weight = 1.0;
|
||||
|
||||
// Camera preview - smaller radius
|
||||
const cameraAnchor = camera.addComponent(new StreamingAnchorComponent());
|
||||
cameraAnchor.weight = 0.5; // Half the load radius
|
||||
cameraAnchor.bEnablePrefetch = false;
|
||||
```
|
||||
|
||||
## Loader Configuration
|
||||
|
||||
The `ChunkLoaderComponent` configures streaming behavior.
|
||||
|
||||
```typescript
|
||||
const loader = entity.addComponent(new ChunkLoaderComponent());
|
||||
|
||||
// Chunk dimensions
|
||||
loader.chunkSize = 512; // World units per chunk
|
||||
|
||||
// Loading radius
|
||||
loader.loadRadius = 2; // Load chunks within 2 chunks of anchor
|
||||
loader.unloadRadius = 4; // Unload beyond 4 chunks
|
||||
|
||||
// Performance tuning
|
||||
loader.maxLoadsPerFrame = 2; // Max async loads per frame
|
||||
loader.maxUnloadsPerFrame = 1; // Max unloads per frame
|
||||
loader.unloadDelay = 3000; // MS before unloading
|
||||
|
||||
// Prefetch
|
||||
loader.bEnablePrefetch = true; // Enable movement-based prefetch
|
||||
loader.prefetchRadius = 1; // Extra chunks to prefetch
|
||||
```
|
||||
|
||||
### Coordinate Helpers
|
||||
|
||||
```typescript
|
||||
// Convert world position to chunk coordinates
|
||||
const coord = loader.worldToChunk(1500, 2300);
|
||||
|
||||
// Get chunk bounds
|
||||
const bounds = loader.getChunkBounds(coord);
|
||||
```
|
||||
|
||||
## Prefetch System
|
||||
|
||||
When enabled, the system prefetches chunks in the movement direction:
|
||||
|
||||
```
|
||||
Movement Direction →
|
||||
|
||||
[ ][ ][ ] [ ][P][P] P = Prefetch
|
||||
[L][L][L] → [L][L][L] L = Loaded
|
||||
[ ][ ][ ] [ ][ ][ ]
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Enable prefetch
|
||||
loader.bEnablePrefetch = true;
|
||||
loader.prefetchRadius = 2; // Prefetch 2 chunks ahead
|
||||
|
||||
// Per-anchor prefetch control
|
||||
anchor.bEnablePrefetch = true; // Enable for main player
|
||||
cameraAnchor.bEnablePrefetch = false; // Disable for camera
|
||||
```
|
||||
|
||||
## System Processing
|
||||
|
||||
The system runs each frame and:
|
||||
|
||||
1. Updates anchor velocities
|
||||
2. Requests loads for chunks in range
|
||||
3. Cancels unloads for chunks back in range
|
||||
4. Requests unloads for chunks outside range
|
||||
5. Processes load/unload queues
|
||||
|
||||
```typescript
|
||||
// Access the chunk manager from system
|
||||
const system = scene.getSystem(ChunkStreamingSystem);
|
||||
const manager = system?.chunkManager;
|
||||
|
||||
if (manager) {
|
||||
console.log('Loaded:', manager.loadedChunkCount);
|
||||
}
|
||||
```
|
||||
|
||||
## Priority-Based Loading
|
||||
|
||||
Chunks are loaded with priority based on distance:
|
||||
|
||||
| Distance | Priority | Description |
|
||||
|----------|----------|-------------|
|
||||
| 0 | Immediate | Player's current chunk |
|
||||
| 1 | High | Adjacent chunks |
|
||||
| 2-4 | Normal | Nearby chunks |
|
||||
| 5+ | Low | Distant chunks |
|
||||
| Prefetch | Prefetch | Movement direction |
|
||||
|
||||
## Events
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// Chunk ready - spawn NPCs, enable collision
|
||||
for (const entity of entities) {
|
||||
entity.getComponent(ColliderComponent)?.enable();
|
||||
}
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
// Cleanup - save state, release resources
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -28,12 +28,14 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
|
||||
|------|------|------|
|
||||
| [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 |
|
||||
| [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 |
|
||||
| [世界流式加载](/modules/world-streaming/) | `@esengine/world-streaming` | 开放世界区块流式加载 |
|
||||
|
||||
### 网络模块
|
||||
|
||||
| 模块 | 包名 | 描述 |
|
||||
|------|------|------|
|
||||
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
|
||||
| [事务系统](/modules/transaction/) | `@esengine/transaction` | 游戏事务处理,支持分布式事务 |
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
283
docs/src/content/docs/modules/network/aoi.md
Normal file
283
docs/src/content/docs/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: "兴趣区域管理 (AOI)"
|
||||
description: "基于视野范围的网络实体过滤"
|
||||
---
|
||||
|
||||
AOI(Area of Interest,兴趣区域)是大规模多人游戏中用于优化网络带宽的关键技术。通过只同步玩家视野范围内的实体,可以大幅减少网络流量。
|
||||
|
||||
## NetworkAOISystem
|
||||
|
||||
`NetworkAOISystem` 提供基于网格的兴趣区域管理。
|
||||
|
||||
### 启用 AOI
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enableAOI: true,
|
||||
aoiConfig: {
|
||||
cellSize: 100, // 网格单元大小
|
||||
defaultViewRange: 500, // 默认视野范围
|
||||
enabled: true,
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### 添加观察者
|
||||
|
||||
每个需要接收同步数据的玩家都需要作为观察者添加:
|
||||
|
||||
```typescript
|
||||
// 玩家加入时添加观察者
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
// ... 设置组件
|
||||
|
||||
// 将玩家添加为 AOI 观察者
|
||||
networkPlugin.addAOIObserver(
|
||||
spawn.netId, // 网络 ID
|
||||
spawn.pos.x, // 初始 X 位置
|
||||
spawn.pos.y, // 初始 Y 位置
|
||||
600 // 视野范围(可选)
|
||||
);
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// 玩家离开时移除观察者
|
||||
networkPlugin.removeAOIObserver(playerNetId);
|
||||
```
|
||||
|
||||
### 更新观察者位置
|
||||
|
||||
当玩家移动时,需要更新其 AOI 位置:
|
||||
|
||||
```typescript
|
||||
// 在游戏循环或同步回调中更新
|
||||
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||
```
|
||||
|
||||
## AOI 配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `cellSize` | `number` | 100 | 网格单元大小 |
|
||||
| `defaultViewRange` | `number` | 500 | 默认视野范围 |
|
||||
| `enabled` | `boolean` | true | 是否启用 AOI |
|
||||
|
||||
### 网格大小建议
|
||||
|
||||
网格大小应根据游戏视野范围设置:
|
||||
|
||||
```typescript
|
||||
// 建议:cellSize = defaultViewRange / 3 到 / 5
|
||||
aoiConfig: {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500, // 网格大约是视野的 1/5
|
||||
}
|
||||
```
|
||||
|
||||
## 查询接口
|
||||
|
||||
### 获取可见实体
|
||||
|
||||
```typescript
|
||||
// 获取玩家能看到的所有实体
|
||||
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||
console.log('Visible entities:', visibleEntities);
|
||||
```
|
||||
|
||||
### 检查可见性
|
||||
|
||||
```typescript
|
||||
// 检查玩家是否能看到某个实体
|
||||
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||
// 目标在视野内
|
||||
}
|
||||
```
|
||||
|
||||
## 事件监听
|
||||
|
||||
AOI 系统会在实体进入/离开视野时触发事件:
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||
// 可以在这里发送实体的初始状态
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||
// 可以在这里清理资源
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 服务器端过滤
|
||||
|
||||
AOI 最常用于服务器端,过滤发送给每个客户端的同步数据:
|
||||
|
||||
```typescript
|
||||
// 服务器端示例
|
||||
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private aoiSystem = createNetworkAOISystem({
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
});
|
||||
|
||||
// 玩家加入
|
||||
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.addObserver(playerId, x, y);
|
||||
}
|
||||
|
||||
// 玩家移动
|
||||
onPlayerMove(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||
}
|
||||
|
||||
// 发送同步数据
|
||||
broadcastSync(allEntities: EntitySyncState[]) {
|
||||
for (const playerId of this.players) {
|
||||
// 使用 AOI 过滤
|
||||
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||
playerId,
|
||||
allEntities
|
||||
);
|
||||
|
||||
// 只发送可见实体
|
||||
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 游戏世界 │
|
||||
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||
│ │ │ │ E │ │ │ │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ E = 敌人实体 │
|
||||
│ │ │ P │ ● │ │ │ P = 玩家 │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ ● = 玩家视野中心 │
|
||||
│ │ │ │ E │ E │ │ ○ = 视野范围 │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||
│ │ │ │ │ │ E │ 玩家只能看到视野内的 E │
|
||||
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||
│ │
|
||||
│ 视野范围(圆形):包含 3 个敌人 │
|
||||
│ 网格优化:只检查视野覆盖的网格单元 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 网格优化
|
||||
|
||||
AOI 使用空间网格加速查询:
|
||||
|
||||
1. **添加实体**:根据位置计算所在网格
|
||||
2. **视野检测**:只检查视野范围覆盖的网格
|
||||
3. **移动更新**:跨网格时更新网格归属
|
||||
4. **事件触发**:检测进入/离开视野
|
||||
|
||||
## 动态视野范围
|
||||
|
||||
可以为不同类型的玩家设置不同的视野:
|
||||
|
||||
```typescript
|
||||
// 普通玩家
|
||||
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||
|
||||
// VIP 玩家(更大视野)
|
||||
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||
|
||||
// 运行时调整视野
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
if (aoiSystem) {
|
||||
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 服务器端使用
|
||||
|
||||
AOI 过滤应在服务器端进行,客户端不应信任自己的 AOI 判断:
|
||||
|
||||
```typescript
|
||||
// 服务器端过滤后再发送
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
sendToClient(playerId, filtered);
|
||||
```
|
||||
|
||||
### 2. 边界处理
|
||||
|
||||
在视野边缘添加缓冲区防止闪烁:
|
||||
|
||||
```typescript
|
||||
// 进入视野时立即添加
|
||||
// 离开视野时延迟移除(保持额外 1-2 秒)
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'exit') {
|
||||
setTimeout(() => {
|
||||
// 再次检查是否真的离开
|
||||
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||
removeFromClient(event.observerNetId, event.targetNetId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 大型实体
|
||||
|
||||
对于大型实体(如 Boss),可能需要特殊处理:
|
||||
|
||||
```typescript
|
||||
// Boss 总是对所有人可见
|
||||
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
|
||||
// 添加 Boss 实体
|
||||
const bossState = entities.find(e => e.netId === bossNetId);
|
||||
if (bossState && !filtered.includes(bossState)) {
|
||||
filtered.push(bossState);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 性能考虑
|
||||
|
||||
```typescript
|
||||
// 大规模游戏建议配置
|
||||
aoiConfig: {
|
||||
cellSize: 200, // 较大的网格减少网格数量
|
||||
defaultViewRange: 800, // 根据实际视野设置
|
||||
}
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
console.log('AOI enabled:', aoiSystem.enabled);
|
||||
console.log('Observer count:', aoiSystem.observerCount);
|
||||
|
||||
// 获取特定玩家的可见实体
|
||||
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||
console.log('Visible entities:', visible.length);
|
||||
}
|
||||
```
|
||||
506
docs/src/content/docs/modules/network/auth.md
Normal file
506
docs/src/content/docs/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
||||
---
|
||||
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)
|
||||
```
|
||||
|
||||
### Session 提供者
|
||||
|
||||
使用服务端会话实现有状态认证:
|
||||
|
||||
```typescript
|
||||
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||
|
||||
// 自定义存储实现
|
||||
const storage: ISessionStorage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return await redis.get(key)
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await redis.set(key, value)
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return await redis.del(key) > 0
|
||||
},
|
||||
}
|
||||
|
||||
const sessionProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 86400000, // 24 小时(毫秒)
|
||||
|
||||
// 可选:每次请求时验证用户
|
||||
validateUser: (user) => !user.banned,
|
||||
})
|
||||
|
||||
// 创建会话(用于登录接口)
|
||||
const sessionId = await sessionProvider.createSession(user, {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
})
|
||||
|
||||
// 撤销会话(用于登出)
|
||||
await sessionProvider.revoke(sessionId)
|
||||
```
|
||||
|
||||
## 服务器认证 Mixin
|
||||
|
||||
`withAuth` 函数用于包装服务器添加认证功能:
|
||||
|
||||
```typescript
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
const server = withAuth(baseServer, {
|
||||
// 必填:认证提供者
|
||||
provider: jwtProvider,
|
||||
|
||||
// 必填:从请求中提取凭证
|
||||
extractCredentials: (req) => {
|
||||
// 从查询字符串获取
|
||||
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||
|
||||
// 或从请求头获取
|
||||
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||
},
|
||||
|
||||
// 可选:处理认证失败
|
||||
onAuthFailed: (conn, error) => {
|
||||
console.log(`认证失败: ${error}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 访问认证上下文
|
||||
|
||||
认证后,可以从连接获取认证上下文:
|
||||
|
||||
```typescript
|
||||
import { getAuthContext } from '@esengine/server/auth'
|
||||
|
||||
server.onConnect = (conn) => {
|
||||
const auth = getAuthContext(conn)
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
console.log(`用户 ${auth.userId} 已连接`)
|
||||
console.log(`角色: ${auth.roles}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 房间认证 Mixin
|
||||
|
||||
`withRoomAuth` 函数为房间添加认证检查:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
// 要求认证才能加入
|
||||
requireAuth: true,
|
||||
|
||||
// 可选:要求特定角色
|
||||
allowedRoles: ['player', 'premium'],
|
||||
|
||||
// 可选:角色检查模式('any' 或 'all')
|
||||
roleCheckMode: 'any',
|
||||
}) {
|
||||
// player 拥有 .auth 和 .user 属性
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} 加入了`)
|
||||
console.log(`是否高级会员: ${player.auth.hasRole('premium')}`)
|
||||
}
|
||||
|
||||
// 可选:自定义认证验证
|
||||
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||
// 额外的验证逻辑
|
||||
if (player.auth.hasRole('banned')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name ?? '访客',
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPlayer 接口
|
||||
|
||||
认证房间中的玩家拥有额外属性:
|
||||
|
||||
```typescript
|
||||
interface AuthPlayer<TUser> extends Player {
|
||||
// 完整认证上下文
|
||||
readonly auth: IAuthContext<TUser>
|
||||
|
||||
// 用户信息(auth.user 的快捷方式)
|
||||
readonly user: TUser | null
|
||||
}
|
||||
```
|
||||
|
||||
### 房间认证辅助方法
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRoomAuth<User>(Room) {
|
||||
someMethod() {
|
||||
// 通过用户 ID 获取玩家
|
||||
const player = this.getPlayerByUserId('user-123')
|
||||
|
||||
// 获取拥有特定角色的所有玩家
|
||||
const admins = this.getPlayersByRole('admin')
|
||||
|
||||
// 获取带认证信息的玩家
|
||||
const authPlayer = this.getAuthPlayer(playerId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证装饰器
|
||||
|
||||
### @requireAuth
|
||||
|
||||
标记消息处理器需要认证:
|
||||
|
||||
```typescript
|
||||
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||
|
||||
class GameRoom extends withRoomAuth(Room) {
|
||||
@requireAuth()
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||
// 只有已认证玩家才能交易
|
||||
}
|
||||
|
||||
@requireAuth({ allowGuest: true })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: ChatData, player: AuthPlayer) {
|
||||
// 访客也可以聊天
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @requireRole
|
||||
|
||||
要求特定角色才能访问消息处理器:
|
||||
|
||||
```typescript
|
||||
class AdminRoom extends withRoomAuth(Room) {
|
||||
@requireRole('admin')
|
||||
@onMessage('Ban')
|
||||
handleBan(data: BanData, player: AuthPlayer) {
|
||||
// 只有管理员才能封禁
|
||||
}
|
||||
|
||||
@requireRole(['moderator', 'admin'])
|
||||
@onMessage('Mute')
|
||||
handleMute(data: MuteData, player: AuthPlayer) {
|
||||
// 版主或管理员可以禁言
|
||||
}
|
||||
|
||||
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
@onMessage('SpecialFeature')
|
||||
handleSpecial(data: any, player: AuthPlayer) {
|
||||
// 需要同时拥有 verified 和 premium 角色
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证上下文 API
|
||||
|
||||
认证上下文提供多种检查认证状态的方法:
|
||||
|
||||
```typescript
|
||||
interface IAuthContext<TUser> {
|
||||
// 认证状态
|
||||
readonly isAuthenticated: boolean
|
||||
readonly user: TUser | null
|
||||
readonly userId: string | null
|
||||
readonly roles: ReadonlyArray<string>
|
||||
readonly authenticatedAt: number | null
|
||||
readonly expiresAt: number | null
|
||||
|
||||
// 角色检查
|
||||
hasRole(role: string): boolean
|
||||
hasAnyRole(roles: string[]): boolean
|
||||
hasAllRoles(roles: string[]): boolean
|
||||
}
|
||||
```
|
||||
|
||||
`AuthContext` 类(实现类)还提供:
|
||||
|
||||
```typescript
|
||||
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||
// 从认证结果设置认证状态
|
||||
setAuthenticated(result: AuthResult<TUser>): void
|
||||
|
||||
// 清除认证状态
|
||||
clear(): void
|
||||
}
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
使用模拟认证提供者进行单元测试:
|
||||
|
||||
```typescript
|
||||
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||
|
||||
// 创建带预设用户的模拟提供者
|
||||
const mockProvider = createMockAuthProvider({
|
||||
users: [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
],
|
||||
autoCreate: true, // 为未知令牌创建用户
|
||||
})
|
||||
|
||||
// 在测试中使用
|
||||
const server = withAuth(testServer, {
|
||||
provider: mockProvider,
|
||||
extractCredentials: (req) => req.headers['x-token'],
|
||||
})
|
||||
|
||||
// 使用用户 ID 作为令牌进行验证
|
||||
const result = await mockProvider.verify('1')
|
||||
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
|
||||
// 动态添加/移除用户
|
||||
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||
mockProvider.removeUser('3')
|
||||
|
||||
// 撤销令牌
|
||||
await mockProvider.revoke('1')
|
||||
|
||||
// 重置到初始状态
|
||||
mockProvider.clear()
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
认证错误包含错误码用于程序化处理:
|
||||
|
||||
```typescript
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS' // 用户名/密码无效
|
||||
| 'INVALID_TOKEN' // 令牌格式错误或无效
|
||||
| 'EXPIRED_TOKEN' // 令牌已过期
|
||||
| 'USER_NOT_FOUND' // 用户查找失败
|
||||
| 'ACCOUNT_DISABLED' // 用户账号已禁用
|
||||
| 'RATE_LIMITED' // 请求过于频繁
|
||||
| 'INSUFFICIENT_PERMISSIONS' // 权限不足
|
||||
|
||||
// 在认证失败处理器中
|
||||
const server = withAuth(baseServer, {
|
||||
provider: jwtProvider,
|
||||
extractCredentials,
|
||||
onAuthFailed: (conn, error) => {
|
||||
switch (error.errorCode) {
|
||||
case 'EXPIRED_TOKEN':
|
||||
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||
break
|
||||
case 'INVALID_TOKEN':
|
||||
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||
break
|
||||
default:
|
||||
conn.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
以下是使用 JWT 认证的完整示例:
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
import {
|
||||
withAuth,
|
||||
withRoomAuth,
|
||||
createJwtAuthProvider,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
type AuthPlayer,
|
||||
} from '@esengine/server/auth'
|
||||
|
||||
// 类型定义
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// JWT 提供者
|
||||
const jwtProvider = createJwtAuthProvider<User>({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) ?? [],
|
||||
}),
|
||||
})
|
||||
|
||||
// 创建带认证的服务器
|
||||
const server = withAuth(
|
||||
await createServer({ port: 3000 }),
|
||||
{
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
return new URL(req.url ?? '', 'http://localhost')
|
||||
.searchParams.get('token')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// 带认证的游戏房间
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['player'],
|
||||
}) {
|
||||
onCreate() {
|
||||
console.log('游戏房间已创建')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} 加入了!`)
|
||||
this.broadcast('PlayerJoined', {
|
||||
id: player.id,
|
||||
name: player.user?.name,
|
||||
})
|
||||
}
|
||||
|
||||
@requireAuth()
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
// 处理移动
|
||||
}
|
||||
|
||||
@requireRole('admin')
|
||||
@onMessage('Kick')
|
||||
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
const target = this.getPlayer(data.playerId)
|
||||
if (target) {
|
||||
this.kick(target, '被管理员踢出')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **保护密钥安全**:永远不要硬编码 JWT 密钥,使用环境变量。
|
||||
|
||||
2. **设置合理的过期时间**:在安全性和用户体验之间平衡令牌 TTL。
|
||||
|
||||
3. **在关键操作上验证**:在敏感消息处理器上使用 `@requireAuth`。
|
||||
|
||||
4. **使用基于角色的访问控制**:为管理功能实现适当的角色层级。
|
||||
|
||||
5. **处理令牌刷新**:为长会话实现令牌刷新逻辑。
|
||||
|
||||
6. **记录认证事件**:跟踪登录尝试和失败以进行安全监控。
|
||||
|
||||
7. **测试认证流程**:使用 `MockAuthProvider` 测试认证场景。
|
||||
316
docs/src/content/docs/modules/network/delta.md
Normal file
316
docs/src/content/docs/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: "状态增量压缩"
|
||||
description: "减少网络带宽的增量同步"
|
||||
---
|
||||
|
||||
状态增量压缩通过只发送变化的字段来减少网络带宽。对于频繁同步的游戏状态,这可以显著降低数据传输量。
|
||||
|
||||
## StateDeltaCompressor
|
||||
|
||||
`StateDeltaCompressor` 类用于压缩和解压状态增量。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||
|
||||
// 创建压缩器
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.01, // 位置变化阈值
|
||||
rotationThreshold: 0.001, // 旋转变化阈值(弧度)
|
||||
velocityThreshold: 0.1, // 速度变化阈值
|
||||
fullSnapshotInterval: 60, // 完整快照间隔(帧数)
|
||||
});
|
||||
|
||||
// 压缩同步数据
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||
],
|
||||
};
|
||||
|
||||
const deltaData = compressor.compress(syncData);
|
||||
// deltaData 只包含变化的字段
|
||||
|
||||
// 解压增量数据
|
||||
const fullData = compressor.decompress(deltaData);
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `positionThreshold` | `number` | 0.01 | 位置变化阈值 |
|
||||
| `rotationThreshold` | `number` | 0.001 | 旋转变化阈值(弧度) |
|
||||
| `velocityThreshold` | `number` | 0.1 | 速度变化阈值 |
|
||||
| `fullSnapshotInterval` | `number` | 60 | 完整快照间隔(帧数) |
|
||||
|
||||
## 增量标志
|
||||
|
||||
使用位标志表示哪些字段发生了变化:
|
||||
|
||||
```typescript
|
||||
import { DeltaFlags } from '@esengine/network';
|
||||
|
||||
// 位标志定义
|
||||
DeltaFlags.NONE // 0 - 无变化
|
||||
DeltaFlags.POSITION // 1 - 位置变化
|
||||
DeltaFlags.ROTATION // 2 - 旋转变化
|
||||
DeltaFlags.VELOCITY // 4 - 速度变化
|
||||
DeltaFlags.ANGULAR_VELOCITY // 8 - 角速度变化
|
||||
DeltaFlags.CUSTOM // 16 - 自定义数据变化
|
||||
```
|
||||
|
||||
## 数据格式
|
||||
|
||||
### 完整状态
|
||||
|
||||
```typescript
|
||||
interface EntitySyncState {
|
||||
netId: number;
|
||||
pos?: { x: number; y: number };
|
||||
rot?: number;
|
||||
vel?: { x: number; y: number };
|
||||
angVel?: number;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### 增量状态
|
||||
|
||||
```typescript
|
||||
interface EntityDeltaState {
|
||||
netId: number;
|
||||
flags: number; // 变化标志位
|
||||
pos?: { x: number; y: number }; // 仅在 POSITION 标志时存在
|
||||
rot?: number; // 仅在 ROTATION 标志时存在
|
||||
vel?: { x: number; y: number }; // 仅在 VELOCITY 标志时存在
|
||||
angVel?: number; // 仅在 ANGULAR_VELOCITY 标志时存在
|
||||
custom?: Record<string, unknown>; // 仅在 CUSTOM 标志时存在
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
帧 1 (完整快照):
|
||||
Entity 1: pos=(100, 200), rot=0
|
||||
|
||||
帧 2 (增量):
|
||||
Entity 1: flags=POSITION, pos=(101, 200) // 只有 X 变化
|
||||
|
||||
帧 3 (增量):
|
||||
Entity 1: flags=0 // 无变化,不发送
|
||||
|
||||
帧 4 (增量):
|
||||
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||
|
||||
帧 60 (强制完整快照):
|
||||
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||
```
|
||||
|
||||
## 服务器端使用
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// 广播状态更新
|
||||
broadcastState(entities: EntitySyncState[]) {
|
||||
const syncData: SyncData = {
|
||||
frame: this.currentFrame,
|
||||
timestamp: Date.now(),
|
||||
entities,
|
||||
};
|
||||
|
||||
// 压缩数据
|
||||
const deltaData = this.compressor.compress(syncData);
|
||||
|
||||
// 发送增量数据
|
||||
this.broadcast('sync', deltaData);
|
||||
}
|
||||
|
||||
// 玩家离开时清理
|
||||
onPlayerLeave(netId: number) {
|
||||
this.compressor.removeEntity(netId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端使用
|
||||
|
||||
```typescript
|
||||
class GameClient {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// 接收增量数据
|
||||
onSyncReceived(deltaData: DeltaSyncData) {
|
||||
// 解压为完整状态
|
||||
const fullData = this.compressor.decompress(deltaData);
|
||||
|
||||
// 应用状态
|
||||
for (const entity of fullData.entities) {
|
||||
this.applyEntityState(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 带宽节省示例
|
||||
|
||||
假设每个实体有以下数据:
|
||||
|
||||
| 字段 | 大小(字节) |
|
||||
|------|------------|
|
||||
| netId | 4 |
|
||||
| pos.x | 8 |
|
||||
| pos.y | 8 |
|
||||
| rot | 8 |
|
||||
| vel.x | 8 |
|
||||
| vel.y | 8 |
|
||||
| angVel | 8 |
|
||||
| **总计** | **52** |
|
||||
|
||||
使用增量压缩:
|
||||
|
||||
| 场景 | 原始 | 压缩后 | 节省 |
|
||||
|------|------|--------|------|
|
||||
| 只有位置变化 | 52 | 4+1+16 = 21 | 60% |
|
||||
| 只有旋转变化 | 52 | 4+1+8 = 13 | 75% |
|
||||
| 静止不动 | 52 | 0 | 100% |
|
||||
| 位置+旋转变化 | 52 | 4+1+24 = 29 | 44% |
|
||||
|
||||
## 强制完整快照
|
||||
|
||||
某些情况下需要发送完整快照:
|
||||
|
||||
```typescript
|
||||
// 新玩家加入时
|
||||
compressor.forceFullSnapshot();
|
||||
const data = compressor.compress(syncData);
|
||||
// 这次会发送完整状态
|
||||
|
||||
// 重连时
|
||||
compressor.clear(); // 清除历史状态
|
||||
compressor.forceFullSnapshot();
|
||||
```
|
||||
|
||||
## 自定义数据
|
||||
|
||||
支持同步自定义游戏数据:
|
||||
|
||||
```typescript
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{
|
||||
netId: 1,
|
||||
pos: { x: 100, y: 200 },
|
||||
custom: {
|
||||
health: 80,
|
||||
mana: 50,
|
||||
buffs: ['speed', 'shield'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 自定义数据也会进行增量压缩
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 合理设置阈值
|
||||
|
||||
```typescript
|
||||
// 高精度游戏(如竞技游戏)
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.001,
|
||||
rotationThreshold: 0.0001,
|
||||
});
|
||||
|
||||
// 普通游戏
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.1,
|
||||
rotationThreshold: 0.01,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 调整完整快照间隔
|
||||
|
||||
```typescript
|
||||
// 高可靠性(网络不稳定)
|
||||
fullSnapshotInterval: 30, // 每 30 帧发送完整快照
|
||||
|
||||
// 低带宽优先
|
||||
fullSnapshotInterval: 120, // 每 120 帧发送完整快照
|
||||
```
|
||||
|
||||
### 3. 配合 AOI 使用
|
||||
|
||||
```typescript
|
||||
// 先用 AOI 过滤,再用增量压缩
|
||||
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
### 4. 处理实体移除
|
||||
|
||||
```typescript
|
||||
// 实体销毁时清理压缩器状态
|
||||
function onEntityDespawn(netId: number) {
|
||||
compressor.removeEntity(netId);
|
||||
}
|
||||
```
|
||||
|
||||
## 与其他功能配合
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 游戏状态 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ AOI 过滤 │ ← 只处理视野内实体
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ 增量压缩 │ ← 只发送变化的字段
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ 网络传输 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
const compressor = createStateDeltaCompressor();
|
||||
|
||||
// 检查压缩效果
|
||||
const original = syncData;
|
||||
const compressed = compressor.compress(original);
|
||||
|
||||
console.log('Original entities:', original.entities.length);
|
||||
console.log('Compressed entities:', compressed.entities.length);
|
||||
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||
|
||||
// 查看每个实体的变化
|
||||
for (const delta of compressed.entities) {
|
||||
console.log(`Entity ${delta.netId}:`, {
|
||||
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -147,7 +147,10 @@ service.on('chat', (data) => {
|
||||
|
||||
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
|
||||
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
|
||||
- [状态同步](/modules/network/sync/) - 插值、预测和快照
|
||||
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
|
||||
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
|
||||
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
|
||||
- [增量压缩](/modules/network/delta/) - 状态增量同步
|
||||
- [API 参考](/modules/network/api/) - 完整 API 文档
|
||||
|
||||
## 服务令牌
|
||||
@@ -159,10 +162,14 @@ import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from '@esengine/network';
|
||||
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
254
docs/src/content/docs/modules/network/prediction.md
Normal file
254
docs/src/content/docs/modules/network/prediction.md
Normal 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);
|
||||
}
|
||||
```
|
||||
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: "速率限制"
|
||||
description: "使用可配置的速率限制保护你的游戏服务器免受滥用"
|
||||
---
|
||||
|
||||
`@esengine/server` 包含可插拔的速率限制系统,用于防止 DDoS 攻击、消息洪水和其他滥用行为。
|
||||
|
||||
## 安装
|
||||
|
||||
速率限制包含在 server 包中:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage } from '@esengine/server'
|
||||
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20,
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
}) {
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: Player) {
|
||||
// 受速率限制保护(默认 10 msg/s)
|
||||
}
|
||||
|
||||
@rateLimit({ messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) {
|
||||
// 交易使用更严格的限制
|
||||
}
|
||||
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
// 心跳不限制
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 速率限制策略
|
||||
|
||||
### 令牌桶(默认)
|
||||
|
||||
令牌桶算法允许突发流量,同时保持长期速率限制。令牌以固定速率添加,每个请求消耗令牌。
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'token-bucket',
|
||||
messagesPerSecond: 10, // 补充速率
|
||||
burstSize: 20, // 桶容量
|
||||
}) { }
|
||||
```
|
||||
|
||||
**工作原理:**
|
||||
```
|
||||
配置: rate=10/s, burstSize=20
|
||||
|
||||
[0s] 桶满: 20 令牌
|
||||
[0s] 收到 15 条消息 → 允许,剩余 5
|
||||
[0.5s] 补充 5 令牌 → 10 令牌
|
||||
[0.5s] 收到 8 条消息 → 允许,剩余 2
|
||||
[0.6s] 补充 1 令牌 → 3 令牌
|
||||
[0.6s] 收到 5 条消息 → 允许 3,拒绝 2
|
||||
```
|
||||
|
||||
**最适合:** 大多数通用场景,平衡突发容忍度与保护。
|
||||
|
||||
### 滑动窗口
|
||||
|
||||
滑动窗口算法精确跟踪时间窗口内的请求。比固定窗口更准确,但内存使用稍多。
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'sliding-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**最适合:** 需要精确限流且不需要突发容忍的场景。
|
||||
|
||||
### 固定窗口
|
||||
|
||||
固定窗口算法将时间划分为固定间隔,并计算每个间隔内的请求数。简单且内存高效,但在窗口边界允许 2 倍突发。
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'fixed-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**最适合:** 简单场景,可接受边界突发。
|
||||
|
||||
## 配置
|
||||
|
||||
### 房间配置
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
// 每秒允许的消息数(默认: 10)
|
||||
messagesPerSecond: 10,
|
||||
|
||||
// 突发容量 / 桶大小(默认: 20)
|
||||
burstSize: 20,
|
||||
|
||||
// 策略: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// 被限流时的回调
|
||||
onLimited: (player, messageType, result) => {
|
||||
player.send('RateLimited', {
|
||||
type: messageType,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// 限流时断开连接(默认: false)
|
||||
disconnectOnLimit: false,
|
||||
|
||||
// 连续 N 次限流后断开(0 = 永不)
|
||||
maxConsecutiveLimits: 10,
|
||||
|
||||
// 自定义键函数(默认: player.id)
|
||||
getKey: (player) => player.id,
|
||||
|
||||
// 清理间隔(毫秒,默认: 60000)
|
||||
cleanupInterval: 60000,
|
||||
}) { }
|
||||
```
|
||||
|
||||
### 单消息配置
|
||||
|
||||
使用装饰器为特定消息配置速率限制:
|
||||
|
||||
```typescript
|
||||
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
// 此消息使用自定义速率限制
|
||||
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) { }
|
||||
|
||||
// 此消息消耗 5 个令牌
|
||||
@rateLimit({ cost: 5 })
|
||||
@onMessage('ExpensiveAction')
|
||||
handleExpensive(data: any, player: Player) { }
|
||||
|
||||
// 豁免速率限制
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) { }
|
||||
|
||||
// 替代方案:显式指定消息类型
|
||||
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||
@onMessage('SpecialAction')
|
||||
handleSpecial(data: any, player: Player) { }
|
||||
}
|
||||
```
|
||||
|
||||
## 与认证系统组合
|
||||
|
||||
速率限制可与认证系统无缝配合:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth } from '@esengine/server/auth'
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
// 同时应用两个 mixin
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth(Room, { requireAuth: true }),
|
||||
{ messagesPerSecond: 10 }
|
||||
) {
|
||||
onJoin(player: AuthPlayer) {
|
||||
console.log(`${player.user?.name} 已加入,受速率限制保护`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 速率限制结果
|
||||
|
||||
当消息被限流时,回调会收到结果对象:
|
||||
|
||||
```typescript
|
||||
interface RateLimitResult {
|
||||
// 是否允许请求
|
||||
allowed: boolean
|
||||
|
||||
// 剩余配额
|
||||
remaining: number
|
||||
|
||||
// 配额重置时间(时间戳)
|
||||
resetAt: number
|
||||
|
||||
// 重试等待时间(毫秒)
|
||||
retryAfter?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 访问速率限制上下文
|
||||
|
||||
你可以访问任何玩家的速率限制上下文:
|
||||
|
||||
```typescript
|
||||
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
someMethod(player: Player) {
|
||||
const context = this.getRateLimitContext(player)
|
||||
|
||||
// 检查但不消费
|
||||
const status = context?.check()
|
||||
console.log(`剩余: ${status?.remaining}`)
|
||||
|
||||
// 获取连续限流次数
|
||||
console.log(`连续限流: ${context?.consecutiveLimitCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 或使用独立函数
|
||||
const context = getPlayerRateLimitContext(player)
|
||||
```
|
||||
|
||||
## 自定义策略
|
||||
|
||||
你可以直接使用策略进行自定义实现:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createTokenBucketStrategy,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
// 直接创建策略
|
||||
const strategy = createTokenBucketStrategy({
|
||||
rate: 10, // 每秒令牌数
|
||||
capacity: 20, // 最大令牌数
|
||||
})
|
||||
|
||||
// 检查并消费
|
||||
const result = strategy.consume('player-123')
|
||||
if (result.allowed) {
|
||||
// 处理消息
|
||||
} else {
|
||||
// 被限流,等待 result.retryAfter 毫秒
|
||||
}
|
||||
|
||||
// 检查但不消费
|
||||
const status = strategy.getStatus('player-123')
|
||||
|
||||
// 重置某个键
|
||||
strategy.reset('player-123')
|
||||
|
||||
// 清理过期记录
|
||||
strategy.cleanup()
|
||||
```
|
||||
|
||||
## 速率限制上下文
|
||||
|
||||
`RateLimitContext` 类管理单个玩家的速率限制:
|
||||
|
||||
```typescript
|
||||
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||
|
||||
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||
const context = new RateLimitContext('player-123', strategy)
|
||||
|
||||
// 检查但不消费
|
||||
context.check()
|
||||
|
||||
// 消费配额
|
||||
context.consume()
|
||||
|
||||
// 带消耗量消费
|
||||
context.consume(undefined, 5)
|
||||
|
||||
// 为特定消息类型消费
|
||||
context.consume('Trade')
|
||||
|
||||
// 设置单消息策略
|
||||
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||
|
||||
// 重置
|
||||
context.reset()
|
||||
|
||||
// 获取连续限流次数
|
||||
console.log(context.consecutiveLimitCount)
|
||||
```
|
||||
|
||||
## 房间生命周期钩子
|
||||
|
||||
你可以重写 `onRateLimited` 钩子进行自定义处理:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
// 记录事件
|
||||
console.log(`玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||
|
||||
// 发送自定义错误
|
||||
player.send('SystemMessage', {
|
||||
type: 'warning',
|
||||
message: `请慢一点!${result.retryAfter}ms 后重试`,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **从令牌桶开始**:对于游戏来说是最灵活的算法。
|
||||
|
||||
2. **设置合适的限制**:考虑你的游戏机制:
|
||||
- 移动消息:较高限制(20-60/s)
|
||||
- 聊天消息:较低限制(1-5/s)
|
||||
- 交易/购买:非常低的限制(0.5-1/s)
|
||||
|
||||
3. **使用突发容量**:允许短暂突发以获得响应式体验:
|
||||
```typescript
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30, // 允许 3 秒的突发
|
||||
```
|
||||
|
||||
4. **豁免关键消息**:不要限制心跳或系统消息:
|
||||
```typescript
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() { }
|
||||
```
|
||||
|
||||
5. **与认证结合**:对已认证用户按用户 ID 限流:
|
||||
```typescript
|
||||
getKey: (player) => player.auth?.userId ?? player.id
|
||||
```
|
||||
|
||||
6. **监控和调整**:记录限流事件以调整限制:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
metrics.increment('rate_limit', { messageType: type })
|
||||
}
|
||||
```
|
||||
|
||||
7. **优雅降级**:发送信息性错误而不是直接断开:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
message: '请求过于频繁',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage, type Player } from '@esengine/server'
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
import {
|
||||
withRateLimit,
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
type RateLimitResult,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
premium: boolean
|
||||
}
|
||||
|
||||
// 组合认证和速率限制
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||
{
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30,
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// 使用用户 ID 进行限流
|
||||
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||
|
||||
// 处理限流
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
messageType: type,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// 连续 20 次限流后断开
|
||||
maxConsecutiveLimits: 20,
|
||||
}
|
||||
) {
|
||||
onCreate() {
|
||||
console.log('房间已创建,具有认证 + 速率限制保护')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||
}
|
||||
|
||||
// 高频移动(默认速率限制)
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||
}
|
||||
|
||||
// 低频交易(严格限制)
|
||||
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||
// 处理交易...
|
||||
}
|
||||
|
||||
// 聊天使用中等限制
|
||||
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
|
||||
// 系统消息 - 不限制
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
player.send('Pong', { time: Date.now() })
|
||||
}
|
||||
|
||||
// 自定义限流处理
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
console.warn(`[限流] 玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -311,6 +311,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 频率**
|
||||
|
||||
@@ -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"` | 字符串 | 变长 |
|
||||
|
||||
## 快照缓冲区
|
||||
|
||||
用于存储服务器状态快照并进行插值:
|
||||
|
||||
251
docs/src/content/docs/modules/rpc/client.md
Normal file
251
docs/src/content/docs/modules/rpc/client.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: "RPC 客户端 API"
|
||||
description: "RpcClient 连接 RPC 服务器"
|
||||
---
|
||||
|
||||
`RpcClient` 类提供类型安全的 WebSocket 客户端,用于 RPC 通信。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('已连接'),
|
||||
onDisconnect: (reason) => console.log('已断开:', reason),
|
||||
onError: (error) => console.error('错误:', error),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
```
|
||||
|
||||
## 构造选项
|
||||
|
||||
```typescript
|
||||
interface RpcClientOptions {
|
||||
// 序列化编解码器(默认: json())
|
||||
codec?: Codec;
|
||||
|
||||
// API 调用超时,毫秒(默认: 30000)
|
||||
timeout?: number;
|
||||
|
||||
// 断开后自动重连(默认: true)
|
||||
autoReconnect?: boolean;
|
||||
|
||||
// 重连间隔,毫秒(默认: 3000)
|
||||
reconnectInterval?: number;
|
||||
|
||||
// 自定义 WebSocket 工厂(用于微信小游戏等)
|
||||
webSocketFactory?: (url: string) => WebSocketAdapter;
|
||||
|
||||
// 回调函数
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: (reason?: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## 连接管理
|
||||
|
||||
### 连接
|
||||
|
||||
```typescript
|
||||
// connect 返回 Promise
|
||||
await client.connect();
|
||||
|
||||
// 或链式调用
|
||||
client.connect().then(() => {
|
||||
console.log('已就绪');
|
||||
});
|
||||
```
|
||||
|
||||
### 检查状态
|
||||
|
||||
```typescript
|
||||
// 连接状态: 'connecting' | 'open' | 'closing' | 'closed'
|
||||
console.log(client.status);
|
||||
|
||||
// 便捷布尔值
|
||||
if (client.isConnected) {
|
||||
// 可以安全调用 API
|
||||
}
|
||||
```
|
||||
|
||||
### 断开连接
|
||||
|
||||
```typescript
|
||||
// 手动断开(禁用自动重连)
|
||||
client.disconnect();
|
||||
```
|
||||
|
||||
## 调用 API
|
||||
|
||||
API 使用请求-响应模式,完全类型安全:
|
||||
|
||||
```typescript
|
||||
// 定义协议
|
||||
const protocol = rpc.define({
|
||||
api: {
|
||||
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
|
||||
getProfile: rpc.api<{ userId: string }, { name: string; level: number }>(),
|
||||
},
|
||||
msg: {}
|
||||
});
|
||||
|
||||
// 调用时类型自动推断
|
||||
const { userId, token } = await client.call('login', { username: 'player1' });
|
||||
const profile = await client.call('getProfile', { userId });
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/client';
|
||||
|
||||
try {
|
||||
await client.call('login', { username: 'player1' });
|
||||
} catch (error) {
|
||||
if (error instanceof RpcError) {
|
||||
switch (error.code) {
|
||||
case ErrorCode.TIMEOUT:
|
||||
console.log('请求超时');
|
||||
break;
|
||||
case ErrorCode.CONNECTION_CLOSED:
|
||||
console.log('未连接');
|
||||
break;
|
||||
case ErrorCode.NOT_FOUND:
|
||||
console.log('API 不存在');
|
||||
break;
|
||||
default:
|
||||
console.log('服务器错误:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 发送消息
|
||||
|
||||
消息是发送即忘模式(无响应):
|
||||
|
||||
```typescript
|
||||
// 向服务器发送消息
|
||||
client.send('playerMove', { x: 100, y: 200 });
|
||||
client.send('chat', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
## 接收消息
|
||||
|
||||
监听服务器推送的消息:
|
||||
|
||||
```typescript
|
||||
// 订阅消息
|
||||
client.on('newMessage', (data) => {
|
||||
console.log(`${data.from}: ${data.text}`);
|
||||
});
|
||||
|
||||
client.on('playerJoined', (data) => {
|
||||
console.log(`${data.name} 加入游戏`);
|
||||
});
|
||||
|
||||
// 取消特定处理器
|
||||
const handler = (data) => console.log(data);
|
||||
client.on('event', handler);
|
||||
client.off('event', handler);
|
||||
|
||||
// 取消某消息的所有处理器
|
||||
client.off('event');
|
||||
|
||||
// 一次性监听
|
||||
client.once('gameStart', (data) => {
|
||||
console.log('游戏开始!');
|
||||
});
|
||||
```
|
||||
|
||||
## 自定义 WebSocket(平台适配器)
|
||||
|
||||
用于微信小游戏等平台:
|
||||
|
||||
```typescript
|
||||
// 微信小游戏适配器
|
||||
const wxWebSocketFactory = (url: string) => {
|
||||
const ws = wx.connectSocket({ url });
|
||||
|
||||
return {
|
||||
get readyState() { return ws.readyState; },
|
||||
send: (data) => ws.send({ data }),
|
||||
close: (code, reason) => ws.close({ code, reason }),
|
||||
set onopen(fn) { ws.onOpen(fn); },
|
||||
set onclose(fn) { ws.onClose((e) => fn({ code: e.code, reason: e.reason })); },
|
||||
set onerror(fn) { ws.onError(fn); },
|
||||
set onmessage(fn) { ws.onMessage((e) => fn({ data: e.data })); },
|
||||
};
|
||||
};
|
||||
|
||||
const client = new RpcClient(protocol, 'wss://game.example.com', {
|
||||
webSocketFactory: wxWebSocketFactory,
|
||||
});
|
||||
```
|
||||
|
||||
## 便捷函数
|
||||
|
||||
```typescript
|
||||
import { connect } from '@esengine/rpc/client';
|
||||
|
||||
// 一次调用完成连接
|
||||
const client = await connect(protocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('已连接'),
|
||||
});
|
||||
|
||||
const result = await client.call('join', { name: 'Alice' });
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
class GameClient {
|
||||
private client: RpcClient<typeof gameProtocol>;
|
||||
private userId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => this.onConnected(),
|
||||
onDisconnect: () => this.onDisconnected(),
|
||||
onError: (e) => console.error('RPC 错误:', e),
|
||||
});
|
||||
|
||||
// 设置消息处理器
|
||||
this.client.on('gameState', (state) => this.updateState(state));
|
||||
this.client.on('playerJoined', (p) => this.addPlayer(p));
|
||||
this.client.on('playerLeft', (p) => this.removePlayer(p));
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
private async onConnected() {
|
||||
const { userId, token } = await this.client.call('login', {
|
||||
username: localStorage.getItem('username') || 'Guest',
|
||||
});
|
||||
this.userId = userId;
|
||||
console.log('登录为', userId);
|
||||
}
|
||||
|
||||
private onDisconnected() {
|
||||
console.log('已断开,将自动重连...');
|
||||
}
|
||||
|
||||
async move(x: number, y: number) {
|
||||
if (!this.client.isConnected) return;
|
||||
this.client.send('move', { x, y });
|
||||
}
|
||||
|
||||
async chat(text: string) {
|
||||
await this.client.call('sendChat', { text });
|
||||
}
|
||||
}
|
||||
```
|
||||
160
docs/src/content/docs/modules/rpc/codec.md
Normal file
160
docs/src/content/docs/modules/rpc/codec.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "RPC 编解码器"
|
||||
description: "RPC 通信的序列化编解码器"
|
||||
---
|
||||
|
||||
编解码器负责 RPC 消息的序列化和反序列化。内置两种编解码器。
|
||||
|
||||
## 内置编解码器
|
||||
|
||||
### JSON 编解码器(默认)
|
||||
|
||||
人类可读,兼容性好:
|
||||
|
||||
```typescript
|
||||
import { json } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: json(),
|
||||
});
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- 人类可读(方便调试)
|
||||
- 无额外依赖
|
||||
- 浏览器普遍支持
|
||||
|
||||
**缺点:**
|
||||
- 消息体积较大
|
||||
- 序列化速度较慢
|
||||
|
||||
### MessagePack 编解码器
|
||||
|
||||
二进制格式,更高效:
|
||||
|
||||
```typescript
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: msgpack(),
|
||||
});
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- 消息体积更小(约小30-50%)
|
||||
- 序列化速度更快
|
||||
- 原生支持二进制数据
|
||||
|
||||
**缺点:**
|
||||
- 不可读
|
||||
- 需要 msgpack 库
|
||||
|
||||
## 编解码器接口
|
||||
|
||||
```typescript
|
||||
interface Codec {
|
||||
/**
|
||||
* 将数据包编码为传输格式
|
||||
*/
|
||||
encode(packet: unknown): string | Uint8Array;
|
||||
|
||||
/**
|
||||
* 将传输格式解码为数据包
|
||||
*/
|
||||
decode(data: string | Uint8Array): unknown;
|
||||
}
|
||||
```
|
||||
|
||||
## 自定义编解码器
|
||||
|
||||
为特殊需求创建自己的编解码器:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
// 示例:压缩 JSON 编解码器
|
||||
const compressedJson: () => Codec = () => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
const json = JSON.stringify(packet);
|
||||
return compress(new TextEncoder().encode(json));
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
const decompressed = decompress(bytes);
|
||||
return JSON.parse(new TextDecoder().decode(decompressed));
|
||||
},
|
||||
});
|
||||
|
||||
// 使用自定义编解码器
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: compressedJson(),
|
||||
});
|
||||
```
|
||||
|
||||
## Protocol Buffers 编解码器
|
||||
|
||||
对于生产级游戏,考虑使用 Protocol Buffers:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
const protobuf = (schema: ProtobufSchema): Codec => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
return schema.Packet.encode(packet).finish();
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
return schema.Packet.decode(bytes);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 客户端与服务器匹配
|
||||
|
||||
客户端和服务器必须使用相同的编解码器:
|
||||
|
||||
```typescript
|
||||
// shared/codec.ts
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
export const gameCodec = msgpack();
|
||||
|
||||
// client.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const client = new RpcClient(protocol, url, { codec: gameCodec });
|
||||
|
||||
// server.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
codec: gameCodec,
|
||||
api: { /* ... */ },
|
||||
});
|
||||
```
|
||||
|
||||
## 性能对比
|
||||
|
||||
| 编解码器 | 编码速度 | 解码速度 | 体积 |
|
||||
|----------|----------|----------|------|
|
||||
| JSON | 中等 | 中等 | 大 |
|
||||
| MessagePack | 快 | 快 | 小 |
|
||||
| Protobuf | 最快 | 最快 | 最小 |
|
||||
|
||||
对于大多数游戏,MessagePack 提供了良好的平衡。对于高性能需求使用 Protobuf。
|
||||
|
||||
## 文本编码工具
|
||||
|
||||
为自定义编解码器提供工具函数:
|
||||
|
||||
```typescript
|
||||
import { textEncode, textDecode } from '@esengine/rpc/codec';
|
||||
|
||||
// 在所有平台上工作(浏览器、Node.js、微信)
|
||||
const bytes = textEncode('Hello'); // Uint8Array
|
||||
const text = textDecode(bytes); // 'Hello'
|
||||
```
|
||||
350
docs/src/content/docs/modules/rpc/server.md
Normal file
350
docs/src/content/docs/modules/rpc/server.md
Normal file
@@ -0,0 +1,350 @@
|
||||
---
|
||||
title: "RPC 服务器 API"
|
||||
description: "RpcServer 处理客户端连接"
|
||||
---
|
||||
|
||||
`serve` 函数创建类型安全的 RPC 服务器,处理客户端连接和 API 调用。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```typescript
|
||||
import { serve } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
console.log(`${input.username} 从 ${conn.ip} 连接`);
|
||||
return { userId: conn.id, token: generateToken() };
|
||||
},
|
||||
sendChat: async (input, conn) => {
|
||||
server.broadcast('newMessage', {
|
||||
from: conn.id,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onStart: (port) => console.log(`服务器启动于端口 ${port}`),
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
## 服务器选项
|
||||
|
||||
```typescript
|
||||
interface ServeOptions<P, TConnData> {
|
||||
// 必需
|
||||
port: number;
|
||||
api: ApiHandlers<P, TConnData>;
|
||||
|
||||
// 可选
|
||||
msg?: MsgHandlers<P, TConnData>;
|
||||
codec?: Codec;
|
||||
createConnData?: () => TConnData;
|
||||
|
||||
// 回调
|
||||
onConnect?: (conn: Connection<TConnData>) => void | Promise<void>;
|
||||
onDisconnect?: (conn: Connection<TConnData>, reason?: string) => void | Promise<void>;
|
||||
onError?: (error: Error, conn?: Connection<TConnData>) => void;
|
||||
onStart?: (port: number) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## API 处理器
|
||||
|
||||
每个 API 处理器接收输入和连接上下文:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
// 同步处理器
|
||||
ping: (input, conn) => {
|
||||
return { pong: true, time: Date.now() };
|
||||
},
|
||||
|
||||
// 异步处理器
|
||||
getProfile: async (input, conn) => {
|
||||
const user = await database.findUser(input.userId);
|
||||
return { name: user.name, level: user.level };
|
||||
},
|
||||
|
||||
// 访问连接上下文
|
||||
getMyInfo: (input, conn) => {
|
||||
return {
|
||||
connectionId: conn.id,
|
||||
ip: conn.ip,
|
||||
data: conn.data,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 抛出错误
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/server';
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
const user = await database.findUser(input.username);
|
||||
|
||||
if (!user) {
|
||||
throw new RpcError(ErrorCode.NOT_FOUND, '用户不存在');
|
||||
}
|
||||
|
||||
if (!await verifyPassword(input.password, user.hash)) {
|
||||
throw new RpcError('AUTH_FAILED', '密码错误');
|
||||
}
|
||||
|
||||
return { userId: user.id, token: generateToken() };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 消息处理器
|
||||
|
||||
处理客户端发送的消息:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: { /* ... */ },
|
||||
msg: {
|
||||
playerMove: (data, conn) => {
|
||||
// 更新玩家位置
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
}
|
||||
|
||||
// 广播给其他玩家
|
||||
server.broadcast('playerMoved', {
|
||||
playerId: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
},
|
||||
|
||||
chat: async (data, conn) => {
|
||||
// 异步处理器也可以
|
||||
await logChat(conn.id, data.text);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 连接上下文
|
||||
|
||||
`Connection` 对象提供客户端信息:
|
||||
|
||||
```typescript
|
||||
interface Connection<TData> {
|
||||
// 唯一连接 ID
|
||||
readonly id: string;
|
||||
|
||||
// 客户端 IP 地址
|
||||
readonly ip: string;
|
||||
|
||||
// 连接状态
|
||||
readonly isOpen: boolean;
|
||||
|
||||
// 附加到此连接的自定义数据
|
||||
data: TData;
|
||||
|
||||
// 关闭连接
|
||||
close(reason?: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义连接数据
|
||||
|
||||
存储每连接的状态:
|
||||
|
||||
```typescript
|
||||
interface PlayerData {
|
||||
playerId: string;
|
||||
username: string;
|
||||
room: string | null;
|
||||
}
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({
|
||||
playerId: '',
|
||||
username: '',
|
||||
room: null,
|
||||
} as PlayerData),
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
// 在连接上存储数据
|
||||
conn.data.playerId = generateId();
|
||||
conn.data.username = input.username;
|
||||
return { playerId: conn.data.playerId };
|
||||
},
|
||||
joinRoom: async (input, conn) => {
|
||||
conn.data.room = input.roomId;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onDisconnect: (conn) => {
|
||||
console.log(`${conn.data.username} 离开房间 ${conn.data.room}`);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 发送消息
|
||||
|
||||
### 发送给单个连接
|
||||
|
||||
```typescript
|
||||
server.send(conn, 'notification', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
### 广播给所有人
|
||||
|
||||
```typescript
|
||||
// 给所有人
|
||||
server.broadcast('announcement', { text: '服务器将在5分钟后重启' });
|
||||
|
||||
// 排除发送者
|
||||
server.broadcast('playerMoved', { id: conn.id, x, y }, { exclude: conn });
|
||||
|
||||
// 排除多个
|
||||
server.broadcast('gameEvent', data, { exclude: [conn1, conn2] });
|
||||
```
|
||||
|
||||
### 发送给特定群组
|
||||
|
||||
```typescript
|
||||
// 自定义广播
|
||||
function broadcastToRoom(roomId: string, name: string, data: any) {
|
||||
for (const conn of server.connections) {
|
||||
if (conn.data.room === roomId) {
|
||||
server.send(conn, name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToRoom('room1', 'roomMessage', { text: '房间内消息!' });
|
||||
```
|
||||
|
||||
## 服务器生命周期
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, { /* ... */ });
|
||||
|
||||
// 启动
|
||||
await server.start();
|
||||
console.log('服务器运行中');
|
||||
|
||||
// 访问连接列表
|
||||
console.log(`${server.connections.length} 个客户端已连接`);
|
||||
|
||||
// 停止(关闭所有连接)
|
||||
await server.stop();
|
||||
console.log('服务器已停止');
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { serve, RpcError } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
interface PlayerData {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const players = new Map<string, PlayerData>();
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({ id: '', name: '', x: 0, y: 0 }),
|
||||
|
||||
api: {
|
||||
join: async (input, conn) => {
|
||||
const player: PlayerData = {
|
||||
id: conn.id,
|
||||
name: input.name,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
players.set(conn.id, player);
|
||||
conn.data = player;
|
||||
|
||||
// 通知其他玩家
|
||||
server.broadcast('playerJoined', {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
}, { exclude: conn });
|
||||
|
||||
// 发送当前状态给新玩家
|
||||
return {
|
||||
playerId: player.id,
|
||||
players: Array.from(players.values()),
|
||||
};
|
||||
},
|
||||
|
||||
chat: async (input, conn) => {
|
||||
server.broadcast('chatMessage', {
|
||||
from: conn.data.name,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { sent: true };
|
||||
},
|
||||
},
|
||||
|
||||
msg: {
|
||||
move: (data, conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
|
||||
server.broadcast('playerMoved', {
|
||||
id: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
onConnect: (conn) => {
|
||||
console.log(`客户端已连接: ${conn.id} 来自 ${conn.ip}`);
|
||||
},
|
||||
|
||||
onDisconnect: (conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
players.delete(conn.id);
|
||||
server.broadcast('playerLeft', { id: conn.id });
|
||||
console.log(`${player.name} 已断开`);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (error, conn) => {
|
||||
console.error(`来自 ${conn?.id} 的错误:`, error);
|
||||
},
|
||||
|
||||
onStart: (port) => {
|
||||
console.log(`游戏服务器运行于 ws://localhost:${port}`);
|
||||
},
|
||||
});
|
||||
|
||||
server.start();
|
||||
```
|
||||
261
docs/src/content/docs/modules/transaction/core.md
Normal file
261
docs/src/content/docs/modules/transaction/core.md
Normal 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}`);
|
||||
}
|
||||
```
|
||||
355
docs/src/content/docs/modules/transaction/distributed.md
Normal file
355
docs/src/content/docs/modules/transaction/distributed.md
Normal 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 });
|
||||
}
|
||||
```
|
||||
238
docs/src/content/docs/modules/transaction/index.md
Normal file
238
docs/src/content/docs/modules/transaction/index.md
Normal 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) });
|
||||
}
|
||||
```
|
||||
313
docs/src/content/docs/modules/transaction/operations.md
Normal file
313
docs/src/content/docs/modules/transaction/operations.md
Normal 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,
|
||||
}));
|
||||
```
|
||||
234
docs/src/content/docs/modules/transaction/storage.md
Normal file
234
docs/src/content/docs/modules/transaction/storage.md
Normal file
@@ -0,0 +1,234 @@
|
||||
---
|
||||
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 存储,适用于需要持久化和复杂查询的场景。使用工厂模式实现惰性连接。
|
||||
|
||||
```typescript
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { MongoStorage } from '@esengine/transaction';
|
||||
|
||||
// 工厂模式:惰性连接,首次操作时才创建连接
|
||||
const storage = new MongoStorage({
|
||||
factory: async () => {
|
||||
const client = new MongoClient('mongodb://localhost:27017');
|
||||
await client.connect();
|
||||
return client;
|
||||
},
|
||||
database: 'game',
|
||||
transactionCollection: 'transactions', // 事务日志集合
|
||||
dataCollection: 'transaction_data', // 业务数据集合
|
||||
lockCollection: 'transaction_locks', // 锁集合
|
||||
});
|
||||
|
||||
// 创建索引(首次运行时执行)
|
||||
await storage.ensureIndexes();
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
|
||||
// 使用后关闭连接
|
||||
await storage.close();
|
||||
|
||||
// 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||
await using storage = new MongoStorage({ ... });
|
||||
```
|
||||
|
||||
### 特点
|
||||
|
||||
- ✅ 持久化存储
|
||||
- ✅ 支持复杂查询
|
||||
- ✅ 事务日志可追溯
|
||||
- ✅ 适合需要审计的场景
|
||||
- ❌ 相比 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> {
|
||||
// 保存事务日志
|
||||
}
|
||||
|
||||
// ... 实现其他方法
|
||||
}
|
||||
```
|
||||
167
docs/src/content/docs/modules/world-streaming/chunk-manager.md
Normal file
167
docs/src/content/docs/modules/world-streaming/chunk-manager.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: "区块管理器 API"
|
||||
description: "ChunkManager 负责区块生命周期、加载队列和空间查询"
|
||||
---
|
||||
|
||||
`ChunkManager` 是管理区块生命周期的核心服务,包括加载、卸载和空间查询。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```typescript
|
||||
import { ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
// 创建 512 单位大小的区块管理器
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// 设置数据提供器
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// 设置事件回调
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
console.log(`区块 (${coord.x}, ${coord.y}) 已加载,包含 ${entities.length} 个实体`);
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
console.log(`区块 (${coord.x}, ${coord.y}) 已卸载`);
|
||||
},
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`加载区块 (${coord.x}, ${coord.y}) 失败:`, error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 加载与卸载
|
||||
|
||||
### 请求加载
|
||||
|
||||
```typescript
|
||||
import { EChunkPriority } from '@esengine/world-streaming';
|
||||
|
||||
// 按优先级请求加载
|
||||
chunkManager.requestLoad({ x: 0, y: 0 }, EChunkPriority.Immediate);
|
||||
chunkManager.requestLoad({ x: 1, y: 0 }, EChunkPriority.High);
|
||||
chunkManager.requestLoad({ x: 2, y: 0 }, EChunkPriority.Normal);
|
||||
chunkManager.requestLoad({ x: 3, y: 0 }, EChunkPriority.Low);
|
||||
chunkManager.requestLoad({ x: 4, y: 0 }, EChunkPriority.Prefetch);
|
||||
```
|
||||
|
||||
### 优先级说明
|
||||
|
||||
| 优先级 | 值 | 说明 |
|
||||
|--------|------|------|
|
||||
| `Immediate` | 0 | 当前区块(玩家所在) |
|
||||
| `High` | 1 | 相邻区块 |
|
||||
| `Normal` | 2 | 附近区块 |
|
||||
| `Low` | 3 | 远处可见区块 |
|
||||
| `Prefetch` | 4 | 移动方向预加载 |
|
||||
|
||||
### 请求卸载
|
||||
|
||||
```typescript
|
||||
// 请求卸载,延迟 3 秒
|
||||
chunkManager.requestUnload({ x: 5, y: 5 }, 3000);
|
||||
|
||||
// 取消待卸载请求(玩家返回了)
|
||||
chunkManager.cancelUnload({ x: 5, y: 5 });
|
||||
```
|
||||
|
||||
### 处理队列
|
||||
|
||||
```typescript
|
||||
// 在更新循环或系统中
|
||||
await chunkManager.processLoads(2); // 每帧最多加载 2 个区块
|
||||
chunkManager.processUnloads(1); // 每帧最多卸载 1 个区块
|
||||
```
|
||||
|
||||
## 空间查询
|
||||
|
||||
### 坐标转换
|
||||
|
||||
```typescript
|
||||
// 世界坐标转区块坐标
|
||||
const coord = chunkManager.worldToChunk(1500, 2300);
|
||||
// 结果: { x: 2, y: 4 }(512单位区块)
|
||||
|
||||
// 获取区块世界边界
|
||||
const bounds = chunkManager.getChunkBounds({ x: 2, y: 4 });
|
||||
// 结果: { minX: 1024, minY: 2048, maxX: 1536, maxY: 2560 }
|
||||
```
|
||||
|
||||
### 区块查询
|
||||
|
||||
```typescript
|
||||
// 检查区块是否已加载
|
||||
if (chunkManager.isChunkLoaded({ x: 0, y: 0 })) {
|
||||
const chunk = chunkManager.getChunk({ x: 0, y: 0 });
|
||||
console.log('实体数量:', chunk.entities.length);
|
||||
}
|
||||
|
||||
// 获取半径内未加载的区块
|
||||
const missing = chunkManager.getMissingChunks({ x: 0, y: 0 }, 2);
|
||||
for (const coord of missing) {
|
||||
chunkManager.requestLoad(coord);
|
||||
}
|
||||
|
||||
// 获取超出范围的区块(用于卸载)
|
||||
const outside = chunkManager.getChunksOutsideRadius({ x: 0, y: 0 }, 4);
|
||||
for (const coord of outside) {
|
||||
chunkManager.requestUnload(coord, 3000);
|
||||
}
|
||||
|
||||
// 遍历所有已加载区块
|
||||
chunkManager.forEachChunk((info, coord) => {
|
||||
console.log(`区块 (${coord.x}, ${coord.y}): ${info.state}`);
|
||||
});
|
||||
```
|
||||
|
||||
## 统计信息
|
||||
|
||||
```typescript
|
||||
console.log('已加载区块:', chunkManager.loadedChunkCount);
|
||||
console.log('待加载:', chunkManager.pendingLoadCount);
|
||||
console.log('待卸载:', chunkManager.pendingUnloadCount);
|
||||
console.log('区块大小:', chunkManager.chunkSize);
|
||||
```
|
||||
|
||||
## 区块状态
|
||||
|
||||
```typescript
|
||||
import { EChunkState } from '@esengine/world-streaming';
|
||||
|
||||
// 区块生命周期状态
|
||||
EChunkState.Unloaded // 未加载
|
||||
EChunkState.Loading // 加载中
|
||||
EChunkState.Loaded // 已加载
|
||||
EChunkState.Unloading // 卸载中
|
||||
EChunkState.Failed // 加载失败
|
||||
```
|
||||
|
||||
## 数据提供器接口
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class MyChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// 从数据库、文件或程序化生成加载
|
||||
const data = await fetchChunkFromServer(coord);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// 保存修改过的区块
|
||||
await saveChunkToServer(data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 清理
|
||||
|
||||
```typescript
|
||||
// 卸载所有区块
|
||||
chunkManager.clear();
|
||||
|
||||
// 完全释放(实现 IService 接口)
|
||||
chunkManager.dispose();
|
||||
```
|
||||
330
docs/src/content/docs/modules/world-streaming/examples.md
Normal file
330
docs/src/content/docs/modules/world-streaming/examples.md
Normal file
@@ -0,0 +1,330 @@
|
||||
---
|
||||
title: "示例"
|
||||
description: "世界流式加载实践示例"
|
||||
---
|
||||
|
||||
## 无限程序化世界
|
||||
|
||||
无限大世界的程序化资源生成示例。
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
// 程序化世界生成器
|
||||
class WorldGenerator implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
private nextEntityId = 1;
|
||||
|
||||
constructor(seed: number = 12345) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const rng = this.createChunkRNG(coord);
|
||||
const entities = [];
|
||||
|
||||
// 每区块生成 5-15 个资源
|
||||
const resourceCount = 5 + Math.floor(rng() * 10);
|
||||
|
||||
for (let i = 0; i < resourceCount; i++) {
|
||||
const type = this.randomResourceType(rng);
|
||||
|
||||
entities.push({
|
||||
name: `Resource_${this.nextEntityId++}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
ResourceNode: {
|
||||
type,
|
||||
amount: this.getResourceAmount(type, rng),
|
||||
regenRate: this.getRegenRate(type)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { coord, entities, version: 1 };
|
||||
}
|
||||
|
||||
async saveChunkData(_data: IChunkData): Promise<void> {
|
||||
// 程序化生成 - 无需持久化
|
||||
}
|
||||
|
||||
private createChunkRNG(coord: IChunkCoord) {
|
||||
let seed = this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private randomResourceType(rng: () => number) {
|
||||
const types = ['energyWell', 'oreVein', 'crystalDeposit'];
|
||||
const weights = [0.5, 0.35, 0.15];
|
||||
|
||||
let random = rng();
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
random -= weights[i];
|
||||
if (random <= 0) return types[i];
|
||||
}
|
||||
return types[0];
|
||||
}
|
||||
|
||||
private getResourceAmount(type: string, rng: () => number) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 300 + Math.floor(rng() * 200);
|
||||
case 'oreVein': return 500 + Math.floor(rng() * 300);
|
||||
case 'crystalDeposit': return 100 + Math.floor(rng() * 100);
|
||||
default: return 100;
|
||||
}
|
||||
}
|
||||
|
||||
private getRegenRate(type: string) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 2;
|
||||
case 'oreVein': return 1;
|
||||
case 'crystalDeposit': return 0.2;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(new WorldGenerator(12345));
|
||||
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
```
|
||||
|
||||
## MMO 服务端区块
|
||||
|
||||
带数据库持久化的 MMO 服务端区块管理。
|
||||
|
||||
```typescript
|
||||
class ServerChunkProvider implements IChunkDataProvider {
|
||||
private db: Database;
|
||||
private cache = new Map<string, IChunkData>();
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
|
||||
// 检查缓存
|
||||
if (this.cache.has(key)) {
|
||||
return this.cache.get(key)!;
|
||||
}
|
||||
|
||||
// 从数据库加载
|
||||
const row = await this.db.query(
|
||||
'SELECT data FROM chunks WHERE x = ? AND y = ?',
|
||||
[coord.x, coord.y]
|
||||
);
|
||||
|
||||
if (row) {
|
||||
const data = JSON.parse(row.data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// 生成新区块
|
||||
const data = this.generateChunk(coord);
|
||||
await this.saveChunkData(data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `${data.coord.x},${data.coord.y}`;
|
||||
this.cache.set(key, data);
|
||||
|
||||
await this.db.query(
|
||||
`INSERT INTO chunks (x, y, data) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE data = VALUES(data)`,
|
||||
[data.coord.x, data.coord.y, JSON.stringify(data)]
|
||||
);
|
||||
}
|
||||
|
||||
private generateChunk(coord: IChunkCoord): IChunkData {
|
||||
// 新区块的程序化生成
|
||||
return { coord, entities: [], version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端按玩家加载区块
|
||||
class PlayerChunkManager {
|
||||
private chunkManager: ChunkManager;
|
||||
private playerChunks = new Map<string, Set<string>>();
|
||||
|
||||
async updatePlayerPosition(playerId: string, x: number, y: number) {
|
||||
const centerCoord = this.chunkManager.worldToChunk(x, y);
|
||||
const loadRadius = 2;
|
||||
|
||||
const newChunks = new Set<string>();
|
||||
|
||||
// 加载玩家周围的区块
|
||||
for (let dx = -loadRadius; dx <= loadRadius; dx++) {
|
||||
for (let dy = -loadRadius; dy <= loadRadius; dy++) {
|
||||
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
newChunks.add(key);
|
||||
|
||||
if (!this.chunkManager.isChunkLoaded(coord)) {
|
||||
await this.chunkManager.requestLoad(coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录玩家已加载的区块
|
||||
this.playerChunks.set(playerId, newChunks);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 瓦片地图世界
|
||||
|
||||
瓦片地图与区块流式加载集成。
|
||||
|
||||
```typescript
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
|
||||
class TilemapChunkProvider implements IChunkDataProvider {
|
||||
private tilemapData: number[][]; // 完整瓦片地图
|
||||
private tileSize = 32;
|
||||
private chunkTiles = 16; // 每区块 16x16 瓦片
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const startTileX = coord.x * this.chunkTiles;
|
||||
const startTileY = coord.y * this.chunkTiles;
|
||||
|
||||
// 提取此区块的瓦片
|
||||
const tiles: number[][] = [];
|
||||
for (let y = 0; y < this.chunkTiles; y++) {
|
||||
const row: number[] = [];
|
||||
for (let x = 0; x < this.chunkTiles; x++) {
|
||||
const tileX = startTileX + x;
|
||||
const tileY = startTileY + y;
|
||||
row.push(this.getTile(tileX, tileY));
|
||||
}
|
||||
tiles.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities: [{
|
||||
name: `TileChunk_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 0, y: 0 },
|
||||
components: {
|
||||
TilemapChunk: { tiles }
|
||||
}
|
||||
}],
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private getTile(x: number, y: number): number {
|
||||
if (x < 0 || y < 0 || y >= this.tilemapData.length) {
|
||||
return 0; // 超出边界 = 空
|
||||
}
|
||||
return this.tilemapData[y]?.[x] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 瓦片地图自定义序列化器
|
||||
class TilemapSerializer extends ChunkSerializer {
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.TilemapChunk) {
|
||||
const data = components.TilemapChunk as { tiles: number[][] };
|
||||
const tilemap = entity.addComponent(new TilemapComponent());
|
||||
tilemap.loadTiles(data.tiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 动态加载事件
|
||||
|
||||
响应区块加载用于游戏逻辑。
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// 启用物理
|
||||
for (const entity of entities) {
|
||||
const collider = entity.getComponent(ColliderComponent);
|
||||
collider?.enable();
|
||||
}
|
||||
|
||||
// 为已加载区块生成 NPC
|
||||
npcManager.spawnForChunk(coord);
|
||||
|
||||
// 更新战争迷雾
|
||||
fogOfWar.revealChunk(coord);
|
||||
|
||||
// 通知客户端(服务端)
|
||||
broadcast('ChunkLoaded', { coord, entityCount: entities.length });
|
||||
},
|
||||
|
||||
onChunkUnloaded: (coord) => {
|
||||
// 保存 NPC 状态
|
||||
npcManager.saveAndRemoveForChunk(coord);
|
||||
|
||||
// 更新迷雾
|
||||
fogOfWar.hideChunk(coord);
|
||||
|
||||
// 通知客户端
|
||||
broadcast('ChunkUnloaded', { coord });
|
||||
},
|
||||
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`加载区块 ${coord.x},${coord.y} 失败:`, error);
|
||||
|
||||
// 延迟后重试
|
||||
setTimeout(() => {
|
||||
chunkManager.requestLoad(coord);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
```typescript
|
||||
// 根据设备性能调整
|
||||
function configureForDevice(loader: ChunkLoaderComponent) {
|
||||
const memory = navigator.deviceMemory ?? 4;
|
||||
const cores = navigator.hardwareConcurrency ?? 4;
|
||||
|
||||
if (memory <= 2 || cores <= 2) {
|
||||
// 低端设备
|
||||
loader.loadRadius = 1;
|
||||
loader.unloadRadius = 2;
|
||||
loader.maxLoadsPerFrame = 1;
|
||||
loader.bEnablePrefetch = false;
|
||||
} else if (memory <= 4) {
|
||||
// 中端设备
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 3;
|
||||
loader.maxLoadsPerFrame = 2;
|
||||
} else {
|
||||
// 高端设备
|
||||
loader.loadRadius = 3;
|
||||
loader.unloadRadius = 5;
|
||||
loader.maxLoadsPerFrame = 4;
|
||||
loader.prefetchRadius = 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
158
docs/src/content/docs/modules/world-streaming/index.md
Normal file
158
docs/src/content/docs/modules/world-streaming/index.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: "世界流式加载"
|
||||
description: "基于区块的开放世界流式加载系统"
|
||||
---
|
||||
|
||||
`@esengine/world-streaming` 提供基于区块的世界流式加载与管理,适用于开放世界游戏。根据玩家位置动态加载/卸载世界区块。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/world-streaming
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基础设置
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
StreamingAnchorComponent,
|
||||
ChunkLoaderComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// 创建区块管理器 (512单位区块)
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// 添加流式加载系统
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// 创建加载器实体
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
|
||||
// 创建玩家作为流式锚点
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// 每帧更新锚点位置
|
||||
function update() {
|
||||
anchor.x = player.position.x;
|
||||
anchor.y = player.position.y;
|
||||
}
|
||||
```
|
||||
|
||||
### 程序化生成
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class ProceduralChunkProvider implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// 使用种子+坐标生成确定性随机数
|
||||
const chunkSeed = this.hashCoord(coord);
|
||||
const rng = this.createRNG(chunkSeed);
|
||||
|
||||
// 生成区块内容
|
||||
const entities = this.generateEntities(coord, rng);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// 可选:持久化已修改的区块
|
||||
}
|
||||
|
||||
private hashCoord(coord: IChunkCoord): number {
|
||||
return this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
}
|
||||
|
||||
private createRNG(seed: number) {
|
||||
// 简单的种子随机数生成器
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord, rng: () => number) {
|
||||
// 生成资源、树木等
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 使用数据提供器
|
||||
chunkManager.setDataProvider(new ProceduralChunkProvider(12345));
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 区块生命周期
|
||||
|
||||
```
|
||||
未加载 → 加载中 → 已加载 → 卸载中 → 未加载
|
||||
↓ ↓
|
||||
失败 (发生错误时)
|
||||
```
|
||||
|
||||
### 流式锚点
|
||||
|
||||
`StreamingAnchorComponent` 用于标记作为区块加载锚点的实体。系统会在所有锚点周围加载区块,在超出范围时卸载区块。
|
||||
|
||||
```typescript
|
||||
// StreamingAnchorComponent 实现 IPositionable 接口
|
||||
interface IPositionable {
|
||||
readonly position: { x: number; y: number };
|
||||
}
|
||||
```
|
||||
|
||||
### 配置参数
|
||||
|
||||
| 属性 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `chunkSize` | 512 | 区块大小(世界单位) |
|
||||
| `loadRadius` | 2 | 锚点周围加载的区块半径 |
|
||||
| `unloadRadius` | 4 | 超过此半径的区块会被卸载 |
|
||||
| `maxLoadsPerFrame` | 2 | 每帧最大异步加载数 |
|
||||
| `unloadDelay` | 3000 | 卸载前的延迟(毫秒) |
|
||||
| `bEnablePrefetch` | true | 沿移动方向预加载 |
|
||||
|
||||
## 模块设置(可选)
|
||||
|
||||
使用模块辅助函数快速配置:
|
||||
|
||||
```typescript
|
||||
import { worldStreamingModule } from '@esengine/world-streaming';
|
||||
|
||||
const chunkManager = worldStreamingModule.setup(
|
||||
scene,
|
||||
services,
|
||||
componentRegistry,
|
||||
{ chunkSize: 256, bEnableCulling: true }
|
||||
);
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [区块管理器 API](./chunk-manager) - 加载队列、区块生命周期
|
||||
- [流式系统](./streaming-system) - 基于锚点的加载
|
||||
- [序列化](./serialization) - 自定义区块序列化
|
||||
- [示例](./examples) - 程序化世界、MMO 区块
|
||||
227
docs/src/content/docs/modules/world-streaming/serialization.md
Normal file
227
docs/src/content/docs/modules/world-streaming/serialization.md
Normal file
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: "区块序列化"
|
||||
description: "自定义区块数据序列化"
|
||||
---
|
||||
|
||||
`ChunkSerializer` 负责实体数据与区块存储格式之间的转换。
|
||||
|
||||
## 默认序列化器
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer, ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
const serializer = new ChunkSerializer();
|
||||
const chunkManager = new ChunkManager(512, serializer);
|
||||
```
|
||||
|
||||
## 自定义序列化器
|
||||
|
||||
继承 `ChunkSerializer` 实现自定义序列化逻辑:
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer } from '@esengine/world-streaming';
|
||||
import type { Entity, IScene } from '@esengine/ecs-framework';
|
||||
import type { IChunkCoord, IChunkData, IChunkBounds } from '@esengine/world-streaming';
|
||||
|
||||
class GameChunkSerializer extends ChunkSerializer {
|
||||
/**
|
||||
* 获取实体位置
|
||||
* 重写以使用你的位置组件
|
||||
*/
|
||||
protected getPositionable(entity: Entity) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
return { position: { x: transform.x, y: transform.y } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化后设置实体位置
|
||||
*/
|
||||
protected setEntityPosition(entity: Entity, x: number, y: number): void {
|
||||
const transform = entity.addComponent(new TransformComponent());
|
||||
transform.x = x;
|
||||
transform.y = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化组件
|
||||
*/
|
||||
protected serializeComponents(entity: Entity): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
const resource = entity.getComponent(ResourceComponent);
|
||||
if (resource) {
|
||||
data.ResourceComponent = {
|
||||
type: resource.type,
|
||||
amount: resource.amount,
|
||||
maxAmount: resource.maxAmount
|
||||
};
|
||||
}
|
||||
|
||||
const npc = entity.getComponent(NPCComponent);
|
||||
if (npc) {
|
||||
data.NPCComponent = {
|
||||
id: npc.id,
|
||||
state: npc.state
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化组件
|
||||
*/
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.ResourceComponent) {
|
||||
const data = components.ResourceComponent as any;
|
||||
const resource = entity.addComponent(new ResourceComponent());
|
||||
resource.type = data.type;
|
||||
resource.amount = data.amount;
|
||||
resource.maxAmount = data.maxAmount;
|
||||
}
|
||||
|
||||
if (components.NPCComponent) {
|
||||
const data = components.NPCComponent as any;
|
||||
const npc = entity.addComponent(new NPCComponent());
|
||||
npc.id = data.id;
|
||||
npc.state = data.state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤需要序列化的组件
|
||||
*/
|
||||
protected shouldSerializeComponent(componentName: string): boolean {
|
||||
const include = ['ResourceComponent', 'NPCComponent', 'BuildingComponent'];
|
||||
return include.includes(componentName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 区块数据格式
|
||||
|
||||
```typescript
|
||||
interface IChunkData {
|
||||
coord: IChunkCoord; // 区块坐标
|
||||
entities: ISerializedEntity[]; // 实体数据
|
||||
version: number; // 数据版本
|
||||
}
|
||||
|
||||
interface ISerializedEntity {
|
||||
name: string; // 实体名称
|
||||
localPosition: { x: number; y: number }; // 区块内位置
|
||||
components: Record<string, unknown>; // 组件数据
|
||||
}
|
||||
|
||||
interface IChunkCoord {
|
||||
x: number; // 区块 X 坐标
|
||||
y: number; // 区块 Y 坐标
|
||||
}
|
||||
```
|
||||
|
||||
## 带序列化的数据提供器
|
||||
|
||||
```typescript
|
||||
class DatabaseChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `chunk_${coord.x}_${coord.y}`;
|
||||
const json = await database.get(key);
|
||||
|
||||
if (!json) return null;
|
||||
return JSON.parse(json) as IChunkData;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `chunk_${data.coord.x}_${data.coord.y}`;
|
||||
await database.set(key, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 程序化生成与序列化器
|
||||
|
||||
```typescript
|
||||
class ProceduralProvider implements IChunkDataProvider {
|
||||
private serializer: GameChunkSerializer;
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const entities = this.generateEntities(coord);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord): ISerializedEntity[] {
|
||||
const entities: ISerializedEntity[] = [];
|
||||
const rng = this.createRNG(coord);
|
||||
|
||||
// 生成树木
|
||||
const treeCount = Math.floor(rng() * 10);
|
||||
for (let i = 0; i < treeCount; i++) {
|
||||
entities.push({
|
||||
name: `Tree_${coord.x}_${coord.y}_${i}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
TreeComponent: { type: 'oak', health: 100 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 生成资源
|
||||
if (rng() > 0.7) {
|
||||
entities.push({
|
||||
name: `Resource_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 256, y: 256 },
|
||||
components: {
|
||||
ResourceComponent: {
|
||||
type: 'iron',
|
||||
amount: 500,
|
||||
maxAmount: 500
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 版本迁移
|
||||
|
||||
```typescript
|
||||
class VersionedSerializer extends ChunkSerializer {
|
||||
private static readonly CURRENT_VERSION = 2;
|
||||
|
||||
deserialize(data: IChunkData, scene: IScene): Entity[] {
|
||||
// 迁移旧数据
|
||||
if (data.version < 2) {
|
||||
data = this.migrateV1toV2(data);
|
||||
}
|
||||
|
||||
return super.deserialize(data, scene);
|
||||
}
|
||||
|
||||
private migrateV1toV2(data: IChunkData): IChunkData {
|
||||
// 转换旧组件格式
|
||||
for (const entity of data.entities) {
|
||||
if (entity.components.OldResource) {
|
||||
entity.components.ResourceComponent = entity.components.OldResource;
|
||||
delete entity.components.OldResource;
|
||||
}
|
||||
}
|
||||
|
||||
data.version = 2;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: "流式加载系统"
|
||||
description: "ChunkStreamingSystem 根据锚点位置自动管理区块加载"
|
||||
---
|
||||
|
||||
`ChunkStreamingSystem` 根据 `StreamingAnchorComponent` 的位置自动管理区块的加载和卸载。
|
||||
|
||||
## 设置
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// 创建并配置区块管理器
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// 创建流式系统
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// 创建加载器实体
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
```
|
||||
|
||||
## 流式锚点
|
||||
|
||||
`StreamingAnchorComponent` 标记实体为区块加载锚点。区块会在所有锚点周围加载。
|
||||
|
||||
```typescript
|
||||
// 创建玩家作为流式锚点
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// 每帧更新位置
|
||||
function update() {
|
||||
anchor.x = player.worldX;
|
||||
anchor.y = player.worldY;
|
||||
}
|
||||
```
|
||||
|
||||
### 锚点属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `x` | number | 0 | 世界 X 坐标 |
|
||||
| `y` | number | 0 | 世界 Y 坐标 |
|
||||
| `weight` | number | 1.0 | 加载半径倍数 |
|
||||
| `bEnablePrefetch` | boolean | true | 是否启用预加载 |
|
||||
|
||||
### 多锚点
|
||||
|
||||
```typescript
|
||||
// 主玩家 - 完整加载半径
|
||||
const playerAnchor = player.addComponent(new StreamingAnchorComponent());
|
||||
playerAnchor.weight = 1.0;
|
||||
|
||||
// 相机预览 - 较小半径
|
||||
const cameraAnchor = camera.addComponent(new StreamingAnchorComponent());
|
||||
cameraAnchor.weight = 0.5; // 加载半径减半
|
||||
cameraAnchor.bEnablePrefetch = false;
|
||||
```
|
||||
|
||||
## 加载器配置
|
||||
|
||||
`ChunkLoaderComponent` 配置流式加载行为。
|
||||
|
||||
```typescript
|
||||
const loader = entity.addComponent(new ChunkLoaderComponent());
|
||||
|
||||
// 区块尺寸
|
||||
loader.chunkSize = 512; // 每区块世界单位
|
||||
|
||||
// 加载半径
|
||||
loader.loadRadius = 2; // 锚点周围 2 个区块内加载
|
||||
loader.unloadRadius = 4; // 超过 4 个区块卸载
|
||||
|
||||
// 性能调优
|
||||
loader.maxLoadsPerFrame = 2; // 每帧最大异步加载数
|
||||
loader.maxUnloadsPerFrame = 1; // 每帧最大卸载数
|
||||
loader.unloadDelay = 3000; // 卸载前延迟(毫秒)
|
||||
|
||||
// 预加载
|
||||
loader.bEnablePrefetch = true; // 启用移动方向预加载
|
||||
loader.prefetchRadius = 1; // 预加载额外区块数
|
||||
```
|
||||
|
||||
### 坐标辅助方法
|
||||
|
||||
```typescript
|
||||
// 世界坐标转区块坐标
|
||||
const coord = loader.worldToChunk(1500, 2300);
|
||||
|
||||
// 获取区块边界
|
||||
const bounds = loader.getChunkBounds(coord);
|
||||
```
|
||||
|
||||
## 预加载系统
|
||||
|
||||
启用后,系统会沿移动方向预加载区块:
|
||||
|
||||
```
|
||||
移动方向 →
|
||||
|
||||
[ ][ ][ ] [ ][P][P] P = 预加载
|
||||
[L][L][L] → [L][L][L] L = 已加载
|
||||
[ ][ ][ ] [ ][ ][ ]
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 启用预加载
|
||||
loader.bEnablePrefetch = true;
|
||||
loader.prefetchRadius = 2; // 向前预加载 2 个区块
|
||||
|
||||
// 单独控制锚点的预加载
|
||||
anchor.bEnablePrefetch = true; // 主玩家启用
|
||||
cameraAnchor.bEnablePrefetch = false; // 相机禁用
|
||||
```
|
||||
|
||||
## 系统处理流程
|
||||
|
||||
系统每帧运行:
|
||||
|
||||
1. 更新锚点速度
|
||||
2. 请求加载范围内的区块
|
||||
3. 取消已回到范围内的区块卸载
|
||||
4. 请求卸载超出范围的区块
|
||||
5. 处理加载/卸载队列
|
||||
|
||||
```typescript
|
||||
// 从系统访问区块管理器
|
||||
const system = scene.getSystem(ChunkStreamingSystem);
|
||||
const manager = system?.chunkManager;
|
||||
|
||||
if (manager) {
|
||||
console.log('已加载:', manager.loadedChunkCount);
|
||||
}
|
||||
```
|
||||
|
||||
## 基于优先级的加载
|
||||
|
||||
区块按距离分配加载优先级:
|
||||
|
||||
| 距离 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 0 | Immediate | 玩家当前区块 |
|
||||
| 1 | High | 相邻区块 |
|
||||
| 2-4 | Normal | 附近区块 |
|
||||
| 5+ | Low | 远处区块 |
|
||||
| 预加载 | Prefetch | 移动方向 |
|
||||
|
||||
## 事件
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// 区块就绪 - 生成 NPC,启用碰撞
|
||||
for (const entity of entities) {
|
||||
entity.getComponent(ColliderComponent)?.enable();
|
||||
}
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
// 清理 - 保存状态,释放资源
|
||||
}
|
||||
});
|
||||
```
|
||||
86
packages/editor/editor-app/README.md
Normal file
86
packages/editor/editor-app/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# ESEngine Editor
|
||||
|
||||
A cross-platform desktop visual editor built with Tauri 2.x + React 18.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running the editor, ensure you have the following installed:
|
||||
|
||||
- **Node.js** >= 18.x
|
||||
- **pnpm** >= 10.x
|
||||
- **Rust** >= 1.70 (for Tauri)
|
||||
- **Platform-specific dependencies**:
|
||||
- **Windows**: Microsoft Visual Studio C++ Build Tools
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Linux**: See [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/esengine/esengine.git
|
||||
cd esengine
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. Build Dependencies
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
pnpm build:editor
|
||||
```
|
||||
|
||||
### 3. Run Editor
|
||||
|
||||
```bash
|
||||
cd packages/editor/editor-app
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
## Available Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `pnpm tauri:dev` | Run editor in development mode with hot-reload |
|
||||
| `pnpm tauri:build` | Build production application |
|
||||
| `pnpm build:sdk` | Build editor-runtime SDK |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
editor-app/
|
||||
├── src/ # React application source
|
||||
│ ├── components/ # UI components
|
||||
│ ├── panels/ # Editor panels
|
||||
│ └── services/ # Core services
|
||||
├── src-tauri/ # Tauri (Rust) backend
|
||||
├── public/ # Static assets
|
||||
└── scripts/ # Build scripts
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors
|
||||
|
||||
```bash
|
||||
pnpm clean
|
||||
pnpm install
|
||||
pnpm build:editor
|
||||
```
|
||||
|
||||
### Rust/Tauri Errors
|
||||
|
||||
```bash
|
||||
rustup update
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ESEngine Documentation](https://esengine.cn/)
|
||||
- [Tauri Documentation](https://tauri.app/)
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
86
packages/editor/editor-app/README_CN.md
Normal file
86
packages/editor/editor-app/README_CN.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# ESEngine 编辑器
|
||||
|
||||
基于 Tauri 2.x + React 18 构建的跨平台桌面可视化编辑器。
|
||||
|
||||
## 环境要求
|
||||
|
||||
运行编辑器前,请确保已安装以下环境:
|
||||
|
||||
- **Node.js** >= 18.x
|
||||
- **pnpm** >= 10.x
|
||||
- **Rust** >= 1.70 (Tauri 需要)
|
||||
- **平台相关依赖**:
|
||||
- **Windows**: Microsoft Visual Studio C++ Build Tools
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Linux**: 参考 [Tauri 环境配置](https://tauri.app/v1/guides/getting-started/prerequisites)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 克隆并安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/esengine/esengine.git
|
||||
cd esengine
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 构建依赖
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
pnpm build:editor
|
||||
```
|
||||
|
||||
### 3. 启动编辑器
|
||||
|
||||
```bash
|
||||
cd packages/editor/editor-app
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
## 可用脚本
|
||||
|
||||
| 脚本 | 说明 |
|
||||
|------|------|
|
||||
| `pnpm tauri:dev` | 开发模式运行编辑器(支持热重载)|
|
||||
| `pnpm tauri:build` | 构建生产版本应用 |
|
||||
| `pnpm build:sdk` | 构建 editor-runtime SDK |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
editor-app/
|
||||
├── src/ # React 应用源码
|
||||
│ ├── components/ # UI 组件
|
||||
│ ├── panels/ # 编辑器面板
|
||||
│ └── services/ # 核心服务
|
||||
├── src-tauri/ # Tauri (Rust) 后端
|
||||
├── public/ # 静态资源
|
||||
└── scripts/ # 构建脚本
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 构建错误
|
||||
|
||||
```bash
|
||||
pnpm clean
|
||||
pnpm install
|
||||
pnpm build:editor
|
||||
```
|
||||
|
||||
### Rust/Tauri 错误
|
||||
|
||||
```bash
|
||||
rustup update
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [ESEngine 文档](https://esengine.cn/)
|
||||
- [Tauri 文档](https://tauri.app/)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -9,7 +9,7 @@
|
||||
"build": "npm run build:sdk && tsc && vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"tauri": "tauri",
|
||||
"copy-modules": "node ../../scripts/copy-engine-modules.mjs",
|
||||
"copy-modules": "node ../../../scripts/copy-engine-modules.mjs",
|
||||
"tauri:dev": "npm run build:sdk && npm run copy-modules && tauri dev",
|
||||
"bundle:runtime": "node scripts/bundle-runtime.mjs",
|
||||
"tauri:build": "npm run build:sdk && npm run copy-modules && npm run bundle:runtime && tauri build",
|
||||
|
||||
1010
packages/editor/editor-app/src-tauri/Cargo.lock
generated
1010
packages/editor/editor-app/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,16 +10,16 @@ name = "ecs_editor_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = ["protocol-asset"] }
|
||||
tauri-plugin-shell = "2.0"
|
||||
tauri-plugin-dialog = "2.0"
|
||||
tauri-plugin-fs = "2.0"
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-http = "2.0"
|
||||
tauri-plugin-cli = "2.0"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-cli = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
glob = "0.3"
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/node-editor": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @zh ESEngine 行为树运行时模块
|
||||
* @en ESEngine Behavior Tree Runtime Module
|
||||
*
|
||||
* @zh 纯运行时模块,不依赖 asset-system。资产加载由编辑器在 install 时注册。
|
||||
* @en Pure runtime module, no asset-system dependency. Asset loading is registered by editor during install.
|
||||
*/
|
||||
|
||||
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, SystemContext } from '@esengine/engine-core';
|
||||
|
||||
import {
|
||||
BehaviorTreeRuntimeComponent,
|
||||
BehaviorTreeExecutionSystem,
|
||||
BehaviorTreeAssetManager,
|
||||
GlobalBlackboardService,
|
||||
BehaviorTreeSystemToken
|
||||
} from '@esengine/behavior-tree';
|
||||
|
||||
export class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(BehaviorTreeRuntimeComponent);
|
||||
}
|
||||
|
||||
registerServices(services: ServiceContainer): void {
|
||||
if (!services.isRegistered(GlobalBlackboardService)) {
|
||||
services.registerSingleton(GlobalBlackboardService);
|
||||
}
|
||||
if (!services.isRegistered(BehaviorTreeAssetManager)) {
|
||||
services.registerSingleton(BehaviorTreeAssetManager);
|
||||
}
|
||||
}
|
||||
|
||||
createSystems(scene: IScene, context: SystemContext): void {
|
||||
const ecsServices = (context as { ecsServices?: ServiceContainer }).ecsServices;
|
||||
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(ecsServices);
|
||||
|
||||
if (context.isEditor) {
|
||||
behaviorTreeSystem.enabled = false;
|
||||
}
|
||||
|
||||
scene.addSystem(behaviorTreeSystem);
|
||||
context.services.register(BehaviorTreeSystemToken, behaviorTreeSystem);
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,11 @@ import {
|
||||
LocaleService,
|
||||
} from '@esengine/editor-runtime';
|
||||
|
||||
// Runtime imports from @esengine/behavior-tree package
|
||||
import { BehaviorTreeRuntimeComponent, BehaviorTreeRuntimeModule } from '@esengine/behavior-tree';
|
||||
// Runtime imports
|
||||
import { BehaviorTreeRuntimeComponent, BehaviorTreeAssetType } from '@esengine/behavior-tree';
|
||||
import { AssetManagerToken } from '@esengine/asset-system';
|
||||
import { BehaviorTreeRuntimeModule } from './BehaviorTreeRuntimeModule';
|
||||
import { BehaviorTreeLoader } from './runtime/BehaviorTreeLoader';
|
||||
|
||||
// Editor components and services
|
||||
import { BehaviorTreeService } from './services/BehaviorTreeService';
|
||||
@@ -71,6 +74,10 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
|
||||
// 设置插件上下文
|
||||
PluginContext.setServices(services);
|
||||
|
||||
// 注册行为树资产加载器到 AssetManager
|
||||
// Register behavior tree asset loader to AssetManager
|
||||
this.registerAssetLoader();
|
||||
|
||||
// 注册服务
|
||||
this.registerServices(services);
|
||||
|
||||
@@ -92,6 +99,22 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
|
||||
logger.info('BehaviorTree editor module installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册行为树资产加载器
|
||||
* Register behavior tree asset loader
|
||||
*/
|
||||
private registerAssetLoader(): void {
|
||||
try {
|
||||
const assetManager = PluginAPI.resolve(AssetManagerToken);
|
||||
if (assetManager) {
|
||||
assetManager.registerLoader(BehaviorTreeAssetType, new BehaviorTreeLoader());
|
||||
logger.info('BehaviorTree asset loader registered');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to register asset loader:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private registerAssetCreationMappings(services: ServiceContainer): void {
|
||||
try {
|
||||
const fileActionRegistry = services.resolve<FileActionRegistry>(IFileActionRegistry);
|
||||
@@ -376,7 +399,7 @@ export const BehaviorTreePlugin: IEditorPlugin = {
|
||||
editorModule: new BehaviorTreeEditorModule(),
|
||||
};
|
||||
|
||||
export { BehaviorTreeRuntimeModule };
|
||||
// BehaviorTreeRuntimeModule is internal, not re-exported
|
||||
|
||||
// Re-exports for editor functionality
|
||||
export { PluginContext } from './PluginContext';
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @zh ESEngine 资产加载器
|
||||
* @en ESEngine asset loader
|
||||
* @internal
|
||||
*/
|
||||
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BehaviorTreeAssetManager,
|
||||
EditorToBehaviorTreeDataConverter,
|
||||
BehaviorTreeAssetType,
|
||||
type BehaviorTreeData
|
||||
} from '@esengine/behavior-tree';
|
||||
|
||||
/**
|
||||
* @zh 行为树资产接口
|
||||
* @en Behavior tree asset interface
|
||||
* @internal
|
||||
*/
|
||||
export interface IBehaviorTreeAsset {
|
||||
data: BehaviorTreeData;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 行为树加载器
|
||||
* @en Behavior tree loader implementing IAssetLoader interface
|
||||
* @internal
|
||||
*/
|
||||
export class BehaviorTreeLoader {
|
||||
readonly supportedType = BehaviorTreeAssetType;
|
||||
readonly supportedExtensions = ['.btree'];
|
||||
readonly contentType = 'text' as const;
|
||||
|
||||
async parse(content: { text?: string }, context: { metadata: { path: string } }): Promise<IBehaviorTreeAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('Behavior tree content is empty');
|
||||
}
|
||||
|
||||
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content.text);
|
||||
const assetPath = context.metadata.path;
|
||||
treeData.id = assetPath;
|
||||
|
||||
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
|
||||
if (btAssetManager) {
|
||||
btAssetManager.loadAsset(treeData);
|
||||
}
|
||||
|
||||
return {
|
||||
data: treeData,
|
||||
path: assetPath
|
||||
};
|
||||
}
|
||||
|
||||
dispose(asset: IBehaviorTreeAsset): void {
|
||||
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
|
||||
if (btAssetManager && asset.data) {
|
||||
btAssetManager.unloadAsset(asset.data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,15 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineConfig } from 'tsup';
|
||||
import { editorOnlyPreset } from '../../../tools/build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...editorOnlyPreset(),
|
||||
...editorOnlyPreset({
|
||||
external: ['@esengine/asset-system']
|
||||
}),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
|
||||
13
packages/editor/plugins/fairygui-editor/tsconfig.build.json
Normal file
13
packages/editor/plugins/fairygui-editor/tsconfig.build.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../build-config/tsconfig.json",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
tsconfig: 'tsconfig.build.json',
|
||||
external: [
|
||||
'react',
|
||||
'react-dom',
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# @esengine/behavior-tree
|
||||
|
||||
## 3.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @esengine/ecs-framework@2.6.0
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
|
||||
## 1.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/behavior-tree",
|
||||
"version": "1.0.3",
|
||||
"version": "3.0.0",
|
||||
"description": "ECS-based AI behavior tree system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# @esengine/blueprint
|
||||
|
||||
## 3.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @esengine/ecs-framework@2.6.0
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/blueprint",
|
||||
"version": "1.0.2",
|
||||
"version": "3.0.0",
|
||||
"description": "Visual scripting system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,5 +1,161 @@
|
||||
# @esengine/ecs-framework
|
||||
|
||||
## 2.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- feat(ecs): 添加 @NetworkEntity 装饰器,支持自动广播实体生成/销毁
|
||||
|
||||
### 新功能
|
||||
|
||||
**@NetworkEntity 装饰器**
|
||||
- 标记组件为网络实体,自动广播 spawn/despawn 消息
|
||||
- 支持 `autoSpawn` 和 `autoDespawn` 配置选项
|
||||
- 通过事件系统(`ECSEventType.COMPONENT_ADDED` / `ECSEventType.ENTITY_DESTROYED`)实现
|
||||
|
||||
**ECSRoom 增强**
|
||||
- 新增 `enableAutoNetworkEntity` 配置选项(默认启用)
|
||||
- 自动监听组件添加和实体销毁事件
|
||||
- 简化 GameRoom 实现,无需手动回调
|
||||
|
||||
### 改进
|
||||
|
||||
**Entity 事件**
|
||||
- `Entity.destroy()` 现在发出 `entity:destroyed` 事件
|
||||
- `Entity.active` 变化时发出 `entity:enabled` / `entity:disabled` 事件
|
||||
- 使用 `ECSEventType` 常量替代硬编码字符串
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync, NetworkEntity } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Enemy')
|
||||
@NetworkEntity('Enemy')
|
||||
class EnemyComponent extends Component {
|
||||
@sync('float32') x: number = 0;
|
||||
@sync('float32') y: number = 0;
|
||||
}
|
||||
|
||||
// 服务端
|
||||
const entity = scene.createEntity('Enemy');
|
||||
entity.addComponent(new EnemyComponent()); // 自动广播 spawn
|
||||
entity.destroy(); // 自动广播 despawn
|
||||
```
|
||||
|
||||
## 2.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#392](https://github.com/esengine/esengine/pull/392) [`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76) Thanks [@esengine](https://github.com/esengine)! - fix(sync): Decoder 现在使用 GlobalComponentRegistry 查找组件 | Decoder now uses GlobalComponentRegistry for component lookup
|
||||
|
||||
**问题 | Problem:**
|
||||
1. `Decoder.ts` 有自己独立的 `componentRegistry` Map,与 `GlobalComponentRegistry` 完全分离。这导致通过 `@ECSComponent` 装饰器注册的组件在网络反序列化时找不到,产生 "Unknown component type" 错误。
|
||||
2. `@sync` 装饰器使用 `constructor.name` 作为 `typeId`,而不是 `@ECSComponent` 装饰器指定的名称,导致编码和解码使用不同的类型 ID。
|
||||
3. `Decoder.ts` had its own local `componentRegistry` Map that was completely separate from `GlobalComponentRegistry`. This caused components registered via `@ECSComponent` decorator to not be found during network deserialization, resulting in "Unknown component type" errors.
|
||||
4. `@sync` decorator used `constructor.name` as `typeId` instead of the name specified by `@ECSComponent` decorator, causing encoding and decoding to use different type IDs.
|
||||
|
||||
**修改 | Changes:**
|
||||
- 从 Decoder.ts 中移除本地 `componentRegistry`
|
||||
- 更新 `decodeEntity` 和 `decodeSpawn` 使用 `GlobalComponentRegistry.getComponentType()`
|
||||
- 移除已废弃的 `registerSyncComponent` 和 `autoRegisterSyncComponent` 函数
|
||||
- 更新 `@sync` 装饰器使用 `getComponentTypeName()` 获取组件类型名称
|
||||
- 更新 `@ECSComponent` 装饰器同步更新 `SYNC_METADATA.typeId`
|
||||
- Removed local `componentRegistry` from Decoder.ts
|
||||
- Updated `decodeEntity` and `decodeSpawn` to use `GlobalComponentRegistry.getComponentType()`
|
||||
- Removed deprecated `registerSyncComponent` and `autoRegisterSyncComponent` functions
|
||||
- Updated `@sync` decorator to use `getComponentTypeName()` for component type name
|
||||
- Updated `@ECSComponent` decorator to sync update `SYNC_METADATA.typeId`
|
||||
|
||||
现在使用 `@ECSComponent` 装饰器的组件会自动可用于网络同步解码,无需手动注册。
|
||||
|
||||
Now `@ECSComponent` decorated components are automatically available for network sync decoding without any manual registration.
|
||||
|
||||
## 2.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#390](https://github.com/esengine/esengine/pull/390) [`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256) Thanks [@esengine](https://github.com/esengine)! - feat: ECS 网络状态同步系统
|
||||
|
||||
## @esengine/ecs-framework
|
||||
|
||||
新增 `@sync` 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync('string') name: string = '';
|
||||
@sync('uint16') score: number = 0;
|
||||
@sync('float32') x: number = 0;
|
||||
@sync('float32') y: number = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 新增导出
|
||||
- `sync` - 标记需要同步的字段装饰器
|
||||
- `SyncType` - 支持的同步类型
|
||||
- `SyncOperation` - 同步操作类型(FULL/DELTA/SPAWN/DESPAWN)
|
||||
- `encodeSnapshot` / `decodeSnapshot` - 批量编解码
|
||||
- `encodeSpawn` / `decodeSpawn` - 实体生成编解码
|
||||
- `encodeDespawn` / `processDespawn` - 实体销毁编解码
|
||||
- `ChangeTracker` - 字段级变更追踪
|
||||
- `initChangeTracker` / `clearChanges` / `hasChanges` - 变更追踪工具函数
|
||||
|
||||
### 内部方法标记
|
||||
|
||||
将以下方法标记为 `@internal`,用户应通过 `Core.update()` 驱动更新:
|
||||
- `Scene.update()`
|
||||
- `SceneManager.update()`
|
||||
- `WorldManager.updateAll()`
|
||||
|
||||
## @esengine/network
|
||||
|
||||
新增 `ComponentSyncSystem`,基于 `@sync` 装饰器自动同步组件状态:
|
||||
|
||||
```typescript
|
||||
import { ComponentSyncSystem } from '@esengine/network';
|
||||
|
||||
// 服务端:编码状态
|
||||
const data = syncSystem.encodeAllEntities(false);
|
||||
|
||||
// 客户端:解码状态
|
||||
syncSystem.applySnapshot(data);
|
||||
```
|
||||
|
||||
### 修复
|
||||
- 将 `@esengine/ecs-framework` 从 devDependencies 移到 peerDependencies
|
||||
|
||||
## @esengine/server
|
||||
|
||||
新增 `ECSRoom`,带有 ECS World 支持的房间基类:
|
||||
|
||||
```typescript
|
||||
import { ECSRoom } from '@esengine/server/ecs';
|
||||
|
||||
// 服务端启动
|
||||
Core.create();
|
||||
setInterval(() => Core.update(1 / 60), 16);
|
||||
|
||||
// 定义房间
|
||||
class GameRoom extends ECSRoom {
|
||||
onCreate() {
|
||||
this.addSystem(new PhysicsSystem());
|
||||
}
|
||||
|
||||
onJoin(player: Player) {
|
||||
const entity = this.createPlayerEntity(player.id);
|
||||
entity.addComponent(new PlayerComponent());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 设计
|
||||
- 每个 `ECSRoom` 在 `Core.worldManager` 中创建独立的 World
|
||||
- `Core.update()` 统一更新 Time 和所有 World
|
||||
- `onTick()` 只处理状态同步逻辑
|
||||
|
||||
## 2.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "@esengine/ecs-framework",
|
||||
"version": "2.4.4",
|
||||
"version": "2.6.0",
|
||||
"description": "用于Laya、Cocos Creator等JavaScript游戏引擎的高性能ECS框架",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"unpkg": "dist/index.umd.js",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
"require": "./dist/index.cjs",
|
||||
"source": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -50,23 +52,24 @@
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.27.1",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@rollup/plugin-babel": "^6.0.4",
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.19.17",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"eslint": "^9.37.0",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"rollup": "^4.42.0",
|
||||
"rollup-plugin-dts": "^6.2.1",
|
||||
"rollup-plugin-sourcemaps": "^0.6.3",
|
||||
"ts-jest": "^29.4.0",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.46.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -10,10 +10,16 @@ import { Int32 } from './Core/SoAStorage';
|
||||
* @en Components in ECS architecture should be pure data containers.
|
||||
* All game logic should be implemented in EntitySystem, not inside components.
|
||||
*
|
||||
* @zh **重要:所有 Component 子类都必须使用 @ECSComponent 装饰器!**
|
||||
* @zh 该装饰器用于注册组件类型名称,是序列化、网络同步等功能正常工作的前提。
|
||||
* @en **IMPORTANT: All Component subclasses MUST use the @ECSComponent decorator!**
|
||||
* @en This decorator registers the component type name, which is required for serialization, network sync, etc.
|
||||
*
|
||||
* @example
|
||||
* @zh 推荐做法:纯数据组件
|
||||
* @en Recommended: Pure data component
|
||||
* @zh 正确做法:使用 @ECSComponent 装饰器
|
||||
* @en Correct: Use @ECSComponent decorator
|
||||
* ```typescript
|
||||
* @ECSComponent('HealthComponent')
|
||||
* class HealthComponent extends Component {
|
||||
* public health: number = 100;
|
||||
* public maxHealth: number = 100;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type ComponentEditorOptions,
|
||||
type ComponentType
|
||||
} from '../Core/ComponentStorage/ComponentTypeUtils';
|
||||
import { SYNC_METADATA, type SyncMetadata } from '../Sync/types';
|
||||
|
||||
/**
|
||||
* 存储系统类型名称的Symbol键
|
||||
@@ -138,6 +139,14 @@ export function ECSComponent(typeName: string, options?: ComponentOptions) {
|
||||
metadata[COMPONENT_EDITOR_OPTIONS] = options.editor;
|
||||
}
|
||||
|
||||
// 更新 @sync 装饰器创建的 SYNC_METADATA.typeId(如果存在)
|
||||
// Update SYNC_METADATA.typeId created by @sync decorator (if exists)
|
||||
// Property decorators execute before class decorators, so @sync may have used constructor.name
|
||||
const syncMeta = (target as any)[SYNC_METADATA] as SyncMetadata | undefined;
|
||||
if (syncMeta) {
|
||||
syncMeta.typeId = typeName;
|
||||
}
|
||||
|
||||
// 自动注册到全局 ComponentRegistry,使组件可以通过名称查找
|
||||
// Auto-register to GlobalComponentRegistry, enabling lookup by name
|
||||
GlobalComponentRegistry.register(target);
|
||||
|
||||
@@ -7,14 +7,7 @@ import { getComponentInstanceTypeName, getComponentTypeName } from './Decorators
|
||||
import { generateGUID } from '../Utils/GUID';
|
||||
import type { IScene } from './IScene';
|
||||
import { EntityHandle, NULL_HANDLE } from './Core/EntityHandle';
|
||||
|
||||
/**
|
||||
* @zh 组件活跃状态变化接口
|
||||
* @en Interface for component active state change
|
||||
*/
|
||||
interface IActiveChangeable {
|
||||
onActiveChanged(): void;
|
||||
}
|
||||
import { ECSEventType } from './CoreEvents';
|
||||
|
||||
/**
|
||||
* @zh 比较两个实体的优先级
|
||||
@@ -482,7 +475,7 @@ export class Entity {
|
||||
}
|
||||
|
||||
if (this.scene.eventSystem) {
|
||||
this.scene.eventSystem.emitSync('component:added', {
|
||||
this.scene.eventSystem.emitSync(ECSEventType.COMPONENT_ADDED, {
|
||||
timestamp: Date.now(),
|
||||
source: 'Entity',
|
||||
entityId: this.id,
|
||||
@@ -639,7 +632,7 @@ export class Entity {
|
||||
component.entityId = null;
|
||||
|
||||
if (this.scene?.eventSystem) {
|
||||
this.scene.eventSystem.emitSync('component:removed', {
|
||||
this.scene.eventSystem.emitSync(ECSEventType.COMPONENT_REMOVED, {
|
||||
timestamp: Date.now(),
|
||||
source: 'Entity',
|
||||
entityId: this.id,
|
||||
@@ -770,19 +763,23 @@ export class Entity {
|
||||
}
|
||||
|
||||
/**
|
||||
* 活跃状态改变时的回调
|
||||
* @zh 活跃状态改变时的回调
|
||||
* @en Callback when active state changes
|
||||
*
|
||||
* @zh 通过事件系统发出 ENTITY_ENABLED 或 ENTITY_DISABLED 事件,
|
||||
* 组件可以通过监听这些事件来响应实体状态变化。
|
||||
* @en Emits ENTITY_ENABLED or ENTITY_DISABLED event through the event system.
|
||||
* Components can listen to these events to respond to entity state changes.
|
||||
*/
|
||||
private onActiveChanged(): void {
|
||||
for (const component of this.components) {
|
||||
if ('onActiveChanged' in component && typeof component.onActiveChanged === 'function') {
|
||||
(component as IActiveChangeable).onActiveChanged();
|
||||
}
|
||||
}
|
||||
if (this.scene?.eventSystem) {
|
||||
const eventType = this._active
|
||||
? ECSEventType.ENTITY_ENABLED
|
||||
: ECSEventType.ENTITY_DISABLED;
|
||||
|
||||
if (this.scene && this.scene.eventSystem) {
|
||||
this.scene.eventSystem.emitSync('entity:activeChanged', {
|
||||
this.scene.eventSystem.emitSync(eventType, {
|
||||
entity: this,
|
||||
active: this._active
|
||||
scene: this.scene,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -801,6 +798,15 @@ export class Entity {
|
||||
|
||||
this._isDestroyed = true;
|
||||
|
||||
// 在清理之前发出销毁事件(组件仍然可访问)
|
||||
if (this.scene?.eventSystem) {
|
||||
this.scene.eventSystem.emitSync(ECSEventType.ENTITY_DESTROYED, {
|
||||
entity: this,
|
||||
entityId: this.id,
|
||||
scene: this.scene,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.scene && this.scene.referenceTracker) {
|
||||
this.scene.referenceTracker.clearReferencesTo(this.id);
|
||||
this.scene.referenceTracker.unregisterEntityScene(this.id);
|
||||
|
||||
@@ -508,7 +508,9 @@ export class Scene implements IScene {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新场景
|
||||
* @zh 更新场景
|
||||
* @en Update scene
|
||||
* @internal 由 SceneManager 或 World 调用,用户不应直接调用
|
||||
*/
|
||||
public update() {
|
||||
this.epochManager.increment();
|
||||
|
||||
@@ -240,18 +240,9 @@ export class SceneManager implements IService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新场景
|
||||
*
|
||||
* 应该在每帧的游戏循环中调用。
|
||||
* 会自动处理延迟场景切换。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* function gameLoop(deltaTime: number) {
|
||||
* Core.update(deltaTime);
|
||||
* sceneManager.update(); // 每帧调用
|
||||
* }
|
||||
* ```
|
||||
* @zh 更新场景
|
||||
* @en Update scene
|
||||
* @internal 由 Core.update() 调用,用户不应直接调用
|
||||
*/
|
||||
public update(): void {
|
||||
// 处理延迟场景切换
|
||||
|
||||
125
packages/framework/core/src/ECS/Sync/ChangeTracker.ts
Normal file
125
packages/framework/core/src/ECS/Sync/ChangeTracker.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @zh 组件变更追踪器
|
||||
* @en Component change tracker
|
||||
*
|
||||
* @zh 用于追踪 @sync 标记字段的变更,支持增量同步
|
||||
* @en Tracks changes to @sync marked fields for delta synchronization
|
||||
*/
|
||||
export class ChangeTracker {
|
||||
/**
|
||||
* @zh 脏字段索引集合
|
||||
* @en Set of dirty field indices
|
||||
*/
|
||||
private _dirtyFields: Set<number> = new Set();
|
||||
|
||||
/**
|
||||
* @zh 是否有任何变更
|
||||
* @en Whether there are any changes
|
||||
*/
|
||||
private _hasChanges: boolean = false;
|
||||
|
||||
/**
|
||||
* @zh 上次同步的时间戳
|
||||
* @en Last sync timestamp
|
||||
*/
|
||||
private _lastSyncTime: number = 0;
|
||||
|
||||
/**
|
||||
* @zh 标记字段为脏
|
||||
* @en Mark field as dirty
|
||||
*
|
||||
* @param fieldIndex - @zh 字段索引 @en Field index
|
||||
*/
|
||||
public setDirty(fieldIndex: number): void {
|
||||
this._dirtyFields.add(fieldIndex);
|
||||
this._hasChanges = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有变更
|
||||
* @en Check if there are any changes
|
||||
*/
|
||||
public hasChanges(): boolean {
|
||||
return this._hasChanges;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查特定字段是否脏
|
||||
* @en Check if a specific field is dirty
|
||||
*
|
||||
* @param fieldIndex - @zh 字段索引 @en Field index
|
||||
*/
|
||||
public isDirty(fieldIndex: number): boolean {
|
||||
return this._dirtyFields.has(fieldIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有脏字段索引
|
||||
* @en Get all dirty field indices
|
||||
*/
|
||||
public getDirtyFields(): number[] {
|
||||
return Array.from(this._dirtyFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取脏字段数量
|
||||
* @en Get number of dirty fields
|
||||
*/
|
||||
public getDirtyCount(): number {
|
||||
return this._dirtyFields.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除所有变更标记
|
||||
* @en Clear all change marks
|
||||
*/
|
||||
public clear(): void {
|
||||
this._dirtyFields.clear();
|
||||
this._hasChanges = false;
|
||||
this._lastSyncTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除特定字段的变更标记
|
||||
* @en Clear change mark for a specific field
|
||||
*
|
||||
* @param fieldIndex - @zh 字段索引 @en Field index
|
||||
*/
|
||||
public clearField(fieldIndex: number): void {
|
||||
this._dirtyFields.delete(fieldIndex);
|
||||
if (this._dirtyFields.size === 0) {
|
||||
this._hasChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取上次同步时间
|
||||
* @en Get last sync time
|
||||
*/
|
||||
public get lastSyncTime(): number {
|
||||
return this._lastSyncTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 标记所有字段为脏(用于首次同步)
|
||||
* @en Mark all fields as dirty (for initial sync)
|
||||
*
|
||||
* @param fieldCount - @zh 字段数量 @en Field count
|
||||
*/
|
||||
public markAllDirty(fieldCount: number): void {
|
||||
for (let i = 0; i < fieldCount; i++) {
|
||||
this._dirtyFields.add(i);
|
||||
}
|
||||
this._hasChanges = fieldCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置追踪器
|
||||
* @en Reset tracker
|
||||
*/
|
||||
public reset(): void {
|
||||
this._dirtyFields.clear();
|
||||
this._hasChanges = false;
|
||||
this._lastSyncTime = 0;
|
||||
}
|
||||
}
|
||||
147
packages/framework/core/src/ECS/Sync/NetworkEntityDecorator.ts
Normal file
147
packages/framework/core/src/ECS/Sync/NetworkEntityDecorator.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @zh 网络实体装饰器
|
||||
* @en Network entity decorator
|
||||
*
|
||||
* @zh 提供 @NetworkEntity 装饰器,用于标记需要自动广播生成/销毁的组件
|
||||
* @en Provides @NetworkEntity decorator to mark components for automatic spawn/despawn broadcasting
|
||||
*/
|
||||
|
||||
/**
|
||||
* @zh 网络实体元数据的 Symbol 键
|
||||
* @en Symbol key for network entity metadata
|
||||
*/
|
||||
export const NETWORK_ENTITY_METADATA = Symbol('NetworkEntityMetadata');
|
||||
|
||||
/**
|
||||
* @zh 网络实体元数据
|
||||
* @en Network entity metadata
|
||||
*/
|
||||
export interface NetworkEntityMetadata {
|
||||
/**
|
||||
* @zh 预制体类型名称(用于客户端重建实体)
|
||||
* @en Prefab type name (used by client to reconstruct entity)
|
||||
*/
|
||||
prefabType: string;
|
||||
|
||||
/**
|
||||
* @zh 是否自动广播生成
|
||||
* @en Whether to auto-broadcast spawn
|
||||
* @default true
|
||||
*/
|
||||
autoSpawn: boolean;
|
||||
|
||||
/**
|
||||
* @zh 是否自动广播销毁
|
||||
* @en Whether to auto-broadcast despawn
|
||||
* @default true
|
||||
*/
|
||||
autoDespawn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 网络实体装饰器配置选项
|
||||
* @en Network entity decorator options
|
||||
*/
|
||||
export interface NetworkEntityOptions {
|
||||
/**
|
||||
* @zh 是否自动广播生成
|
||||
* @en Whether to auto-broadcast spawn
|
||||
* @default true
|
||||
*/
|
||||
autoSpawn?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 是否自动广播销毁
|
||||
* @en Whether to auto-broadcast despawn
|
||||
* @default true
|
||||
*/
|
||||
autoDespawn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 网络实体装饰器
|
||||
* @en Network entity decorator
|
||||
*
|
||||
* @zh 标记组件类为网络实体。当包含此组件的实体被创建或销毁时,
|
||||
* ECSRoom 会自动广播相应的 spawn/despawn 消息给所有客户端。
|
||||
* @en Marks a component class as a network entity. When an entity containing
|
||||
* this component is created or destroyed, ECSRoom will automatically broadcast
|
||||
* the corresponding spawn/despawn messages to all clients.
|
||||
*
|
||||
* @param prefabType - @zh 预制体类型名称 @en Prefab type name
|
||||
* @param options - @zh 可选配置 @en Optional configuration
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Component, ECSComponent, NetworkEntity, sync } 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
|
||||
* const enemy = scene.createEntity('Enemy');
|
||||
* enemy.addComponent(new EnemyComponent()); // 自动广播给所有客户端
|
||||
*
|
||||
* // 当实体销毁时,自动广播 despawn
|
||||
* enemy.destroy(); // 自动广播给所有客户端
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 只自动广播生成,销毁由手动控制
|
||||
* @ECSComponent('Bullet')
|
||||
* @NetworkEntity('Bullet', { autoDespawn: false })
|
||||
* class BulletComponent extends Component {
|
||||
* @sync('float32') x: number = 0;
|
||||
* @sync('float32') y: number = 0;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function NetworkEntity(prefabType: string, options?: NetworkEntityOptions) {
|
||||
return function <T extends new (...args: any[]) => any>(target: T): T {
|
||||
const metadata: NetworkEntityMetadata = {
|
||||
prefabType,
|
||||
autoSpawn: options?.autoSpawn ?? true,
|
||||
autoDespawn: options?.autoDespawn ?? true,
|
||||
};
|
||||
|
||||
(target as any)[NETWORK_ENTITY_METADATA] = metadata;
|
||||
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取组件类的网络实体元数据
|
||||
* @en Get network entity metadata for a component class
|
||||
*
|
||||
* @param componentClass - @zh 组件类或组件实例 @en Component class or instance
|
||||
* @returns @zh 网络实体元数据,如果不存在则返回 null @en Network entity metadata, or null if not exists
|
||||
*/
|
||||
export function getNetworkEntityMetadata(componentClass: any): NetworkEntityMetadata | null {
|
||||
if (!componentClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const constructor = typeof componentClass === 'function'
|
||||
? componentClass
|
||||
: componentClass.constructor;
|
||||
|
||||
return constructor[NETWORK_ENTITY_METADATA] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查组件是否标记为网络实体
|
||||
* @en Check if a component is marked as a network entity
|
||||
*
|
||||
* @param component - @zh 组件类或组件实例 @en Component class or instance
|
||||
* @returns @zh 如果是网络实体返回 true @en Returns true if is a network entity
|
||||
*/
|
||||
export function isNetworkEntity(component: any): boolean {
|
||||
return getNetworkEntityMetadata(component) !== null;
|
||||
}
|
||||
219
packages/framework/core/src/ECS/Sync/decorators.ts
Normal file
219
packages/framework/core/src/ECS/Sync/decorators.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @zh 网络同步装饰器
|
||||
* @en Network synchronization decorators
|
||||
*
|
||||
* @zh 提供 @sync 装饰器,用于标记需要网络同步的 Component 字段
|
||||
* @en Provides @sync decorator to mark Component fields for network synchronization
|
||||
*/
|
||||
|
||||
import type { SyncType, SyncFieldMetadata, SyncMetadata } from './types';
|
||||
import { SYNC_METADATA, CHANGE_TRACKER } from './types';
|
||||
import { ChangeTracker } from './ChangeTracker';
|
||||
import { getComponentTypeName } from '../Core/ComponentStorage/ComponentTypeUtils';
|
||||
|
||||
/**
|
||||
* @zh 获取或创建组件的同步元数据
|
||||
* @en Get or create sync metadata for a component class
|
||||
*
|
||||
* @param target - @zh 组件类的原型 @en Component class prototype
|
||||
* @returns @zh 同步元数据 @en Sync metadata
|
||||
*/
|
||||
function getOrCreateSyncMetadata(target: any): SyncMetadata {
|
||||
const constructor = target.constructor;
|
||||
|
||||
// Check if has own metadata (not inherited)
|
||||
const hasOwnMetadata = Object.prototype.hasOwnProperty.call(constructor, SYNC_METADATA);
|
||||
|
||||
if (hasOwnMetadata) {
|
||||
return constructor[SYNC_METADATA];
|
||||
}
|
||||
|
||||
// Check for inherited metadata
|
||||
const inheritedMetadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
|
||||
|
||||
// Create new metadata (copy from inherited if exists)
|
||||
// Use getComponentTypeName to get @ECSComponent decorator name, or fall back to constructor.name
|
||||
const metadata: SyncMetadata = {
|
||||
typeId: getComponentTypeName(constructor),
|
||||
fields: inheritedMetadata ? [...inheritedMetadata.fields] : [],
|
||||
fieldIndexMap: inheritedMetadata ? new Map(inheritedMetadata.fieldIndexMap) : new Map()
|
||||
};
|
||||
|
||||
constructor[SYNC_METADATA] = metadata;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 同步字段装饰器
|
||||
* @en Sync field decorator
|
||||
*
|
||||
* @zh 标记 Component 字段为可网络同步。被标记的字段会自动追踪变更,
|
||||
* 并在值修改时触发变更追踪器。
|
||||
* @en Marks a Component field for network synchronization. Marked fields
|
||||
* automatically track changes and trigger the change tracker on modification.
|
||||
*
|
||||
* @param type - @zh 字段的同步类型 @en Sync type of the field
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
* import { 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 的字段不会同步
|
||||
* // Fields without @sync will not be synchronized
|
||||
* localData: any;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function sync(type: SyncType) {
|
||||
return function (target: any, propertyKey: string) {
|
||||
const metadata = getOrCreateSyncMetadata(target);
|
||||
|
||||
// Assign field index (auto-increment based on field count)
|
||||
const fieldIndex = metadata.fields.length;
|
||||
|
||||
// Create field metadata
|
||||
const fieldMeta: SyncFieldMetadata = {
|
||||
index: fieldIndex,
|
||||
name: propertyKey,
|
||||
type: type
|
||||
};
|
||||
|
||||
// Register field
|
||||
metadata.fields.push(fieldMeta);
|
||||
metadata.fieldIndexMap.set(propertyKey, fieldIndex);
|
||||
|
||||
// Store original property key for getter/setter
|
||||
const privateKey = `_sync_${propertyKey}`;
|
||||
|
||||
// Define getter/setter to intercept value changes
|
||||
Object.defineProperty(target, propertyKey, {
|
||||
get() {
|
||||
return this[privateKey];
|
||||
},
|
||||
set(value: any) {
|
||||
const oldValue = this[privateKey];
|
||||
if (oldValue !== value) {
|
||||
this[privateKey] = value;
|
||||
// Trigger change tracker if exists
|
||||
const tracker = this[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (tracker) {
|
||||
tracker.setDirty(fieldIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取组件类的同步元数据
|
||||
* @en Get sync metadata for a component class
|
||||
*
|
||||
* @param componentClass - @zh 组件类或组件实例 @en Component class or instance
|
||||
* @returns @zh 同步元数据,如果不存在则返回 null @en Sync metadata, or null if not exists
|
||||
*/
|
||||
export function getSyncMetadata(componentClass: any): SyncMetadata | null {
|
||||
if (!componentClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const constructor = typeof componentClass === 'function'
|
||||
? componentClass
|
||||
: componentClass.constructor;
|
||||
|
||||
return constructor[SYNC_METADATA] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查组件是否有同步字段
|
||||
* @en Check if a component has sync fields
|
||||
*
|
||||
* @param component - @zh 组件类或组件实例 @en Component class or instance
|
||||
* @returns @zh 如果有同步字段返回 true @en Returns true if has sync fields
|
||||
*/
|
||||
export function hasSyncFields(component: any): boolean {
|
||||
const metadata = getSyncMetadata(component);
|
||||
return metadata !== null && metadata.fields.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取组件实例的变更追踪器
|
||||
* @en Get change tracker of a component instance
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @returns @zh 变更追踪器,如果不存在则返回 null @en Change tracker, or null if not exists
|
||||
*/
|
||||
export function getChangeTracker(component: any): ChangeTracker | null {
|
||||
if (!component) {
|
||||
return null;
|
||||
}
|
||||
return component[CHANGE_TRACKER] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 为组件实例初始化变更追踪器
|
||||
* @en Initialize change tracker for a component instance
|
||||
*
|
||||
* @zh 这个函数应该在组件首次添加到实体时调用。
|
||||
* 它会创建变更追踪器并标记所有字段为脏(用于首次同步)。
|
||||
* @en This function should be called when a component is first added to an entity.
|
||||
* It creates the change tracker and marks all fields as dirty (for initial sync).
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @returns @zh 变更追踪器 @en Change tracker
|
||||
*/
|
||||
export function initChangeTracker(component: any): ChangeTracker {
|
||||
const metadata = getSyncMetadata(component);
|
||||
if (!metadata) {
|
||||
throw new Error('Component does not have sync metadata. Use @sync decorator on fields.');
|
||||
}
|
||||
|
||||
let tracker = component[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (!tracker) {
|
||||
tracker = new ChangeTracker();
|
||||
component[CHANGE_TRACKER] = tracker;
|
||||
}
|
||||
|
||||
// Mark all fields as dirty for initial sync
|
||||
tracker.markAllDirty(metadata.fields.length);
|
||||
|
||||
return tracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除组件实例的变更标记
|
||||
* @en Clear change marks for a component instance
|
||||
*
|
||||
* @zh 通常在同步完成后调用,清除所有脏标记
|
||||
* @en Usually called after sync is complete, clears all dirty marks
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
*/
|
||||
export function clearChanges(component: any): void {
|
||||
const tracker = getChangeTracker(component);
|
||||
if (tracker) {
|
||||
tracker.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查组件是否有变更
|
||||
* @en Check if a component has changes
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @returns @zh 如果有变更返回 true @en Returns true if has changes
|
||||
*/
|
||||
export function hasChanges(component: any): boolean {
|
||||
const tracker = getChangeTracker(component);
|
||||
return tracker ? tracker.hasChanges() : false;
|
||||
}
|
||||
285
packages/framework/core/src/ECS/Sync/encoding/BinaryReader.ts
Normal file
285
packages/framework/core/src/ECS/Sync/encoding/BinaryReader.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* @zh 二进制读取器
|
||||
* @en Binary Reader
|
||||
*
|
||||
* @zh 提供高效的二进制数据读取功能
|
||||
* @en Provides efficient binary data reading
|
||||
*/
|
||||
|
||||
import { decodeVarint } from './varint';
|
||||
|
||||
/**
|
||||
* @zh 文本解码器(使用浏览器原生 API)
|
||||
* @en Text decoder (using browser native API)
|
||||
*/
|
||||
const textDecoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
||||
|
||||
/**
|
||||
* @zh 二进制读取器
|
||||
* @en Binary reader for decoding data
|
||||
*/
|
||||
export class BinaryReader {
|
||||
/**
|
||||
* @zh 数据缓冲区
|
||||
* @en Data buffer
|
||||
*/
|
||||
private _buffer: Uint8Array;
|
||||
|
||||
/**
|
||||
* @zh DataView 用于读取数值
|
||||
* @en DataView for reading numbers
|
||||
*/
|
||||
private _view: DataView;
|
||||
|
||||
/**
|
||||
* @zh 当前读取位置
|
||||
* @en Current read position
|
||||
*/
|
||||
private _offset: number = 0;
|
||||
|
||||
/**
|
||||
* @zh 创建二进制读取器
|
||||
* @en Create binary reader
|
||||
*
|
||||
* @param buffer - @zh 要读取的数据 @en Data to read
|
||||
*/
|
||||
constructor(buffer: Uint8Array) {
|
||||
this._buffer = buffer;
|
||||
this._view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前读取位置
|
||||
* @en Get current read position
|
||||
*/
|
||||
public get offset(): number {
|
||||
return this._offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置读取位置
|
||||
* @en Set read position
|
||||
*/
|
||||
public set offset(value: number) {
|
||||
this._offset = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取剩余可读字节数
|
||||
* @en Get remaining readable bytes
|
||||
*/
|
||||
public get remaining(): number {
|
||||
return this._buffer.length - this._offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有更多数据可读
|
||||
* @en Check if there's more data to read
|
||||
*/
|
||||
public hasMore(): boolean {
|
||||
return this._offset < this._buffer.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取单个字节
|
||||
* @en Read single byte
|
||||
*/
|
||||
public readUint8(): number {
|
||||
this.checkBounds(1);
|
||||
return this._buffer[this._offset++]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取有符号字节
|
||||
* @en Read signed byte
|
||||
*/
|
||||
public readInt8(): number {
|
||||
this.checkBounds(1);
|
||||
return this._view.getInt8(this._offset++);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取布尔值
|
||||
* @en Read boolean
|
||||
*/
|
||||
public readBoolean(): boolean {
|
||||
return this.readUint8() !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 16 位无符号整数(小端序)
|
||||
* @en Read 16-bit unsigned integer (little-endian)
|
||||
*/
|
||||
public readUint16(): number {
|
||||
this.checkBounds(2);
|
||||
const value = this._view.getUint16(this._offset, true);
|
||||
this._offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 16 位有符号整数(小端序)
|
||||
* @en Read 16-bit signed integer (little-endian)
|
||||
*/
|
||||
public readInt16(): number {
|
||||
this.checkBounds(2);
|
||||
const value = this._view.getInt16(this._offset, true);
|
||||
this._offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 32 位无符号整数(小端序)
|
||||
* @en Read 32-bit unsigned integer (little-endian)
|
||||
*/
|
||||
public readUint32(): number {
|
||||
this.checkBounds(4);
|
||||
const value = this._view.getUint32(this._offset, true);
|
||||
this._offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 32 位有符号整数(小端序)
|
||||
* @en Read 32-bit signed integer (little-endian)
|
||||
*/
|
||||
public readInt32(): number {
|
||||
this.checkBounds(4);
|
||||
const value = this._view.getInt32(this._offset, true);
|
||||
this._offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 32 位浮点数(小端序)
|
||||
* @en Read 32-bit float (little-endian)
|
||||
*/
|
||||
public readFloat32(): number {
|
||||
this.checkBounds(4);
|
||||
const value = this._view.getFloat32(this._offset, true);
|
||||
this._offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 64 位浮点数(小端序)
|
||||
* @en Read 64-bit float (little-endian)
|
||||
*/
|
||||
public readFloat64(): number {
|
||||
this.checkBounds(8);
|
||||
const value = this._view.getFloat64(this._offset, true);
|
||||
this._offset += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取变长整数
|
||||
* @en Read variable-length integer
|
||||
*/
|
||||
public readVarint(): number {
|
||||
const [value, newOffset] = decodeVarint(this._buffer, this._offset);
|
||||
this._offset = newOffset;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取字符串(UTF-8 编码,带长度前缀)
|
||||
* @en Read string (UTF-8 encoded with length prefix)
|
||||
*/
|
||||
public readString(): string {
|
||||
const length = this.readVarint();
|
||||
this.checkBounds(length);
|
||||
|
||||
const bytes = this._buffer.subarray(this._offset, this._offset + length);
|
||||
this._offset += length;
|
||||
|
||||
if (textDecoder) {
|
||||
return textDecoder.decode(bytes);
|
||||
} else {
|
||||
return this.utf8BytesToString(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取原始字节
|
||||
* @en Read raw bytes
|
||||
*
|
||||
* @param length - @zh 要读取的字节数 @en Number of bytes to read
|
||||
*/
|
||||
public readBytes(length: number): Uint8Array {
|
||||
this.checkBounds(length);
|
||||
const bytes = this._buffer.slice(this._offset, this._offset + length);
|
||||
this._offset += length;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 查看下一个字节但不移动读取位置
|
||||
* @en Peek next byte without advancing read position
|
||||
*/
|
||||
public peekUint8(): number {
|
||||
this.checkBounds(1);
|
||||
return this._buffer[this._offset]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 跳过指定字节数
|
||||
* @en Skip specified number of bytes
|
||||
*/
|
||||
public skip(count: number): void {
|
||||
this.checkBounds(count);
|
||||
this._offset += count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查边界
|
||||
* @en Check bounds
|
||||
*/
|
||||
private checkBounds(size: number): void {
|
||||
if (this._offset + size > this._buffer.length) {
|
||||
throw new Error(`BinaryReader: buffer overflow (offset=${this._offset}, size=${size}, bufferLength=${this._buffer.length})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh UTF-8 字节转字符串(后备方案)
|
||||
* @en UTF-8 bytes to string (fallback)
|
||||
*/
|
||||
private utf8BytesToString(bytes: Uint8Array): string {
|
||||
let result = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < bytes.length) {
|
||||
let charCode: number;
|
||||
const byte1 = bytes[i++]!;
|
||||
|
||||
if (byte1 < 0x80) {
|
||||
charCode = byte1;
|
||||
} else if (byte1 < 0xE0) {
|
||||
const byte2 = bytes[i++]!;
|
||||
charCode = ((byte1 & 0x1F) << 6) | (byte2 & 0x3F);
|
||||
} else if (byte1 < 0xF0) {
|
||||
const byte2 = bytes[i++]!;
|
||||
const byte3 = bytes[i++]!;
|
||||
charCode = ((byte1 & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F);
|
||||
} else {
|
||||
const byte2 = bytes[i++]!;
|
||||
const byte3 = bytes[i++]!;
|
||||
const byte4 = bytes[i++]!;
|
||||
charCode = ((byte1 & 0x07) << 18) | ((byte2 & 0x3F) << 12) |
|
||||
((byte3 & 0x3F) << 6) | (byte4 & 0x3F);
|
||||
|
||||
// Convert to surrogate pair
|
||||
if (charCode > 0xFFFF) {
|
||||
charCode -= 0x10000;
|
||||
result += String.fromCharCode(0xD800 + (charCode >> 10));
|
||||
charCode = 0xDC00 + (charCode & 0x3FF);
|
||||
}
|
||||
}
|
||||
|
||||
result += String.fromCharCode(charCode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
257
packages/framework/core/src/ECS/Sync/encoding/BinaryWriter.ts
Normal file
257
packages/framework/core/src/ECS/Sync/encoding/BinaryWriter.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @zh 二进制写入器
|
||||
* @en Binary Writer
|
||||
*
|
||||
* @zh 提供高效的二进制数据写入功能,支持自动扩容
|
||||
* @en Provides efficient binary data writing with auto-expansion
|
||||
*/
|
||||
|
||||
import { encodeVarint, varintSize } from './varint';
|
||||
|
||||
/**
|
||||
* @zh 文本编码器(使用浏览器原生 API)
|
||||
* @en Text encoder (using browser native API)
|
||||
*/
|
||||
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
||||
|
||||
/**
|
||||
* @zh 二进制写入器
|
||||
* @en Binary writer for encoding data
|
||||
*/
|
||||
export class BinaryWriter {
|
||||
/**
|
||||
* @zh 内部缓冲区
|
||||
* @en Internal buffer
|
||||
*/
|
||||
private _buffer: Uint8Array;
|
||||
|
||||
/**
|
||||
* @zh DataView 用于写入数值
|
||||
* @en DataView for writing numbers
|
||||
*/
|
||||
private _view: DataView;
|
||||
|
||||
/**
|
||||
* @zh 当前写入位置
|
||||
* @en Current write position
|
||||
*/
|
||||
private _offset: number = 0;
|
||||
|
||||
/**
|
||||
* @zh 创建二进制写入器
|
||||
* @en Create binary writer
|
||||
*
|
||||
* @param initialCapacity - @zh 初始容量 @en Initial capacity
|
||||
*/
|
||||
constructor(initialCapacity: number = 256) {
|
||||
this._buffer = new Uint8Array(initialCapacity);
|
||||
this._view = new DataView(this._buffer.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前写入位置
|
||||
* @en Get current write position
|
||||
*/
|
||||
public get offset(): number {
|
||||
return this._offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取写入的数据
|
||||
* @en Get written data
|
||||
*
|
||||
* @returns @zh 包含写入数据的 Uint8Array @en Uint8Array containing written data
|
||||
*/
|
||||
public toUint8Array(): Uint8Array {
|
||||
return this._buffer.slice(0, this._offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置写入器(清空数据但保留缓冲区)
|
||||
* @en Reset writer (clear data but keep buffer)
|
||||
*/
|
||||
public reset(): void {
|
||||
this._offset = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 确保有足够空间
|
||||
* @en Ensure enough space
|
||||
*
|
||||
* @param size - @zh 需要的额外字节数 @en Extra bytes needed
|
||||
*/
|
||||
private ensureCapacity(size: number): void {
|
||||
const required = this._offset + size;
|
||||
if (required > this._buffer.length) {
|
||||
// Double the buffer size or use required size, whichever is larger
|
||||
const newSize = Math.max(this._buffer.length * 2, required);
|
||||
const newBuffer = new Uint8Array(newSize);
|
||||
newBuffer.set(this._buffer);
|
||||
this._buffer = newBuffer;
|
||||
this._view = new DataView(this._buffer.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入单个字节
|
||||
* @en Write single byte
|
||||
*/
|
||||
public writeUint8(value: number): void {
|
||||
this.ensureCapacity(1);
|
||||
this._buffer[this._offset++] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入有符号字节
|
||||
* @en Write signed byte
|
||||
*/
|
||||
public writeInt8(value: number): void {
|
||||
this.ensureCapacity(1);
|
||||
this._view.setInt8(this._offset++, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入布尔值
|
||||
* @en Write boolean
|
||||
*/
|
||||
public writeBoolean(value: boolean): void {
|
||||
this.writeUint8(value ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 16 位无符号整数(小端序)
|
||||
* @en Write 16-bit unsigned integer (little-endian)
|
||||
*/
|
||||
public writeUint16(value: number): void {
|
||||
this.ensureCapacity(2);
|
||||
this._view.setUint16(this._offset, value, true);
|
||||
this._offset += 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 16 位有符号整数(小端序)
|
||||
* @en Write 16-bit signed integer (little-endian)
|
||||
*/
|
||||
public writeInt16(value: number): void {
|
||||
this.ensureCapacity(2);
|
||||
this._view.setInt16(this._offset, value, true);
|
||||
this._offset += 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 32 位无符号整数(小端序)
|
||||
* @en Write 32-bit unsigned integer (little-endian)
|
||||
*/
|
||||
public writeUint32(value: number): void {
|
||||
this.ensureCapacity(4);
|
||||
this._view.setUint32(this._offset, value, true);
|
||||
this._offset += 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 32 位有符号整数(小端序)
|
||||
* @en Write 32-bit signed integer (little-endian)
|
||||
*/
|
||||
public writeInt32(value: number): void {
|
||||
this.ensureCapacity(4);
|
||||
this._view.setInt32(this._offset, value, true);
|
||||
this._offset += 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 32 位浮点数(小端序)
|
||||
* @en Write 32-bit float (little-endian)
|
||||
*/
|
||||
public writeFloat32(value: number): void {
|
||||
this.ensureCapacity(4);
|
||||
this._view.setFloat32(this._offset, value, true);
|
||||
this._offset += 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 64 位浮点数(小端序)
|
||||
* @en Write 64-bit float (little-endian)
|
||||
*/
|
||||
public writeFloat64(value: number): void {
|
||||
this.ensureCapacity(8);
|
||||
this._view.setFloat64(this._offset, value, true);
|
||||
this._offset += 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入变长整数
|
||||
* @en Write variable-length integer
|
||||
*/
|
||||
public writeVarint(value: number): void {
|
||||
this.ensureCapacity(varintSize(value));
|
||||
this._offset = encodeVarint(value, this._buffer, this._offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入字符串(UTF-8 编码,带长度前缀)
|
||||
* @en Write string (UTF-8 encoded with length prefix)
|
||||
*/
|
||||
public writeString(value: string): void {
|
||||
if (textEncoder) {
|
||||
const encoded = textEncoder.encode(value);
|
||||
this.writeVarint(encoded.length);
|
||||
this.ensureCapacity(encoded.length);
|
||||
this._buffer.set(encoded, this._offset);
|
||||
this._offset += encoded.length;
|
||||
} else {
|
||||
// Fallback for environments without TextEncoder
|
||||
const bytes = this.stringToUtf8Bytes(value);
|
||||
this.writeVarint(bytes.length);
|
||||
this.ensureCapacity(bytes.length);
|
||||
this._buffer.set(bytes, this._offset);
|
||||
this._offset += bytes.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入原始字节
|
||||
* @en Write raw bytes
|
||||
*/
|
||||
public writeBytes(data: Uint8Array): void {
|
||||
this.ensureCapacity(data.length);
|
||||
this._buffer.set(data, this._offset);
|
||||
this._offset += data.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 字符串转 UTF-8 字节(后备方案)
|
||||
* @en String to UTF-8 bytes (fallback)
|
||||
*/
|
||||
private stringToUtf8Bytes(str: string): Uint8Array {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let charCode = str.charCodeAt(i);
|
||||
|
||||
// Handle surrogate pairs
|
||||
if (charCode >= 0xD800 && charCode <= 0xDBFF && i + 1 < str.length) {
|
||||
const next = str.charCodeAt(i + 1);
|
||||
if (next >= 0xDC00 && next <= 0xDFFF) {
|
||||
charCode = 0x10000 + ((charCode - 0xD800) << 10) + (next - 0xDC00);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (charCode < 0x80) {
|
||||
bytes.push(charCode);
|
||||
} else if (charCode < 0x800) {
|
||||
bytes.push(0xC0 | (charCode >> 6));
|
||||
bytes.push(0x80 | (charCode & 0x3F));
|
||||
} else if (charCode < 0x10000) {
|
||||
bytes.push(0xE0 | (charCode >> 12));
|
||||
bytes.push(0x80 | ((charCode >> 6) & 0x3F));
|
||||
bytes.push(0x80 | (charCode & 0x3F));
|
||||
} else {
|
||||
bytes.push(0xF0 | (charCode >> 18));
|
||||
bytes.push(0x80 | ((charCode >> 12) & 0x3F));
|
||||
bytes.push(0x80 | ((charCode >> 6) & 0x3F));
|
||||
bytes.push(0x80 | (charCode & 0x3F));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
}
|
||||
372
packages/framework/core/src/ECS/Sync/encoding/Decoder.ts
Normal file
372
packages/framework/core/src/ECS/Sync/encoding/Decoder.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* @zh 组件状态解码器
|
||||
* @en Component state decoder
|
||||
*
|
||||
* @zh 从二进制格式解码并应用到 ECS Component
|
||||
* @en Decodes binary format and applies to ECS Components
|
||||
*/
|
||||
|
||||
import type { Entity } from '../../Entity';
|
||||
import type { Component } from '../../Component';
|
||||
import type { Scene } from '../../Scene';
|
||||
import type { SyncType, SyncMetadata } from '../types';
|
||||
import { SyncOperation, SYNC_METADATA } from '../types';
|
||||
import { BinaryReader } from './BinaryReader';
|
||||
import { GlobalComponentRegistry } from '../../Core/ComponentStorage/ComponentRegistry';
|
||||
|
||||
/**
|
||||
* @zh 解码字段值
|
||||
* @en Decode field value
|
||||
*/
|
||||
function decodeFieldValue(reader: BinaryReader, type: SyncType): any {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return reader.readBoolean();
|
||||
case 'int8':
|
||||
return reader.readInt8();
|
||||
case 'uint8':
|
||||
return reader.readUint8();
|
||||
case 'int16':
|
||||
return reader.readInt16();
|
||||
case 'uint16':
|
||||
return reader.readUint16();
|
||||
case 'int32':
|
||||
return reader.readInt32();
|
||||
case 'uint32':
|
||||
return reader.readUint32();
|
||||
case 'float32':
|
||||
return reader.readFloat32();
|
||||
case 'float64':
|
||||
return reader.readFloat64();
|
||||
case 'string':
|
||||
return reader.readString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码并应用组件数据
|
||||
* @en Decode and apply component data
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @param metadata - @zh 组件同步元数据 @en Component sync metadata
|
||||
* @param reader - @zh 二进制读取器 @en Binary reader
|
||||
*/
|
||||
export function decodeComponent(
|
||||
component: Component,
|
||||
metadata: SyncMetadata,
|
||||
reader: BinaryReader
|
||||
): void {
|
||||
const fieldCount = reader.readVarint();
|
||||
|
||||
for (let i = 0; i < fieldCount; i++) {
|
||||
const fieldIndex = reader.readUint8();
|
||||
const field = metadata.fields[fieldIndex];
|
||||
|
||||
if (field) {
|
||||
const value = decodeFieldValue(reader, field.type);
|
||||
// Directly set the private backing field to avoid triggering change tracking
|
||||
(component as any)[`_sync_${field.name}`] = value;
|
||||
} else {
|
||||
// Unknown field, skip based on type info in metadata
|
||||
console.warn(`Unknown sync field index: ${fieldIndex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码实体快照结果
|
||||
* @en Decode entity snapshot result
|
||||
*/
|
||||
export interface DecodeEntityResult {
|
||||
/**
|
||||
* @zh 实体 ID
|
||||
* @en Entity ID
|
||||
*/
|
||||
entityId: number;
|
||||
|
||||
/**
|
||||
* @zh 是否为新实体
|
||||
* @en Whether it's a new entity
|
||||
*/
|
||||
isNew: boolean;
|
||||
|
||||
/**
|
||||
* @zh 解码的组件类型列表
|
||||
* @en List of decoded component types
|
||||
*/
|
||||
componentTypes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码并应用实体数据
|
||||
* @en Decode and apply entity data
|
||||
*
|
||||
* @param scene - @zh 场景 @en Scene
|
||||
* @param reader - @zh 二进制读取器 @en Binary reader
|
||||
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
|
||||
* @returns @zh 解码结果 @en Decode result
|
||||
*/
|
||||
export function decodeEntity(
|
||||
scene: Scene,
|
||||
reader: BinaryReader,
|
||||
entityMap?: Map<number, Entity>
|
||||
): DecodeEntityResult {
|
||||
const entityId = reader.readUint32();
|
||||
const componentCount = reader.readVarint();
|
||||
const componentTypes: string[] = [];
|
||||
|
||||
// Find or create entity
|
||||
let entity: Entity | null | undefined = entityMap?.get(entityId);
|
||||
let isNew = false;
|
||||
|
||||
if (!entity) {
|
||||
entity = scene.findEntityById(entityId);
|
||||
}
|
||||
|
||||
if (!entity) {
|
||||
// Entity doesn't exist, create it
|
||||
entity = scene.createEntity(`entity_${entityId}`);
|
||||
isNew = true;
|
||||
entityMap?.set(entityId, entity);
|
||||
}
|
||||
|
||||
for (let i = 0; i < componentCount; i++) {
|
||||
const typeId = reader.readString();
|
||||
componentTypes.push(typeId);
|
||||
|
||||
// Find component class from GlobalComponentRegistry
|
||||
const componentClass = GlobalComponentRegistry.getComponentType(typeId) as (new () => Component) | null;
|
||||
if (!componentClass) {
|
||||
console.warn(`Unknown component type: ${typeId}`);
|
||||
// Skip component data - we need to read it to advance the reader
|
||||
const fieldCount = reader.readVarint();
|
||||
for (let j = 0; j < fieldCount; j++) {
|
||||
reader.readUint8(); // fieldIndex
|
||||
// We can't skip properly without knowing the type, so this is a problem
|
||||
// For now, log error and break
|
||||
console.error(`Cannot skip unknown component type: ${typeId}`);
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata: SyncMetadata | undefined = (componentClass as any)[SYNC_METADATA];
|
||||
if (!metadata) {
|
||||
console.warn(`Component ${typeId} has no sync metadata`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find or add component
|
||||
let component = entity.getComponent(componentClass);
|
||||
if (!component) {
|
||||
component = entity.addComponent(new componentClass());
|
||||
}
|
||||
|
||||
// Decode component data
|
||||
decodeComponent(component, metadata, reader);
|
||||
}
|
||||
|
||||
return { entityId, isNew, componentTypes };
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码快照结果
|
||||
* @en Decode snapshot result
|
||||
*/
|
||||
export interface DecodeSnapshotResult {
|
||||
/**
|
||||
* @zh 操作类型
|
||||
* @en Operation type
|
||||
*/
|
||||
operation: SyncOperation;
|
||||
|
||||
/**
|
||||
* @zh 解码的实体列表
|
||||
* @en List of decoded entities
|
||||
*/
|
||||
entities: DecodeEntityResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码状态快照
|
||||
* @en Decode state snapshot
|
||||
*
|
||||
* @param scene - @zh 场景 @en Scene
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
|
||||
* @returns @zh 解码结果 @en Decode result
|
||||
*/
|
||||
export function decodeSnapshot(
|
||||
scene: Scene,
|
||||
data: Uint8Array,
|
||||
entityMap?: Map<number, Entity>
|
||||
): DecodeSnapshotResult {
|
||||
const reader = new BinaryReader(data);
|
||||
const operation = reader.readUint8() as SyncOperation;
|
||||
const entityCount = reader.readVarint();
|
||||
const entities: DecodeEntityResult[] = [];
|
||||
|
||||
const map = entityMap || new Map<number, Entity>();
|
||||
|
||||
for (let i = 0; i < entityCount; i++) {
|
||||
const result = decodeEntity(scene, reader, map);
|
||||
entities.push(result);
|
||||
}
|
||||
|
||||
return { operation, entities };
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码生成消息结果
|
||||
* @en Decode spawn message result
|
||||
*/
|
||||
export interface DecodeSpawnResult {
|
||||
/**
|
||||
* @zh 实体
|
||||
* @en Entity
|
||||
*/
|
||||
entity: Entity;
|
||||
|
||||
/**
|
||||
* @zh 预制体类型
|
||||
* @en Prefab type
|
||||
*/
|
||||
prefabType: string;
|
||||
|
||||
/**
|
||||
* @zh 解码的组件类型列表
|
||||
* @en List of decoded component types
|
||||
*/
|
||||
componentTypes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码实体生成消息
|
||||
* @en Decode entity spawn message
|
||||
*
|
||||
* @param scene - @zh 场景 @en Scene
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
|
||||
* @returns @zh 解码结果,如果不是 SPAWN 消息则返回 null @en Decode result, or null if not a SPAWN message
|
||||
*/
|
||||
export function decodeSpawn(
|
||||
scene: Scene,
|
||||
data: Uint8Array,
|
||||
entityMap?: Map<number, Entity>
|
||||
): DecodeSpawnResult | null {
|
||||
const reader = new BinaryReader(data);
|
||||
const operation = reader.readUint8();
|
||||
|
||||
if (operation !== SyncOperation.SPAWN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entityId = reader.readUint32();
|
||||
const prefabType = reader.readString();
|
||||
const componentCount = reader.readVarint();
|
||||
const componentTypes: string[] = [];
|
||||
|
||||
// Create entity
|
||||
const entity = scene.createEntity(`entity_${entityId}`);
|
||||
entityMap?.set(entityId, entity);
|
||||
|
||||
for (let i = 0; i < componentCount; i++) {
|
||||
const typeId = reader.readString();
|
||||
componentTypes.push(typeId);
|
||||
|
||||
const componentClass = GlobalComponentRegistry.getComponentType(typeId) as (new () => Component) | null;
|
||||
if (!componentClass) {
|
||||
console.warn(`Unknown component type: ${typeId}`);
|
||||
// Try to skip
|
||||
const fieldCount = reader.readVarint();
|
||||
for (let j = 0; j < fieldCount; j++) {
|
||||
reader.readUint8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata: SyncMetadata | undefined = (componentClass as any)[SYNC_METADATA];
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = entity.addComponent(new (componentClass as new () => Component)());
|
||||
decodeComponent(component, metadata, reader);
|
||||
}
|
||||
|
||||
return { entity, prefabType, componentTypes };
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码销毁消息结果
|
||||
* @en Decode despawn message result
|
||||
*/
|
||||
export interface DecodeDespawnResult {
|
||||
/**
|
||||
* @zh 销毁的实体 ID 列表
|
||||
* @en List of despawned entity IDs
|
||||
*/
|
||||
entityIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码实体销毁消息
|
||||
* @en Decode entity despawn message
|
||||
*
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @returns @zh 解码结果,如果不是 DESPAWN 消息则返回 null @en Decode result, or null if not a DESPAWN message
|
||||
*/
|
||||
export function decodeDespawn(data: Uint8Array): DecodeDespawnResult | null {
|
||||
const reader = new BinaryReader(data);
|
||||
const operation = reader.readUint8();
|
||||
|
||||
if (operation !== SyncOperation.DESPAWN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entityIds: number[] = [];
|
||||
|
||||
// Check if it's a single entity or batch
|
||||
if (reader.remaining === 4) {
|
||||
// Single entity
|
||||
entityIds.push(reader.readUint32());
|
||||
} else {
|
||||
// Batch
|
||||
const count = reader.readVarint();
|
||||
for (let i = 0; i < count; i++) {
|
||||
entityIds.push(reader.readUint32());
|
||||
}
|
||||
}
|
||||
|
||||
return { entityIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理销毁消息(从场景中移除实体)
|
||||
* @en Process despawn message (remove entities from scene)
|
||||
*
|
||||
* @param scene - @zh 场景 @en Scene
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
|
||||
* @returns @zh 移除的实体 ID 列表 @en List of removed entity IDs
|
||||
*/
|
||||
export function processDespawn(
|
||||
scene: Scene,
|
||||
data: Uint8Array,
|
||||
entityMap?: Map<number, Entity>
|
||||
): number[] {
|
||||
const result = decodeDespawn(data);
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const entityId of result.entityIds) {
|
||||
const entity = entityMap?.get(entityId) || scene.findEntityById(entityId);
|
||||
if (entity) {
|
||||
entity.destroy();
|
||||
entityMap?.delete(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
return result.entityIds;
|
||||
}
|
||||
291
packages/framework/core/src/ECS/Sync/encoding/Encoder.ts
Normal file
291
packages/framework/core/src/ECS/Sync/encoding/Encoder.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @zh 组件状态编码器
|
||||
* @en Component state encoder
|
||||
*
|
||||
* @zh 将 ECS Component 的 @sync 字段编码为二进制格式
|
||||
* @en Encodes @sync fields of ECS Components to binary format
|
||||
*/
|
||||
|
||||
import type { Entity } from '../../Entity';
|
||||
import type { Component } from '../../Component';
|
||||
import type { SyncType, SyncMetadata } from '../types';
|
||||
import { SyncOperation, SYNC_METADATA, CHANGE_TRACKER } from '../types';
|
||||
import type { ChangeTracker } from '../ChangeTracker';
|
||||
import { BinaryWriter } from './BinaryWriter';
|
||||
|
||||
/**
|
||||
* @zh 编码单个字段值
|
||||
* @en Encode a single field value
|
||||
*/
|
||||
function encodeFieldValue(writer: BinaryWriter, value: any, type: SyncType): void {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
writer.writeBoolean(value);
|
||||
break;
|
||||
case 'int8':
|
||||
writer.writeInt8(value);
|
||||
break;
|
||||
case 'uint8':
|
||||
writer.writeUint8(value);
|
||||
break;
|
||||
case 'int16':
|
||||
writer.writeInt16(value);
|
||||
break;
|
||||
case 'uint16':
|
||||
writer.writeUint16(value);
|
||||
break;
|
||||
case 'int32':
|
||||
writer.writeInt32(value);
|
||||
break;
|
||||
case 'uint32':
|
||||
writer.writeUint32(value);
|
||||
break;
|
||||
case 'float32':
|
||||
writer.writeFloat32(value);
|
||||
break;
|
||||
case 'float64':
|
||||
writer.writeFloat64(value);
|
||||
break;
|
||||
case 'string':
|
||||
writer.writeString(value ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码组件的完整状态
|
||||
* @en Encode full state of a component
|
||||
*
|
||||
* @zh 格式: [fieldCount: varint] ([fieldIndex: uint8] [value])...
|
||||
* @en Format: [fieldCount: varint] ([fieldIndex: uint8] [value])...
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @param metadata - @zh 组件同步元数据 @en Component sync metadata
|
||||
* @param writer - @zh 二进制写入器 @en Binary writer
|
||||
*/
|
||||
export function encodeComponentFull(
|
||||
component: Component,
|
||||
metadata: SyncMetadata,
|
||||
writer: BinaryWriter
|
||||
): void {
|
||||
const fields = metadata.fields;
|
||||
writer.writeVarint(fields.length);
|
||||
|
||||
for (const field of fields) {
|
||||
writer.writeUint8(field.index);
|
||||
const value = (component as any)[field.name];
|
||||
encodeFieldValue(writer, value, field.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码组件的增量状态(只编码脏字段)
|
||||
* @en Encode delta state of a component (only dirty fields)
|
||||
*
|
||||
* @zh 格式: [dirtyCount: varint] ([fieldIndex: uint8] [value])...
|
||||
* @en Format: [dirtyCount: varint] ([fieldIndex: uint8] [value])...
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @param metadata - @zh 组件同步元数据 @en Component sync metadata
|
||||
* @param tracker - @zh 变更追踪器 @en Change tracker
|
||||
* @param writer - @zh 二进制写入器 @en Binary writer
|
||||
* @returns @zh 是否有数据编码 @en Whether any data was encoded
|
||||
*/
|
||||
export function encodeComponentDelta(
|
||||
component: Component,
|
||||
metadata: SyncMetadata,
|
||||
tracker: ChangeTracker,
|
||||
writer: BinaryWriter
|
||||
): boolean {
|
||||
if (!tracker.hasChanges()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dirtyFields = tracker.getDirtyFields();
|
||||
writer.writeVarint(dirtyFields.length);
|
||||
|
||||
for (const fieldIndex of dirtyFields) {
|
||||
const field = metadata.fields[fieldIndex];
|
||||
if (field) {
|
||||
writer.writeUint8(field.index);
|
||||
const value = (component as any)[field.name];
|
||||
encodeFieldValue(writer, value, field.type);
|
||||
}
|
||||
}
|
||||
|
||||
return dirtyFields.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体的所有同步组件
|
||||
* @en Encode all sync components of an entity
|
||||
*
|
||||
* @zh 格式:
|
||||
* [entityId: uint32]
|
||||
* [componentCount: varint]
|
||||
* ([typeIdLength: varint] [typeId: string] [componentData])...
|
||||
*
|
||||
* @en Format:
|
||||
* [entityId: uint32]
|
||||
* [componentCount: varint]
|
||||
* ([typeIdLength: varint] [typeId: string] [componentData])...
|
||||
*
|
||||
* @param entity - @zh 实体 @en Entity
|
||||
* @param writer - @zh 二进制写入器 @en Binary writer
|
||||
* @param deltaOnly - @zh 只编码增量 @en Only encode delta
|
||||
* @returns @zh 编码的组件数量 @en Number of components encoded
|
||||
*/
|
||||
export function encodeEntity(
|
||||
entity: Entity,
|
||||
writer: BinaryWriter,
|
||||
deltaOnly: boolean = false
|
||||
): number {
|
||||
writer.writeUint32(entity.id);
|
||||
|
||||
const components = entity.components;
|
||||
const syncComponents: Array<{
|
||||
component: Component;
|
||||
metadata: SyncMetadata;
|
||||
tracker: ChangeTracker | undefined;
|
||||
}> = [];
|
||||
|
||||
// Collect components with sync metadata
|
||||
for (const component of components) {
|
||||
const constructor = component.constructor as any;
|
||||
const metadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
|
||||
if (metadata && metadata.fields.length > 0) {
|
||||
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
|
||||
// For delta encoding, only include components with changes
|
||||
if (deltaOnly && tracker && !tracker.hasChanges()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
syncComponents.push({ component, metadata, tracker });
|
||||
}
|
||||
}
|
||||
|
||||
writer.writeVarint(syncComponents.length);
|
||||
|
||||
for (const { component, metadata, tracker } of syncComponents) {
|
||||
// Write component type ID
|
||||
writer.writeString(metadata.typeId);
|
||||
|
||||
if (deltaOnly && tracker) {
|
||||
encodeComponentDelta(component, metadata, tracker, writer);
|
||||
} else {
|
||||
encodeComponentFull(component, metadata, writer);
|
||||
}
|
||||
}
|
||||
|
||||
return syncComponents.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码状态快照(多个实体)
|
||||
* @en Encode state snapshot (multiple entities)
|
||||
*
|
||||
* @zh 格式:
|
||||
* [operation: uint8] (FULL=0, DELTA=1, SPAWN=2, DESPAWN=3)
|
||||
* [entityCount: varint]
|
||||
* (entityData)...
|
||||
*
|
||||
* @en Format:
|
||||
* [operation: uint8] (FULL=0, DELTA=1, SPAWN=2, DESPAWN=3)
|
||||
* [entityCount: varint]
|
||||
* (entityData)...
|
||||
*
|
||||
* @param entities - @zh 要编码的实体数组 @en Entities to encode
|
||||
* @param operation - @zh 同步操作类型 @en Sync operation type
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
export function encodeSnapshot(
|
||||
entities: Entity[],
|
||||
operation: SyncOperation = SyncOperation.FULL
|
||||
): Uint8Array {
|
||||
const writer = new BinaryWriter(1024);
|
||||
|
||||
writer.writeUint8(operation);
|
||||
writer.writeVarint(entities.length);
|
||||
|
||||
const deltaOnly = operation === SyncOperation.DELTA;
|
||||
|
||||
for (const entity of entities) {
|
||||
encodeEntity(entity, writer, deltaOnly);
|
||||
}
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体生成消息
|
||||
* @en Encode entity spawn message
|
||||
*
|
||||
* @param entity - @zh 生成的实体 @en Spawned entity
|
||||
* @param prefabType - @zh 预制体类型(可选)@en Prefab type (optional)
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
export function encodeSpawn(entity: Entity, prefabType?: string): Uint8Array {
|
||||
const writer = new BinaryWriter(256);
|
||||
|
||||
writer.writeUint8(SyncOperation.SPAWN);
|
||||
writer.writeUint32(entity.id);
|
||||
writer.writeString(prefabType || '');
|
||||
|
||||
// Encode all sync components for initial state
|
||||
const components = entity.components;
|
||||
const syncComponents: Array<{ component: Component; metadata: SyncMetadata }> = [];
|
||||
|
||||
for (const component of components) {
|
||||
const constructor = component.constructor as any;
|
||||
const metadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
|
||||
if (metadata && metadata.fields.length > 0) {
|
||||
syncComponents.push({ component, metadata });
|
||||
}
|
||||
}
|
||||
|
||||
writer.writeVarint(syncComponents.length);
|
||||
|
||||
for (const { component, metadata } of syncComponents) {
|
||||
writer.writeString(metadata.typeId);
|
||||
encodeComponentFull(component, metadata, writer);
|
||||
}
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体销毁消息
|
||||
* @en Encode entity despawn message
|
||||
*
|
||||
* @param entityId - @zh 销毁的实体 ID @en Despawned entity ID
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
export function encodeDespawn(entityId: number): Uint8Array {
|
||||
const writer = new BinaryWriter(8);
|
||||
|
||||
writer.writeUint8(SyncOperation.DESPAWN);
|
||||
writer.writeUint32(entityId);
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码批量实体销毁消息
|
||||
* @en Encode batch entity despawn message
|
||||
*
|
||||
* @param entityIds - @zh 销毁的实体 ID 数组 @en Despawned entity IDs
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
export function encodeDespawnBatch(entityIds: number[]): Uint8Array {
|
||||
const writer = new BinaryWriter(8 + entityIds.length * 4);
|
||||
|
||||
writer.writeUint8(SyncOperation.DESPAWN);
|
||||
writer.writeVarint(entityIds.length);
|
||||
|
||||
for (const id of entityIds) {
|
||||
writer.writeUint32(id);
|
||||
}
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
50
packages/framework/core/src/ECS/Sync/encoding/index.ts
Normal file
50
packages/framework/core/src/ECS/Sync/encoding/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @zh 二进制编解码模块
|
||||
* @en Binary encoding/decoding module
|
||||
*
|
||||
* @zh 提供 ECS Component 状态的二进制序列化和反序列化功能
|
||||
* @en Provides binary serialization and deserialization for ECS Component state
|
||||
*/
|
||||
|
||||
// Variable-length integer encoding
|
||||
export {
|
||||
varintSize,
|
||||
encodeVarint,
|
||||
decodeVarint,
|
||||
zigzagEncode,
|
||||
zigzagDecode,
|
||||
encodeSignedVarint,
|
||||
decodeSignedVarint
|
||||
} from './varint';
|
||||
|
||||
// Binary writer/reader
|
||||
export { BinaryWriter } from './BinaryWriter';
|
||||
export { BinaryReader } from './BinaryReader';
|
||||
|
||||
// Encoder
|
||||
export {
|
||||
encodeComponentFull,
|
||||
encodeComponentDelta,
|
||||
encodeEntity,
|
||||
encodeSnapshot,
|
||||
encodeSpawn,
|
||||
encodeDespawn,
|
||||
encodeDespawnBatch
|
||||
} from './Encoder';
|
||||
|
||||
// Decoder
|
||||
export {
|
||||
decodeComponent,
|
||||
decodeEntity,
|
||||
decodeSnapshot,
|
||||
decodeSpawn,
|
||||
decodeDespawn,
|
||||
processDespawn
|
||||
} from './Decoder';
|
||||
|
||||
export type {
|
||||
DecodeEntityResult,
|
||||
DecodeSnapshotResult,
|
||||
DecodeSpawnResult,
|
||||
DecodeDespawnResult
|
||||
} from './Decoder';
|
||||
137
packages/framework/core/src/ECS/Sync/encoding/varint.ts
Normal file
137
packages/framework/core/src/ECS/Sync/encoding/varint.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @zh 变长整数编解码
|
||||
* @en Variable-length integer encoding/decoding
|
||||
*
|
||||
* @zh 使用 LEB128 编码方式,可变长度编码正整数。
|
||||
* 小数值使用更少字节,大数值使用更多字节。
|
||||
* @en Uses LEB128 encoding for variable-length integer encoding.
|
||||
* Small values use fewer bytes, large values use more bytes.
|
||||
*
|
||||
* | 值范围 | 字节数 |
|
||||
* |--------|--------|
|
||||
* | 0-127 | 1 |
|
||||
* | 128-16383 | 2 |
|
||||
* | 16384-2097151 | 3 |
|
||||
* | 2097152-268435455 | 4 |
|
||||
* | 268435456+ | 5 |
|
||||
*/
|
||||
|
||||
/**
|
||||
* @zh 计算变长整数所需的字节数
|
||||
* @en Calculate bytes needed for a varint
|
||||
*
|
||||
* @param value - @zh 整数值 @en Integer value
|
||||
* @returns @zh 所需字节数 @en Bytes needed
|
||||
*/
|
||||
export function varintSize(value: number): number {
|
||||
if (value < 0) {
|
||||
throw new Error('Varint only supports non-negative integers');
|
||||
}
|
||||
if (value < 128) return 1;
|
||||
if (value < 16384) return 2;
|
||||
if (value < 2097152) return 3;
|
||||
if (value < 268435456) return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码变长整数到字节数组
|
||||
* @en Encode varint to byte array
|
||||
*
|
||||
* @param value - @zh 要编码的整数 @en Integer to encode
|
||||
* @param buffer - @zh 目标缓冲区 @en Target buffer
|
||||
* @param offset - @zh 写入偏移 @en Write offset
|
||||
* @returns @zh 写入后的新偏移 @en New offset after writing
|
||||
*/
|
||||
export function encodeVarint(value: number, buffer: Uint8Array, offset: number): number {
|
||||
if (value < 0) {
|
||||
throw new Error('Varint only supports non-negative integers');
|
||||
}
|
||||
|
||||
while (value >= 0x80) {
|
||||
buffer[offset++] = (value & 0x7F) | 0x80;
|
||||
value >>>= 7;
|
||||
}
|
||||
buffer[offset++] = value;
|
||||
return offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 从字节数组解码变长整数
|
||||
* @en Decode varint from byte array
|
||||
*
|
||||
* @param buffer - @zh 源缓冲区 @en Source buffer
|
||||
* @param offset - @zh 读取偏移 @en Read offset
|
||||
* @returns @zh [解码值, 新偏移] @en [decoded value, new offset]
|
||||
*/
|
||||
export function decodeVarint(buffer: Uint8Array, offset: number): [number, number] {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let byte: number;
|
||||
|
||||
do {
|
||||
if (offset >= buffer.length) {
|
||||
throw new Error('Varint decode: buffer overflow');
|
||||
}
|
||||
byte = buffer[offset++]!;
|
||||
result |= (byte & 0x7F) << shift;
|
||||
shift += 7;
|
||||
} while (byte >= 0x80);
|
||||
|
||||
return [result, offset];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码有符号整数(ZigZag 编码)
|
||||
* @en Encode signed integer (ZigZag encoding)
|
||||
*
|
||||
* @zh ZigZag 编码将有符号整数映射到无符号整数:
|
||||
* 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, ...
|
||||
* 这样小的负数也能用较少字节表示。
|
||||
* @en ZigZag encoding maps signed integers to unsigned:
|
||||
* 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, ...
|
||||
* This allows small negative numbers to use fewer bytes.
|
||||
*
|
||||
* @param value - @zh 有符号整数 @en Signed integer
|
||||
* @returns @zh ZigZag 编码后的值 @en ZigZag encoded value
|
||||
*/
|
||||
export function zigzagEncode(value: number): number {
|
||||
return (value << 1) ^ (value >> 31);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码有符号整数(ZigZag 解码)
|
||||
* @en Decode signed integer (ZigZag decoding)
|
||||
*
|
||||
* @param value - @zh ZigZag 编码的值 @en ZigZag encoded value
|
||||
* @returns @zh 原始有符号整数 @en Original signed integer
|
||||
*/
|
||||
export function zigzagDecode(value: number): number {
|
||||
return (value >>> 1) ^ -(value & 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码有符号变长整数
|
||||
* @en Encode signed varint
|
||||
*
|
||||
* @param value - @zh 有符号整数 @en Signed integer
|
||||
* @param buffer - @zh 目标缓冲区 @en Target buffer
|
||||
* @param offset - @zh 写入偏移 @en Write offset
|
||||
* @returns @zh 写入后的新偏移 @en New offset after writing
|
||||
*/
|
||||
export function encodeSignedVarint(value: number, buffer: Uint8Array, offset: number): number {
|
||||
return encodeVarint(zigzagEncode(value), buffer, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码有符号变长整数
|
||||
* @en Decode signed varint
|
||||
*
|
||||
* @param buffer - @zh 源缓冲区 @en Source buffer
|
||||
* @param offset - @zh 读取偏移 @en Read offset
|
||||
* @returns @zh [解码值, 新偏移] @en [decoded value, new offset]
|
||||
*/
|
||||
export function decodeSignedVarint(buffer: Uint8Array, offset: number): [number, number] {
|
||||
const [encoded, newOffset] = decodeVarint(buffer, offset);
|
||||
return [zigzagDecode(encoded), newOffset];
|
||||
}
|
||||
65
packages/framework/core/src/ECS/Sync/index.ts
Normal file
65
packages/framework/core/src/ECS/Sync/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @zh ECS 网络同步模块
|
||||
* @en ECS Network Synchronization Module
|
||||
*
|
||||
* @zh 提供基于 ECS Component 的网络状态同步功能:
|
||||
* - @sync 装饰器:标记需要同步的字段
|
||||
* - ChangeTracker:追踪字段级变更
|
||||
* - 二进制编解码器:高效的网络序列化
|
||||
*
|
||||
* @en Provides network state synchronization based on ECS Components:
|
||||
* - @sync decorator: Mark fields for synchronization
|
||||
* - ChangeTracker: Track field-level changes
|
||||
* - Binary encoder/decoder: Efficient network serialization
|
||||
*
|
||||
* @example
|
||||
* ```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;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
SyncType,
|
||||
SyncFieldMetadata,
|
||||
SyncMetadata,
|
||||
SyncOperation,
|
||||
TYPE_SIZES,
|
||||
SYNC_METADATA,
|
||||
CHANGE_TRACKER
|
||||
} from './types';
|
||||
|
||||
// Change Tracker
|
||||
export { ChangeTracker } from './ChangeTracker';
|
||||
|
||||
// Decorators
|
||||
export {
|
||||
sync,
|
||||
getSyncMetadata,
|
||||
hasSyncFields,
|
||||
getChangeTracker,
|
||||
initChangeTracker,
|
||||
clearChanges,
|
||||
hasChanges
|
||||
} from './decorators';
|
||||
|
||||
// Network Entity Decorator
|
||||
export {
|
||||
NetworkEntity,
|
||||
getNetworkEntityMetadata,
|
||||
isNetworkEntity,
|
||||
NETWORK_ENTITY_METADATA,
|
||||
type NetworkEntityMetadata,
|
||||
type NetworkEntityOptions
|
||||
} from './NetworkEntityDecorator';
|
||||
|
||||
// Encoding
|
||||
export * from './encoding';
|
||||
127
packages/framework/core/src/ECS/Sync/types.ts
Normal file
127
packages/framework/core/src/ECS/Sync/types.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @zh 网络同步类型定义
|
||||
* @en Network synchronization type definitions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @zh 支持的同步数据类型
|
||||
* @en Supported sync data types
|
||||
*/
|
||||
export type SyncType =
|
||||
| 'boolean'
|
||||
| 'int8'
|
||||
| 'uint8'
|
||||
| 'int16'
|
||||
| 'uint16'
|
||||
| 'int32'
|
||||
| 'uint32'
|
||||
| 'float32'
|
||||
| 'float64'
|
||||
| 'string';
|
||||
|
||||
/**
|
||||
* @zh 同步字段元数据
|
||||
* @en Sync field metadata
|
||||
*/
|
||||
export interface SyncFieldMetadata {
|
||||
/**
|
||||
* @zh 字段索引(用于二进制编码)
|
||||
* @en Field index (for binary encoding)
|
||||
*/
|
||||
index: number;
|
||||
|
||||
/**
|
||||
* @zh 字段名称
|
||||
* @en Field name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @zh 字段类型
|
||||
* @en Field type
|
||||
*/
|
||||
type: SyncType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 组件同步元数据
|
||||
* @en Component sync metadata
|
||||
*/
|
||||
export interface SyncMetadata {
|
||||
/**
|
||||
* @zh 组件类型 ID
|
||||
* @en Component type ID
|
||||
*/
|
||||
typeId: string;
|
||||
|
||||
/**
|
||||
* @zh 同步字段列表(按索引排序)
|
||||
* @en Sync fields list (sorted by index)
|
||||
*/
|
||||
fields: SyncFieldMetadata[];
|
||||
|
||||
/**
|
||||
* @zh 字段名到索引的映射
|
||||
* @en Field name to index mapping
|
||||
*/
|
||||
fieldIndexMap: Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 同步操作类型
|
||||
* @en Sync operation type
|
||||
*/
|
||||
export enum SyncOperation {
|
||||
/**
|
||||
* @zh 完整快照
|
||||
* @en Full snapshot
|
||||
*/
|
||||
FULL = 0,
|
||||
|
||||
/**
|
||||
* @zh 增量更新
|
||||
* @en Delta update
|
||||
*/
|
||||
DELTA = 1,
|
||||
|
||||
/**
|
||||
* @zh 实体生成
|
||||
* @en Entity spawn
|
||||
*/
|
||||
SPAWN = 2,
|
||||
|
||||
/**
|
||||
* @zh 实体销毁
|
||||
* @en Entity despawn
|
||||
*/
|
||||
DESPAWN = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 各类型的字节大小
|
||||
* @en Byte size for each type
|
||||
*/
|
||||
export const TYPE_SIZES: Record<SyncType, number> = {
|
||||
boolean: 1,
|
||||
int8: 1,
|
||||
uint8: 1,
|
||||
int16: 2,
|
||||
uint16: 2,
|
||||
int32: 4,
|
||||
uint32: 4,
|
||||
float32: 4,
|
||||
float64: 8,
|
||||
string: -1, // 动态长度 | dynamic length
|
||||
};
|
||||
|
||||
/**
|
||||
* @zh 同步元数据的 Symbol 键
|
||||
* @en Symbol key for sync metadata
|
||||
*/
|
||||
export const SYNC_METADATA = Symbol('SyncMetadata');
|
||||
|
||||
/**
|
||||
* @zh 变更追踪器的 Symbol 键
|
||||
* @en Symbol key for change tracker
|
||||
*/
|
||||
export const CHANGE_TRACKER = Symbol('ChangeTracker');
|
||||
@@ -317,9 +317,7 @@ export class WorldManager implements IService {
|
||||
/**
|
||||
* @zh 更新所有活跃的World
|
||||
* @en Update all active Worlds
|
||||
*
|
||||
* @zh 应该在每帧的游戏循环中调用
|
||||
* @en Should be called in each frame of game loop
|
||||
* @internal 由 Core.update() 调用,用户不应直接调用
|
||||
*/
|
||||
public updateAll(): void {
|
||||
if (!this._isRunning) return;
|
||||
|
||||
@@ -57,3 +57,6 @@ export { EpochManager } from './Core/EpochManager';
|
||||
// Compiled Query
|
||||
export { CompiledQuery } from './Core/Query/CompiledQuery';
|
||||
export type { InstanceTypes } from './Core/Query/CompiledQuery';
|
||||
|
||||
// Network Synchronization
|
||||
export * from './Sync';
|
||||
|
||||
172
packages/framework/core/tests/ECS/Sync/ChangeTracker.test.ts
Normal file
172
packages/framework/core/tests/ECS/Sync/ChangeTracker.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { ChangeTracker } from '../../../src/ECS/Sync/ChangeTracker';
|
||||
|
||||
describe('ChangeTracker - 变更追踪器测试', () => {
|
||||
let tracker: ChangeTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new ChangeTracker();
|
||||
});
|
||||
|
||||
describe('基本功能', () => {
|
||||
test('初始状态应该没有变更', () => {
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
expect(tracker.getDirtyCount()).toBe(0);
|
||||
expect(tracker.getDirtyFields()).toEqual([]);
|
||||
});
|
||||
|
||||
test('setDirty 应该标记字段为脏', () => {
|
||||
tracker.setDirty(0);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
expect(tracker.isDirty(0)).toBe(true);
|
||||
expect(tracker.getDirtyCount()).toBe(1);
|
||||
expect(tracker.getDirtyFields()).toEqual([0]);
|
||||
});
|
||||
|
||||
test('多次 setDirty 同一字段应该只记录一次', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(0);
|
||||
|
||||
expect(tracker.getDirtyCount()).toBe(1);
|
||||
expect(tracker.getDirtyFields()).toEqual([0]);
|
||||
});
|
||||
|
||||
test('setDirty 不同字段应该都被记录', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.setDirty(2);
|
||||
|
||||
expect(tracker.getDirtyCount()).toBe(3);
|
||||
expect(tracker.getDirtyFields().sort()).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDirty 方法', () => {
|
||||
test('未标记的字段应该返回 false', () => {
|
||||
expect(tracker.isDirty(0)).toBe(false);
|
||||
expect(tracker.isDirty(5)).toBe(false);
|
||||
});
|
||||
|
||||
test('已标记的字段应该返回 true', () => {
|
||||
tracker.setDirty(3);
|
||||
|
||||
expect(tracker.isDirty(3)).toBe(true);
|
||||
expect(tracker.isDirty(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear 方法', () => {
|
||||
test('clear 应该清除所有变更', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.setDirty(2);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
|
||||
tracker.clear();
|
||||
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
expect(tracker.getDirtyCount()).toBe(0);
|
||||
expect(tracker.getDirtyFields()).toEqual([]);
|
||||
});
|
||||
|
||||
test('clear 应该更新 lastSyncTime', () => {
|
||||
const before = tracker.lastSyncTime;
|
||||
tracker.setDirty(0);
|
||||
tracker.clear();
|
||||
|
||||
expect(tracker.lastSyncTime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearField 方法', () => {
|
||||
test('clearField 应该只清除指定字段', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.setDirty(2);
|
||||
|
||||
tracker.clearField(1);
|
||||
|
||||
expect(tracker.isDirty(0)).toBe(true);
|
||||
expect(tracker.isDirty(1)).toBe(false);
|
||||
expect(tracker.isDirty(2)).toBe(true);
|
||||
expect(tracker.getDirtyCount()).toBe(2);
|
||||
});
|
||||
|
||||
test('清除最后一个字段应该使 hasChanges 返回 false', () => {
|
||||
tracker.setDirty(0);
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
|
||||
tracker.clearField(0);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllDirty 方法', () => {
|
||||
test('markAllDirty 应该标记所有字段', () => {
|
||||
tracker.markAllDirty(5);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
expect(tracker.getDirtyCount()).toBe(5);
|
||||
expect(tracker.getDirtyFields().sort()).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test('markAllDirty(0) 应该没有变更', () => {
|
||||
tracker.markAllDirty(0);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
expect(tracker.getDirtyCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('markAllDirty 用于首次同步', () => {
|
||||
tracker.markAllDirty(3);
|
||||
|
||||
expect(tracker.isDirty(0)).toBe(true);
|
||||
expect(tracker.isDirty(1)).toBe(true);
|
||||
expect(tracker.isDirty(2)).toBe(true);
|
||||
expect(tracker.isDirty(3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset 方法', () => {
|
||||
test('reset 应该重置所有状态', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.clear();
|
||||
|
||||
tracker.reset();
|
||||
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
expect(tracker.getDirtyCount()).toBe(0);
|
||||
expect(tracker.lastSyncTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
test('大量字段标记应该正常工作', () => {
|
||||
const fieldCount = 1000;
|
||||
|
||||
for (let i = 0; i < fieldCount; i++) {
|
||||
tracker.setDirty(i);
|
||||
}
|
||||
|
||||
expect(tracker.getDirtyCount()).toBe(fieldCount);
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
});
|
||||
|
||||
test('交替设置和清除应该正常工作', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.clearField(0);
|
||||
tracker.setDirty(2);
|
||||
tracker.clearField(1);
|
||||
|
||||
expect(tracker.isDirty(0)).toBe(false);
|
||||
expect(tracker.isDirty(1)).toBe(false);
|
||||
expect(tracker.isDirty(2)).toBe(true);
|
||||
expect(tracker.getDirtyCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
327
packages/framework/core/tests/ECS/Sync/decorators.test.ts
Normal file
327
packages/framework/core/tests/ECS/Sync/decorators.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
||||
import { Scene } from '../../../src/ECS/Scene';
|
||||
import {
|
||||
sync,
|
||||
getSyncMetadata,
|
||||
hasSyncFields,
|
||||
getChangeTracker,
|
||||
initChangeTracker,
|
||||
clearChanges,
|
||||
hasChanges
|
||||
} from '../../../src/ECS/Sync/decorators';
|
||||
import { SYNC_METADATA, CHANGE_TRACKER } from '../../../src/ECS/Sync/types';
|
||||
|
||||
@ECSComponent('SyncTest_PlayerComponent')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
|
||||
localData: string = "not synced";
|
||||
}
|
||||
|
||||
@ECSComponent('SyncTest_SimpleComponent')
|
||||
class SimpleComponent extends Component {
|
||||
@sync("boolean") active: boolean = true;
|
||||
@sync("int32") value: number = 100;
|
||||
}
|
||||
|
||||
@ECSComponent('SyncTest_NoSyncComponent')
|
||||
class NoSyncComponent extends Component {
|
||||
localValue: number = 0;
|
||||
}
|
||||
|
||||
@ECSComponent('SyncTest_AllTypesComponent')
|
||||
class AllTypesComponent extends Component {
|
||||
@sync("boolean") boolField: boolean = false;
|
||||
@sync("int8") int8Field: number = 0;
|
||||
@sync("uint8") uint8Field: number = 0;
|
||||
@sync("int16") int16Field: number = 0;
|
||||
@sync("uint16") uint16Field: number = 0;
|
||||
@sync("int32") int32Field: number = 0;
|
||||
@sync("uint32") uint32Field: number = 0;
|
||||
@sync("float32") float32Field: number = 0;
|
||||
@sync("float64") float64Field: number = 0;
|
||||
@sync("string") stringField: string = "";
|
||||
}
|
||||
|
||||
describe('@sync 装饰器测试', () => {
|
||||
describe('getSyncMetadata', () => {
|
||||
test('应该返回带 @sync 字段的组件元数据', () => {
|
||||
const metadata = getSyncMetadata(PlayerComponent);
|
||||
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata!.typeId).toBe('SyncTest_PlayerComponent');
|
||||
expect(metadata!.fields.length).toBe(4);
|
||||
});
|
||||
|
||||
test('应该正确记录字段信息', () => {
|
||||
const metadata = getSyncMetadata(PlayerComponent);
|
||||
|
||||
const nameField = metadata!.fields.find(f => f.name === 'name');
|
||||
expect(nameField).toBeDefined();
|
||||
expect(nameField!.type).toBe('string');
|
||||
expect(nameField!.index).toBe(0);
|
||||
|
||||
const scoreField = metadata!.fields.find(f => f.name === 'score');
|
||||
expect(scoreField).toBeDefined();
|
||||
expect(scoreField!.type).toBe('uint16');
|
||||
|
||||
const xField = metadata!.fields.find(f => f.name === 'x');
|
||||
expect(xField).toBeDefined();
|
||||
expect(xField!.type).toBe('float32');
|
||||
});
|
||||
|
||||
test('没有 @sync 字段的组件应该返回 null', () => {
|
||||
const metadata = getSyncMetadata(NoSyncComponent);
|
||||
|
||||
expect(metadata).toBeNull();
|
||||
});
|
||||
|
||||
test('可以从实例获取元数据', () => {
|
||||
const component = new PlayerComponent();
|
||||
const metadata = getSyncMetadata(component);
|
||||
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata!.fields.length).toBe(4);
|
||||
});
|
||||
|
||||
test('fieldIndexMap 应该正确映射字段名到索引', () => {
|
||||
const metadata = getSyncMetadata(PlayerComponent);
|
||||
|
||||
expect(metadata!.fieldIndexMap.get('name')).toBe(0);
|
||||
expect(metadata!.fieldIndexMap.get('score')).toBe(1);
|
||||
expect(metadata!.fieldIndexMap.get('x')).toBe(2);
|
||||
expect(metadata!.fieldIndexMap.get('y')).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSyncFields', () => {
|
||||
test('有 @sync 字段应该返回 true', () => {
|
||||
expect(hasSyncFields(PlayerComponent)).toBe(true);
|
||||
expect(hasSyncFields(new PlayerComponent())).toBe(true);
|
||||
});
|
||||
|
||||
test('没有 @sync 字段应该返回 false', () => {
|
||||
expect(hasSyncFields(NoSyncComponent)).toBe(false);
|
||||
expect(hasSyncFields(new NoSyncComponent())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('支持所有同步类型', () => {
|
||||
test('AllTypesComponent 应该有所有类型的字段', () => {
|
||||
const metadata = getSyncMetadata(AllTypesComponent);
|
||||
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata!.fields.length).toBe(10);
|
||||
|
||||
const types = metadata!.fields.map(f => f.type);
|
||||
expect(types).toContain('boolean');
|
||||
expect(types).toContain('int8');
|
||||
expect(types).toContain('uint8');
|
||||
expect(types).toContain('int16');
|
||||
expect(types).toContain('uint16');
|
||||
expect(types).toContain('int32');
|
||||
expect(types).toContain('uint32');
|
||||
expect(types).toContain('float32');
|
||||
expect(types).toContain('float64');
|
||||
expect(types).toContain('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('字段值拦截', () => {
|
||||
test('修改 @sync 字段应该触发变更追踪', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
expect(tracker).not.toBeNull();
|
||||
tracker!.clear();
|
||||
|
||||
component.name = "TestPlayer";
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(true);
|
||||
expect(tracker!.isDirty(0)).toBe(true);
|
||||
});
|
||||
|
||||
test('设置相同值不应该触发变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
component.name = "Test";
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
tracker!.clear();
|
||||
|
||||
component.name = "Test";
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(false);
|
||||
});
|
||||
|
||||
test('修改非 @sync 字段不应该触发变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
tracker!.clear();
|
||||
|
||||
component.localData = "new value";
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(false);
|
||||
});
|
||||
|
||||
test('多个字段变更应该都被追踪', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
tracker!.clear();
|
||||
|
||||
component.name = "NewName";
|
||||
component.score = 100;
|
||||
component.x = 1.5;
|
||||
|
||||
expect(tracker!.getDirtyCount()).toBe(3);
|
||||
expect(tracker!.isDirty(0)).toBe(true);
|
||||
expect(tracker!.isDirty(1)).toBe(true);
|
||||
expect(tracker!.isDirty(2)).toBe(true);
|
||||
expect(tracker!.isDirty(3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initChangeTracker', () => {
|
||||
test('应该创建变更追踪器', () => {
|
||||
const component = new PlayerComponent();
|
||||
|
||||
expect(getChangeTracker(component)).toBeNull();
|
||||
|
||||
initChangeTracker(component);
|
||||
|
||||
expect(getChangeTracker(component)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('应该标记所有字段为脏(用于首次同步)', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
expect(tracker!.hasChanges()).toBe(true);
|
||||
expect(tracker!.getDirtyCount()).toBe(4);
|
||||
});
|
||||
|
||||
test('对没有 @sync 字段的组件应该抛出错误', () => {
|
||||
const component = new NoSyncComponent();
|
||||
|
||||
expect(() => {
|
||||
initChangeTracker(component);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test('重复初始化应该重新标记所有字段', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
tracker!.clear();
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(false);
|
||||
|
||||
initChangeTracker(component);
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(true);
|
||||
expect(tracker!.getDirtyCount()).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearChanges', () => {
|
||||
test('应该清除所有变更标记', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
expect(hasChanges(component)).toBe(true);
|
||||
|
||||
clearChanges(component);
|
||||
|
||||
expect(hasChanges(component)).toBe(false);
|
||||
});
|
||||
|
||||
test('对没有追踪器的组件应该安全执行', () => {
|
||||
const component = new PlayerComponent();
|
||||
|
||||
expect(() => {
|
||||
clearChanges(component);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges', () => {
|
||||
test('初始化后应该有变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
expect(hasChanges(component)).toBe(true);
|
||||
});
|
||||
|
||||
test('清除后应该没有变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
clearChanges(component);
|
||||
|
||||
expect(hasChanges(component)).toBe(false);
|
||||
});
|
||||
|
||||
test('修改字段后应该有变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
clearChanges(component);
|
||||
|
||||
component.score = 999;
|
||||
|
||||
expect(hasChanges(component)).toBe(true);
|
||||
});
|
||||
|
||||
test('没有追踪器应该返回 false', () => {
|
||||
const component = new PlayerComponent();
|
||||
|
||||
expect(hasChanges(component)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('与实体集成', () => {
|
||||
let scene: Scene;
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new Scene();
|
||||
});
|
||||
|
||||
test('添加到实体的组件应该能正常工作', () => {
|
||||
const entity = scene.createEntity('TestEntity');
|
||||
const component = new PlayerComponent();
|
||||
|
||||
entity.addComponent(component);
|
||||
initChangeTracker(component);
|
||||
|
||||
component.name = "EntityPlayer";
|
||||
component.x = 100;
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
expect(tracker!.hasChanges()).toBe(true);
|
||||
});
|
||||
|
||||
test('从实体获取的组件应该保持追踪状态', () => {
|
||||
const entity = scene.createEntity('TestEntity');
|
||||
const component = new PlayerComponent();
|
||||
|
||||
entity.addComponent(component);
|
||||
initChangeTracker(component);
|
||||
clearChanges(component);
|
||||
|
||||
const retrieved = entity.getComponent(PlayerComponent);
|
||||
retrieved!.score = 50;
|
||||
|
||||
expect(hasChanges(component)).toBe(true);
|
||||
expect(hasChanges(retrieved!)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
530
packages/framework/core/tests/ECS/Sync/encoding.test.ts
Normal file
530
packages/framework/core/tests/ECS/Sync/encoding.test.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import { BinaryWriter } from '../../../src/ECS/Sync/encoding/BinaryWriter';
|
||||
import { BinaryReader } from '../../../src/ECS/Sync/encoding/BinaryReader';
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
||||
import { Scene } from '../../../src/ECS/Scene';
|
||||
import { sync, initChangeTracker, clearChanges } from '../../../src/ECS/Sync/decorators';
|
||||
import {
|
||||
encodeSnapshot,
|
||||
encodeSpawn,
|
||||
encodeDespawn,
|
||||
encodeDespawnBatch
|
||||
} from '../../../src/ECS/Sync/encoding/Encoder';
|
||||
import {
|
||||
decodeSnapshot,
|
||||
decodeSpawn,
|
||||
processDespawn
|
||||
} from '../../../src/ECS/Sync/encoding/Decoder';
|
||||
import { SyncOperation } from '../../../src/ECS/Sync/types';
|
||||
|
||||
@ECSComponent('EncodingTest_PlayerComponent')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
}
|
||||
|
||||
@ECSComponent('EncodingTest_AllTypesComponent')
|
||||
class AllTypesComponent extends Component {
|
||||
@sync("boolean") boolField: boolean = false;
|
||||
@sync("int8") int8Field: number = 0;
|
||||
@sync("uint8") uint8Field: number = 0;
|
||||
@sync("int16") int16Field: number = 0;
|
||||
@sync("uint16") uint16Field: number = 0;
|
||||
@sync("int32") int32Field: number = 0;
|
||||
@sync("uint32") uint32Field: number = 0;
|
||||
@sync("float32") float32Field: number = 0;
|
||||
@sync("float64") float64Field: number = 0;
|
||||
@sync("string") stringField: string = "";
|
||||
}
|
||||
|
||||
describe('BinaryWriter/BinaryReader - 二进制读写器测试', () => {
|
||||
describe('基本数值类型', () => {
|
||||
test('writeUint8/readUint8', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint8(0);
|
||||
writer.writeUint8(127);
|
||||
writer.writeUint8(255);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readUint8()).toBe(0);
|
||||
expect(reader.readUint8()).toBe(127);
|
||||
expect(reader.readUint8()).toBe(255);
|
||||
});
|
||||
|
||||
test('writeInt8/readInt8', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeInt8(-128);
|
||||
writer.writeInt8(0);
|
||||
writer.writeInt8(127);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readInt8()).toBe(-128);
|
||||
expect(reader.readInt8()).toBe(0);
|
||||
expect(reader.readInt8()).toBe(127);
|
||||
});
|
||||
|
||||
test('writeBoolean/readBoolean', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeBoolean(true);
|
||||
writer.writeBoolean(false);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readBoolean()).toBe(true);
|
||||
expect(reader.readBoolean()).toBe(false);
|
||||
});
|
||||
|
||||
test('writeUint16/readUint16', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint16(0);
|
||||
writer.writeUint16(32767);
|
||||
writer.writeUint16(65535);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readUint16()).toBe(0);
|
||||
expect(reader.readUint16()).toBe(32767);
|
||||
expect(reader.readUint16()).toBe(65535);
|
||||
});
|
||||
|
||||
test('writeInt16/readInt16', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeInt16(-32768);
|
||||
writer.writeInt16(0);
|
||||
writer.writeInt16(32767);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readInt16()).toBe(-32768);
|
||||
expect(reader.readInt16()).toBe(0);
|
||||
expect(reader.readInt16()).toBe(32767);
|
||||
});
|
||||
|
||||
test('writeUint32/readUint32', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint32(0);
|
||||
writer.writeUint32(2147483647);
|
||||
writer.writeUint32(4294967295);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readUint32()).toBe(0);
|
||||
expect(reader.readUint32()).toBe(2147483647);
|
||||
expect(reader.readUint32()).toBe(4294967295);
|
||||
});
|
||||
|
||||
test('writeInt32/readInt32', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeInt32(-2147483648);
|
||||
writer.writeInt32(0);
|
||||
writer.writeInt32(2147483647);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readInt32()).toBe(-2147483648);
|
||||
expect(reader.readInt32()).toBe(0);
|
||||
expect(reader.readInt32()).toBe(2147483647);
|
||||
});
|
||||
|
||||
test('writeFloat32/readFloat32', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeFloat32(0);
|
||||
writer.writeFloat32(3.14);
|
||||
writer.writeFloat32(-100.5);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readFloat32()).toBe(0);
|
||||
expect(reader.readFloat32()).toBeCloseTo(3.14, 5);
|
||||
expect(reader.readFloat32()).toBeCloseTo(-100.5, 5);
|
||||
});
|
||||
|
||||
test('writeFloat64/readFloat64', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeFloat64(0);
|
||||
writer.writeFloat64(Math.PI);
|
||||
writer.writeFloat64(-1e100);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readFloat64()).toBe(0);
|
||||
expect(reader.readFloat64()).toBe(Math.PI);
|
||||
expect(reader.readFloat64()).toBe(-1e100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('变长整数 (Varint)', () => {
|
||||
test('小值 (1字节)', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeVarint(0);
|
||||
writer.writeVarint(127);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readVarint()).toBe(0);
|
||||
expect(reader.readVarint()).toBe(127);
|
||||
});
|
||||
|
||||
test('中等值 (2字节)', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeVarint(128);
|
||||
writer.writeVarint(16383);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readVarint()).toBe(128);
|
||||
expect(reader.readVarint()).toBe(16383);
|
||||
});
|
||||
|
||||
test('大值 (多字节)', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeVarint(16384);
|
||||
writer.writeVarint(1000000);
|
||||
writer.writeVarint(2147483647);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readVarint()).toBe(16384);
|
||||
expect(reader.readVarint()).toBe(1000000);
|
||||
expect(reader.readVarint()).toBe(2147483647);
|
||||
});
|
||||
});
|
||||
|
||||
describe('字符串', () => {
|
||||
test('空字符串', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeString("");
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readString()).toBe("");
|
||||
});
|
||||
|
||||
test('ASCII 字符串', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeString("Hello, World!");
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readString()).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
test('Unicode 字符串', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeString("你好世界");
|
||||
writer.writeString("日本語テスト");
|
||||
writer.writeString("emoji: 🎮🎯");
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readString()).toBe("你好世界");
|
||||
expect(reader.readString()).toBe("日本語テスト");
|
||||
expect(reader.readString()).toBe("emoji: 🎮🎯");
|
||||
});
|
||||
|
||||
test('混合字符串', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeString("Player_玩家_プレイヤー");
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readString()).toBe("Player_玩家_プレイヤー");
|
||||
});
|
||||
});
|
||||
|
||||
describe('字节数组', () => {
|
||||
test('writeBytes/readBytes', () => {
|
||||
const writer = new BinaryWriter();
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
writer.writeBytes(data);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
const result = reader.readBytes(5);
|
||||
expect(Array.from(result)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BinaryReader 辅助方法', () => {
|
||||
test('remaining 应该返回剩余字节数', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint32(100);
|
||||
writer.writeUint32(200);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.remaining).toBe(8);
|
||||
|
||||
reader.readUint32();
|
||||
expect(reader.remaining).toBe(4);
|
||||
});
|
||||
|
||||
test('hasMore 应该正确判断', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint8(1);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.hasMore()).toBe(true);
|
||||
|
||||
reader.readUint8();
|
||||
expect(reader.hasMore()).toBe(false);
|
||||
});
|
||||
|
||||
test('peekUint8 不应该移动读取位置', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint8(42);
|
||||
writer.writeUint8(99);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.peekUint8()).toBe(42);
|
||||
expect(reader.peekUint8()).toBe(42);
|
||||
expect(reader.offset).toBe(0);
|
||||
});
|
||||
|
||||
test('skip 应该跳过指定字节', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint8(1);
|
||||
writer.writeUint8(2);
|
||||
writer.writeUint8(3);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
reader.skip(2);
|
||||
expect(reader.readUint8()).toBe(3);
|
||||
});
|
||||
|
||||
test('读取超出范围应该抛出错误', () => {
|
||||
const reader = new BinaryReader(new Uint8Array([1, 2]));
|
||||
|
||||
expect(() => reader.readUint32()).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BinaryWriter 自动扩容', () => {
|
||||
test('应该自动扩容', () => {
|
||||
const writer = new BinaryWriter(4);
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
writer.writeUint32(i);
|
||||
}
|
||||
|
||||
expect(writer.offset).toBe(400);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(reader.readUint32()).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
test('reset 应该清空数据但保留缓冲区', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint32(100);
|
||||
writer.writeUint32(200);
|
||||
|
||||
expect(writer.offset).toBe(8);
|
||||
|
||||
writer.reset();
|
||||
|
||||
expect(writer.offset).toBe(0);
|
||||
expect(writer.toUint8Array().length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Encoder/Decoder - 实体编解码测试', () => {
|
||||
let scene: Scene;
|
||||
|
||||
// Components are auto-registered via @ECSComponent decorator
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new Scene();
|
||||
});
|
||||
|
||||
describe('encodeSnapshot/decodeSnapshot', () => {
|
||||
test('应该编码和解码单个实体', () => {
|
||||
const entity = scene.createEntity('Player1');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = "TestPlayer";
|
||||
comp.score = 100;
|
||||
comp.x = 10.5;
|
||||
comp.y = 20.5;
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSnapshot([entity], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSnapshot(targetScene, data);
|
||||
|
||||
expect(result.operation).toBe(SyncOperation.FULL);
|
||||
expect(result.entities.length).toBe(1);
|
||||
expect(result.entities[0].isNew).toBe(true);
|
||||
|
||||
const decodedEntity = targetScene.entities.buffer[0];
|
||||
expect(decodedEntity).toBeDefined();
|
||||
|
||||
const decodedComp = decodedEntity!.getComponent(PlayerComponent);
|
||||
expect(decodedComp).not.toBeNull();
|
||||
expect(decodedComp!.name).toBe("TestPlayer");
|
||||
expect(decodedComp!.score).toBe(100);
|
||||
expect(decodedComp!.x).toBeCloseTo(10.5, 5);
|
||||
expect(decodedComp!.y).toBeCloseTo(20.5, 5);
|
||||
});
|
||||
|
||||
test('应该编码和解码多个实体', () => {
|
||||
const entity1 = scene.createEntity('Player1');
|
||||
const comp1 = entity1.addComponent(new PlayerComponent());
|
||||
comp1.name = "Player1";
|
||||
comp1.score = 50;
|
||||
initChangeTracker(comp1);
|
||||
|
||||
const entity2 = scene.createEntity('Player2');
|
||||
const comp2 = entity2.addComponent(new PlayerComponent());
|
||||
comp2.name = "Player2";
|
||||
comp2.score = 100;
|
||||
initChangeTracker(comp2);
|
||||
|
||||
const data = encodeSnapshot([entity1, entity2], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSnapshot(targetScene, data);
|
||||
|
||||
expect(result.entities.length).toBe(2);
|
||||
});
|
||||
|
||||
test('DELTA 操作应该只编码变更的字段', () => {
|
||||
const entity = scene.createEntity('Player1');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = "TestPlayer";
|
||||
comp.score = 0;
|
||||
initChangeTracker(comp);
|
||||
clearChanges(comp);
|
||||
|
||||
comp.score = 200;
|
||||
|
||||
const deltaData = encodeSnapshot([entity], SyncOperation.DELTA);
|
||||
|
||||
expect(deltaData[0]).toBe(SyncOperation.DELTA);
|
||||
expect(deltaData.length).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeSpawn/decodeSpawn', () => {
|
||||
test('应该编码和解码实体生成', () => {
|
||||
const entity = scene.createEntity('SpawnedEntity');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = "SpawnedPlayer";
|
||||
comp.score = 50;
|
||||
comp.x = 100;
|
||||
comp.y = 200;
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSpawn(entity, 'Player');
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSpawn(targetScene, data);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.prefabType).toBe('Player');
|
||||
expect(result!.componentTypes).toContain('EncodingTest_PlayerComponent');
|
||||
|
||||
const decodedComp = result!.entity.getComponent(PlayerComponent);
|
||||
expect(decodedComp!.name).toBe("SpawnedPlayer");
|
||||
expect(decodedComp!.score).toBe(50);
|
||||
});
|
||||
|
||||
test('没有 prefabType 应该也能工作', () => {
|
||||
const entity = scene.createEntity('Entity');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSpawn(entity);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSpawn(targetScene, data);
|
||||
|
||||
expect(result!.prefabType).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeDespawn/processDespawn', () => {
|
||||
test('应该编码和处理单个实体销毁', () => {
|
||||
const targetScene = new Scene();
|
||||
const entity = targetScene.createEntity('ToBeDestroyed');
|
||||
const entityId = entity.id;
|
||||
|
||||
const data = encodeDespawn(entityId);
|
||||
|
||||
expect(data[0]).toBe(SyncOperation.DESPAWN);
|
||||
|
||||
const removedIds = processDespawn(targetScene, data);
|
||||
|
||||
expect(removedIds).toContain(entityId);
|
||||
});
|
||||
|
||||
test('应该编码和处理批量实体销毁', () => {
|
||||
const targetScene = new Scene();
|
||||
const entity1 = targetScene.createEntity('Entity1');
|
||||
const entity2 = targetScene.createEntity('Entity2');
|
||||
const entity3 = targetScene.createEntity('Entity3');
|
||||
|
||||
const data = encodeDespawnBatch([entity1.id, entity2.id, entity3.id]);
|
||||
|
||||
expect(data[0]).toBe(SyncOperation.DESPAWN);
|
||||
|
||||
const removedIds = processDespawn(targetScene, data);
|
||||
|
||||
expect(removedIds.length).toBe(3);
|
||||
expect(removedIds).toContain(entity1.id);
|
||||
expect(removedIds).toContain(entity2.id);
|
||||
expect(removedIds).toContain(entity3.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('所有同步类型编解码', () => {
|
||||
test('应该正确编解码所有类型', () => {
|
||||
const entity = scene.createEntity('AllTypes');
|
||||
const comp = entity.addComponent(new AllTypesComponent());
|
||||
comp.boolField = true;
|
||||
comp.int8Field = -100;
|
||||
comp.uint8Field = 200;
|
||||
comp.int16Field = -30000;
|
||||
comp.uint16Field = 60000;
|
||||
comp.int32Field = -2000000000;
|
||||
comp.uint32Field = 4000000000;
|
||||
comp.float32Field = 3.14159;
|
||||
comp.float64Field = Math.PI;
|
||||
comp.stringField = "测试字符串";
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSnapshot([entity], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
decodeSnapshot(targetScene, data);
|
||||
|
||||
const decodedEntity = targetScene.entities.buffer[0];
|
||||
const decodedComp = decodedEntity!.getComponent(AllTypesComponent);
|
||||
|
||||
expect(decodedComp!.boolField).toBe(true);
|
||||
expect(decodedComp!.int8Field).toBe(-100);
|
||||
expect(decodedComp!.uint8Field).toBe(200);
|
||||
expect(decodedComp!.int16Field).toBe(-30000);
|
||||
expect(decodedComp!.uint16Field).toBe(60000);
|
||||
expect(decodedComp!.int32Field).toBe(-2000000000);
|
||||
expect(decodedComp!.uint32Field).toBe(4000000000);
|
||||
expect(decodedComp!.float32Field).toBeCloseTo(3.14159, 4);
|
||||
expect(decodedComp!.float64Field).toBe(Math.PI);
|
||||
expect(decodedComp!.stringField).toBe("测试字符串");
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
test('空实体列表应该能编码', () => {
|
||||
const data = encodeSnapshot([], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSnapshot(targetScene, data);
|
||||
|
||||
expect(result.entities.length).toBe(0);
|
||||
});
|
||||
|
||||
test('entityMap 应该正确跟踪实体', () => {
|
||||
const entity = scene.createEntity('Tracked');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = "TrackedPlayer";
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSnapshot([entity], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const entityMap = new Map();
|
||||
decodeSnapshot(targetScene, data, entityMap);
|
||||
|
||||
expect(entityMap.size).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,29 @@
|
||||
# @esengine/fsm
|
||||
|
||||
## 3.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @esengine/ecs-framework@2.6.0
|
||||
- @esengine/blueprint@3.0.0
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
- @esengine/blueprint@2.0.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
- @esengine/blueprint@2.0.0
|
||||
|
||||
## 1.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/fsm",
|
||||
"version": "1.0.3",
|
||||
"version": "3.0.0",
|
||||
"description": "Finite State Machine for ECS Framework / ECS 框架的有限状态机",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,147 @@
|
||||
# @esengine/network
|
||||
|
||||
## 4.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @esengine/ecs-framework@2.6.0
|
||||
- @esengine/blueprint@3.0.0
|
||||
|
||||
## 3.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
- @esengine/blueprint@2.0.1
|
||||
|
||||
## 3.0.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#390](https://github.com/esengine/esengine/pull/390) [`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256) Thanks [@esengine](https://github.com/esengine)! - feat: ECS 网络状态同步系统
|
||||
|
||||
## @esengine/ecs-framework
|
||||
|
||||
新增 `@sync` 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync('string') name: string = '';
|
||||
@sync('uint16') score: number = 0;
|
||||
@sync('float32') x: number = 0;
|
||||
@sync('float32') y: number = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 新增导出
|
||||
- `sync` - 标记需要同步的字段装饰器
|
||||
- `SyncType` - 支持的同步类型
|
||||
- `SyncOperation` - 同步操作类型(FULL/DELTA/SPAWN/DESPAWN)
|
||||
- `encodeSnapshot` / `decodeSnapshot` - 批量编解码
|
||||
- `encodeSpawn` / `decodeSpawn` - 实体生成编解码
|
||||
- `encodeDespawn` / `processDespawn` - 实体销毁编解码
|
||||
- `ChangeTracker` - 字段级变更追踪
|
||||
- `initChangeTracker` / `clearChanges` / `hasChanges` - 变更追踪工具函数
|
||||
|
||||
### 内部方法标记
|
||||
|
||||
将以下方法标记为 `@internal`,用户应通过 `Core.update()` 驱动更新:
|
||||
- `Scene.update()`
|
||||
- `SceneManager.update()`
|
||||
- `WorldManager.updateAll()`
|
||||
|
||||
## @esengine/network
|
||||
|
||||
新增 `ComponentSyncSystem`,基于 `@sync` 装饰器自动同步组件状态:
|
||||
|
||||
```typescript
|
||||
import { ComponentSyncSystem } from '@esengine/network';
|
||||
|
||||
// 服务端:编码状态
|
||||
const data = syncSystem.encodeAllEntities(false);
|
||||
|
||||
// 客户端:解码状态
|
||||
syncSystem.applySnapshot(data);
|
||||
```
|
||||
|
||||
### 修复
|
||||
- 将 `@esengine/ecs-framework` 从 devDependencies 移到 peerDependencies
|
||||
|
||||
## @esengine/server
|
||||
|
||||
新增 `ECSRoom`,带有 ECS World 支持的房间基类:
|
||||
|
||||
```typescript
|
||||
import { ECSRoom } from '@esengine/server/ecs';
|
||||
|
||||
// 服务端启动
|
||||
Core.create();
|
||||
setInterval(() => Core.update(1 / 60), 16);
|
||||
|
||||
// 定义房间
|
||||
class GameRoom extends ECSRoom {
|
||||
onCreate() {
|
||||
this.addSystem(new PhysicsSystem());
|
||||
}
|
||||
|
||||
onJoin(player: Player) {
|
||||
const entity = this.createPlayerEntity(player.id);
|
||||
entity.addComponent(new PlayerComponent());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 设计
|
||||
- 每个 `ECSRoom` 在 `Core.worldManager` 中创建独立的 World
|
||||
- `Core.update()` 统一更新 Time 和所有 World
|
||||
- `onTick()` 只处理状态同步逻辑
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
- @esengine/blueprint@2.0.0
|
||||
|
||||
## 2.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#379](https://github.com/esengine/esengine/pull/379) [`fb8bde6`](https://github.com/esengine/esengine/commit/fb8bde64856ef71ea8e20906496682ccfb27f9b3) Thanks [@esengine](https://github.com/esengine)! - feat(network): 网络模块增强
|
||||
|
||||
### 新增功能
|
||||
- **客户端预测 (NetworkPredictionSystem)**
|
||||
- 本地输入预测和服务器校正
|
||||
- 平滑的校正偏移应用
|
||||
- 可配置移动速度、校正阈值等
|
||||
- **兴趣区域管理 (NetworkAOISystem)**
|
||||
- 基于网格的 AOI 实现
|
||||
- 观察者进入/离开事件
|
||||
- 同步数据过滤
|
||||
- **状态增量压缩 (StateDeltaCompressor)**
|
||||
- 只发送变化的字段
|
||||
- 可配置变化阈值
|
||||
- 定期完整快照
|
||||
- **断线重连**
|
||||
- 自动重连机制
|
||||
- Token 认证
|
||||
- 完整状态恢复
|
||||
|
||||
### 协议增强
|
||||
- 添加输入序列号和时间戳
|
||||
- 添加速度和角速度字段
|
||||
- 添加自定义数据字段
|
||||
- 新增重连协议
|
||||
|
||||
### 文档
|
||||
- 添加客户端预测文档(中英文)
|
||||
- 添加 AOI 文档(中英文)
|
||||
- 添加增量压缩文档(中英文)
|
||||
|
||||
## 2.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/network",
|
||||
"version": "2.1.1",
|
||||
"version": "4.0.0",
|
||||
"description": "Network synchronization for multiplayer games",
|
||||
"esengine": {
|
||||
"plugin": true,
|
||||
@@ -30,6 +30,15 @@
|
||||
"dependencies": {
|
||||
"@esengine/rpc": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/blueprint": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@esengine/blueprint": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/blueprint": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
|
||||
@@ -1,13 +1,126 @@
|
||||
/**
|
||||
* @zh 网络插件
|
||||
* @en Network Plugin
|
||||
*
|
||||
* @zh 提供基于 @esengine/rpc 的网络同步功能,支持客户端预测和断线重连
|
||||
* @en Provides @esengine/rpc based network synchronization with client prediction and reconnection
|
||||
*/
|
||||
|
||||
import { type IPlugin, Core, type ServiceContainer, type Scene } from '@esengine/ecs-framework'
|
||||
import { GameNetworkService, type NetworkServiceOptions } from './services/NetworkService'
|
||||
import { NetworkSyncSystem } from './systems/NetworkSyncSystem'
|
||||
import {
|
||||
GameNetworkService,
|
||||
type NetworkServiceOptions,
|
||||
NetworkState,
|
||||
} from './services/NetworkService'
|
||||
import { NetworkSyncSystem, type NetworkSyncConfig } from './systems/NetworkSyncSystem'
|
||||
import { NetworkSpawnSystem, type PrefabFactory } from './systems/NetworkSpawnSystem'
|
||||
import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
import { NetworkInputSystem, type NetworkInputConfig } from './systems/NetworkInputSystem'
|
||||
import {
|
||||
NetworkPredictionSystem,
|
||||
type NetworkPredictionConfig,
|
||||
} from './systems/NetworkPredictionSystem'
|
||||
import {
|
||||
NetworkAOISystem,
|
||||
type NetworkAOIConfig,
|
||||
} from './systems/NetworkAOISystem'
|
||||
import type { FullStateData, SyncData } from './protocol'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络插件配置
|
||||
* @en Network plugin configuration
|
||||
*/
|
||||
export interface NetworkPluginConfig {
|
||||
/**
|
||||
* @zh 是否启用客户端预测
|
||||
* @en Whether to enable client prediction
|
||||
*/
|
||||
enablePrediction: boolean
|
||||
|
||||
/**
|
||||
* @zh 是否启用自动重连
|
||||
* @en Whether to enable auto reconnection
|
||||
*/
|
||||
enableAutoReconnect: boolean
|
||||
|
||||
/**
|
||||
* @zh 重连最大尝试次数
|
||||
* @en Maximum reconnection attempts
|
||||
*/
|
||||
maxReconnectAttempts: number
|
||||
|
||||
/**
|
||||
* @zh 重连间隔(毫秒)
|
||||
* @en Reconnection interval in milliseconds
|
||||
*/
|
||||
reconnectInterval: number
|
||||
|
||||
/**
|
||||
* @zh 同步系统配置
|
||||
* @en Sync system configuration
|
||||
*/
|
||||
syncConfig?: Partial<NetworkSyncConfig>
|
||||
|
||||
/**
|
||||
* @zh 输入系统配置
|
||||
* @en Input system configuration
|
||||
*/
|
||||
inputConfig?: Partial<NetworkInputConfig>
|
||||
|
||||
/**
|
||||
* @zh 预测系统配置
|
||||
* @en Prediction system configuration
|
||||
*/
|
||||
predictionConfig?: Partial<NetworkPredictionConfig>
|
||||
|
||||
/**
|
||||
* @zh 是否启用 AOI 兴趣管理
|
||||
* @en Whether to enable AOI interest management
|
||||
*/
|
||||
enableAOI: boolean
|
||||
|
||||
/**
|
||||
* @zh AOI 系统配置
|
||||
* @en AOI system configuration
|
||||
*/
|
||||
aoiConfig?: Partial<NetworkAOIConfig>
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: NetworkPluginConfig = {
|
||||
enablePrediction: true,
|
||||
enableAutoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectInterval: 2000,
|
||||
enableAOI: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 连接选项
|
||||
* @en Connection options
|
||||
*/
|
||||
export interface ConnectOptions extends NetworkServiceOptions {
|
||||
playerName: string
|
||||
roomId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重连状态
|
||||
* @en Reconnection state
|
||||
*/
|
||||
interface ReconnectState {
|
||||
token: string
|
||||
playerId: number
|
||||
roomId: string
|
||||
attempts: number
|
||||
isReconnecting: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NetworkPlugin | 网络插件
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络插件
|
||||
@@ -21,7 +134,10 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
* import { Core } from '@esengine/ecs-framework'
|
||||
* import { NetworkPlugin } from '@esengine/network'
|
||||
*
|
||||
* const networkPlugin = new NetworkPlugin()
|
||||
* const networkPlugin = new NetworkPlugin({
|
||||
* enablePrediction: true,
|
||||
* enableAutoReconnect: true
|
||||
* })
|
||||
* await Core.installPlugin(networkPlugin)
|
||||
*
|
||||
* // 连接到服务器
|
||||
@@ -36,13 +152,28 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
*/
|
||||
export class NetworkPlugin implements IPlugin {
|
||||
public readonly name = '@esengine/network'
|
||||
public readonly version = '2.0.0'
|
||||
public readonly version = '2.1.0'
|
||||
|
||||
private readonly _config: NetworkPluginConfig
|
||||
private _networkService!: GameNetworkService
|
||||
private _syncSystem!: NetworkSyncSystem
|
||||
private _spawnSystem!: NetworkSpawnSystem
|
||||
private _inputSystem!: NetworkInputSystem
|
||||
private _predictionSystem: NetworkPredictionSystem | null = null
|
||||
private _aoiSystem: NetworkAOISystem | null = null
|
||||
|
||||
private _localPlayerId: number = 0
|
||||
private _reconnectState: ReconnectState | null = null
|
||||
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private _lastConnectOptions: ConnectOptions | null = null
|
||||
|
||||
constructor(config?: Partial<NetworkPluginConfig>) {
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Getters | 属性访问器
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络服务
|
||||
@@ -76,6 +207,22 @@ export class NetworkPlugin implements IPlugin {
|
||||
return this._inputSystem
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 预测系统
|
||||
* @en Prediction system
|
||||
*/
|
||||
get predictionSystem(): NetworkPredictionSystem | null {
|
||||
return this._predictionSystem
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh AOI 系统
|
||||
* @en AOI system
|
||||
*/
|
||||
get aoiSystem(): NetworkAOISystem | null {
|
||||
return this._aoiSystem
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 本地玩家 ID
|
||||
* @en Local player ID
|
||||
@@ -92,6 +239,34 @@ export class NetworkPlugin implements IPlugin {
|
||||
return this._networkService?.isConnected ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否正在重连
|
||||
* @en Is reconnecting
|
||||
*/
|
||||
get isReconnecting(): boolean {
|
||||
return this._reconnectState?.isReconnecting ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否启用预测
|
||||
* @en Is prediction enabled
|
||||
*/
|
||||
get isPredictionEnabled(): boolean {
|
||||
return this._config.enablePrediction && this._predictionSystem !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否启用 AOI
|
||||
* @en Is AOI enabled
|
||||
*/
|
||||
get isAOIEnabled(): boolean {
|
||||
return this._config.enableAOI && this._aoiSystem !== null
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Plugin Lifecycle | 插件生命周期
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 安装插件
|
||||
* @en Install plugin
|
||||
@@ -110,13 +285,28 @@ export class NetworkPlugin implements IPlugin {
|
||||
* @en Uninstall plugin
|
||||
*/
|
||||
uninstall(): void {
|
||||
this._clearReconnectTimer()
|
||||
this._networkService?.disconnect()
|
||||
}
|
||||
|
||||
private _setupSystems(scene: Scene): void {
|
||||
this._syncSystem = new NetworkSyncSystem()
|
||||
// Create systems
|
||||
this._syncSystem = new NetworkSyncSystem(this._config.syncConfig)
|
||||
this._spawnSystem = new NetworkSpawnSystem(this._syncSystem)
|
||||
this._inputSystem = new NetworkInputSystem(this._networkService)
|
||||
this._inputSystem = new NetworkInputSystem(this._networkService, this._config.inputConfig)
|
||||
|
||||
// Create prediction system if enabled
|
||||
if (this._config.enablePrediction) {
|
||||
this._predictionSystem = new NetworkPredictionSystem(this._config.predictionConfig)
|
||||
this._inputSystem.setPredictionSystem(this._predictionSystem)
|
||||
scene.addSystem(this._predictionSystem)
|
||||
}
|
||||
|
||||
// Create AOI system if enabled
|
||||
if (this._config.enableAOI) {
|
||||
this._aoiSystem = new NetworkAOISystem(this._config.aoiConfig)
|
||||
scene.addSystem(this._aoiSystem)
|
||||
}
|
||||
|
||||
scene.addSystem(this._syncSystem)
|
||||
scene.addSystem(this._spawnSystem)
|
||||
@@ -127,8 +317,14 @@ export class NetworkPlugin implements IPlugin {
|
||||
|
||||
private _setupMessageHandlers(): void {
|
||||
this._networkService
|
||||
.onSync((data) => {
|
||||
this._syncSystem.handleSync({ entities: data.entities })
|
||||
.onSync((data: SyncData) => {
|
||||
// Use new sync handler with timestamps
|
||||
this._syncSystem.handleSyncData(data)
|
||||
|
||||
// Reconcile prediction if enabled
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.reconcileWithServer(data)
|
||||
}
|
||||
})
|
||||
.onSpawn((data) => {
|
||||
this._spawnSystem.handleSpawn(data)
|
||||
@@ -136,14 +332,32 @@ export class NetworkPlugin implements IPlugin {
|
||||
.onDespawn((data) => {
|
||||
this._spawnSystem.handleDespawn(data)
|
||||
})
|
||||
|
||||
// Handle full state for reconnection
|
||||
this._networkService.on('fullState', (data: FullStateData) => {
|
||||
this._handleFullState(data)
|
||||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Connection | 连接管理
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 连接到服务器
|
||||
* @en Connect to server
|
||||
*/
|
||||
public async connect(options: NetworkServiceOptions & { playerName: string; roomId?: string }): Promise<boolean> {
|
||||
public async connect(options: ConnectOptions): Promise<boolean> {
|
||||
this._lastConnectOptions = options
|
||||
|
||||
try {
|
||||
// Setup disconnect handler for auto-reconnect
|
||||
const originalOnDisconnect = options.onDisconnect
|
||||
options.onDisconnect = (reason) => {
|
||||
originalOnDisconnect?.(reason)
|
||||
this._handleDisconnect(reason)
|
||||
}
|
||||
|
||||
await this._networkService.connect(options)
|
||||
|
||||
const result = await this._networkService.call('join', {
|
||||
@@ -154,8 +368,25 @@ export class NetworkPlugin implements IPlugin {
|
||||
this._localPlayerId = result.playerId
|
||||
this._spawnSystem.setLocalPlayerId(this._localPlayerId)
|
||||
|
||||
// Setup prediction for local player
|
||||
if (this._predictionSystem) {
|
||||
// Will be set when local player entity is spawned
|
||||
}
|
||||
|
||||
// Save reconnect state
|
||||
if (this._config.enableAutoReconnect) {
|
||||
this._reconnectState = {
|
||||
token: this._generateReconnectToken(),
|
||||
playerId: result.playerId,
|
||||
roomId: result.roomId,
|
||||
attempts: 0,
|
||||
isReconnecting: false,
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[NetworkPlugin] Connection failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -165,14 +396,114 @@ export class NetworkPlugin implements IPlugin {
|
||||
* @en Disconnect
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
this._clearReconnectTimer()
|
||||
this._reconnectState = null
|
||||
|
||||
try {
|
||||
await this._networkService.call('leave', undefined)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this._networkService.disconnect()
|
||||
this._cleanup()
|
||||
}
|
||||
|
||||
private _handleDisconnect(reason?: string): void {
|
||||
console.log('[NetworkPlugin] Disconnected:', reason)
|
||||
|
||||
if (this._config.enableAutoReconnect && this._reconnectState && !this._reconnectState.isReconnecting) {
|
||||
this._attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private _attemptReconnect(): void {
|
||||
if (!this._reconnectState || !this._lastConnectOptions) return
|
||||
|
||||
if (this._reconnectState.attempts >= this._config.maxReconnectAttempts) {
|
||||
console.error('[NetworkPlugin] Max reconnection attempts reached')
|
||||
this._reconnectState = null
|
||||
return
|
||||
}
|
||||
|
||||
this._reconnectState.isReconnecting = true
|
||||
this._reconnectState.attempts++
|
||||
|
||||
console.log(`[NetworkPlugin] Attempting reconnection (${this._reconnectState.attempts}/${this._config.maxReconnectAttempts})`)
|
||||
|
||||
this._reconnectTimer = setTimeout(async () => {
|
||||
try {
|
||||
await this._networkService.connect(this._lastConnectOptions!)
|
||||
|
||||
const result = await this._networkService.call('reconnect', {
|
||||
playerId: this._reconnectState!.playerId,
|
||||
roomId: this._reconnectState!.roomId,
|
||||
token: this._reconnectState!.token,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
console.log('[NetworkPlugin] Reconnection successful')
|
||||
this._reconnectState!.isReconnecting = false
|
||||
this._reconnectState!.attempts = 0
|
||||
|
||||
// Restore state
|
||||
if (result.state) {
|
||||
this._handleFullState(result.state)
|
||||
}
|
||||
} else {
|
||||
console.error('[NetworkPlugin] Reconnection rejected:', result.error)
|
||||
this._attemptReconnect()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[NetworkPlugin] Reconnection failed:', err)
|
||||
if (this._reconnectState) {
|
||||
this._reconnectState.isReconnecting = false
|
||||
}
|
||||
this._attemptReconnect()
|
||||
}
|
||||
}, this._config.reconnectInterval)
|
||||
}
|
||||
|
||||
private _handleFullState(data: FullStateData): void {
|
||||
// Clear existing entities
|
||||
this._syncSystem.clearSnapshots()
|
||||
|
||||
// Spawn all entities from full state
|
||||
for (const entityData of data.entities) {
|
||||
this._spawnSystem.handleSpawn(entityData)
|
||||
|
||||
// Apply initial state if available
|
||||
if (entityData.state) {
|
||||
this._syncSystem.handleSyncData({
|
||||
frame: data.frame,
|
||||
timestamp: data.timestamp,
|
||||
entities: [entityData.state],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _clearReconnectTimer(): void {
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer)
|
||||
this._reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private _generateReconnectToken(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
|
||||
}
|
||||
|
||||
private _cleanup(): void {
|
||||
this._localPlayerId = 0
|
||||
this._syncSystem?.clearSnapshots()
|
||||
this._predictionSystem?.reset()
|
||||
this._inputSystem?.reset()
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Game API | 游戏接口
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 注册预制体工厂
|
||||
* @en Register prefab factory
|
||||
@@ -196,4 +527,78 @@ export class NetworkPlugin implements IPlugin {
|
||||
public sendActionInput(action: string): void {
|
||||
this._inputSystem?.addActionInput(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置本地玩家网络 ID(用于预测)
|
||||
* @en Set local player network ID (for prediction)
|
||||
*/
|
||||
public setLocalPlayerNetId(netId: number): void {
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.setLocalPlayerNetId(netId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 启用/禁用预测
|
||||
* @en Enable/disable prediction
|
||||
*/
|
||||
public setPredictionEnabled(enabled: boolean): void {
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AOI API | AOI 接口
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 添加 AOI 观察者(玩家)
|
||||
* @en Add AOI observer (player)
|
||||
*/
|
||||
public addAOIObserver(netId: number, x: number, y: number, viewRange?: number): void {
|
||||
this._aoiSystem?.addObserver(netId, x, y, viewRange)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除 AOI 观察者
|
||||
* @en Remove AOI observer
|
||||
*/
|
||||
public removeAOIObserver(netId: number): void {
|
||||
this._aoiSystem?.removeObserver(netId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 更新 AOI 观察者位置
|
||||
* @en Update AOI observer position
|
||||
*/
|
||||
public updateAOIObserverPosition(netId: number, x: number, y: number): void {
|
||||
this._aoiSystem?.updateObserverPosition(netId, x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取观察者可见的实体
|
||||
* @en Get entities visible to observer
|
||||
*/
|
||||
public getVisibleEntities(observerNetId: number): number[] {
|
||||
return this._aoiSystem?.getVisibleEntities(observerNetId) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否可见
|
||||
* @en Check if visible
|
||||
*/
|
||||
public canSee(observerNetId: number, targetNetId: number): boolean {
|
||||
return this._aoiSystem?.canSee(observerNetId, targetNetId) ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 启用/禁用 AOI
|
||||
* @en Enable/disable AOI
|
||||
*/
|
||||
public setAOIEnabled(enabled: boolean): void {
|
||||
if (this._aoiSystem) {
|
||||
this._aoiSystem.enabled = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ export {
|
||||
type SyncData,
|
||||
type SpawnData,
|
||||
type DespawnData,
|
||||
type FullStateData,
|
||||
type JoinRequest,
|
||||
type JoinResponse,
|
||||
type ReconnectRequest,
|
||||
type ReconnectResponse,
|
||||
} from './protocol'
|
||||
|
||||
// ============================================================================
|
||||
@@ -48,6 +51,8 @@ export {
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from './tokens'
|
||||
|
||||
// ============================================================================
|
||||
@@ -81,10 +86,30 @@ export { NetworkTransform } from './components/NetworkTransform'
|
||||
// ============================================================================
|
||||
|
||||
export { NetworkSyncSystem } from './systems/NetworkSyncSystem'
|
||||
export type { SyncMessage } from './systems/NetworkSyncSystem'
|
||||
export type { SyncMessage, NetworkSyncConfig } from './systems/NetworkSyncSystem'
|
||||
export { NetworkSpawnSystem } from './systems/NetworkSpawnSystem'
|
||||
export type { PrefabFactory, SpawnMessage, DespawnMessage } from './systems/NetworkSpawnSystem'
|
||||
export { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
export { NetworkInputSystem, createNetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
export type { NetworkInputConfig } from './systems/NetworkInputSystem'
|
||||
export {
|
||||
NetworkPredictionSystem,
|
||||
createNetworkPredictionSystem,
|
||||
} from './systems/NetworkPredictionSystem'
|
||||
export type {
|
||||
NetworkPredictionConfig,
|
||||
MovementInput,
|
||||
PredictedTransform,
|
||||
} from './systems/NetworkPredictionSystem'
|
||||
export {
|
||||
NetworkAOISystem,
|
||||
createNetworkAOISystem,
|
||||
} from './systems/NetworkAOISystem'
|
||||
export type {
|
||||
NetworkAOIConfig,
|
||||
NetworkAOIEvent,
|
||||
NetworkAOIEventType,
|
||||
NetworkAOIEventListener,
|
||||
} from './systems/NetworkAOISystem'
|
||||
|
||||
// ============================================================================
|
||||
// State Sync | 状态同步
|
||||
@@ -105,6 +130,14 @@ export type {
|
||||
IPredictedState,
|
||||
IPredictor,
|
||||
ClientPredictionConfig,
|
||||
EntityDeltaState,
|
||||
DeltaSyncData,
|
||||
DeltaCompressionConfig,
|
||||
// Component sync types
|
||||
ComponentSyncEventType,
|
||||
ComponentSyncEvent,
|
||||
ComponentSyncEventListener,
|
||||
ComponentSyncConfig,
|
||||
} from './sync'
|
||||
|
||||
export {
|
||||
@@ -119,6 +152,12 @@ export {
|
||||
createHermiteTransformInterpolator,
|
||||
ClientPrediction,
|
||||
createClientPrediction,
|
||||
DeltaFlags,
|
||||
StateDeltaCompressor,
|
||||
createStateDeltaCompressor,
|
||||
// Component sync
|
||||
ComponentSyncSystem,
|
||||
createComponentSyncSystem,
|
||||
} from './sync'
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -17,12 +17,24 @@ import { rpc } from '@esengine/rpc'
|
||||
* @en Player input
|
||||
*/
|
||||
export interface PlayerInput {
|
||||
/**
|
||||
* @zh 输入序列号(用于客户端预测)
|
||||
* @en Input sequence number (for client prediction)
|
||||
*/
|
||||
seq: number
|
||||
|
||||
/**
|
||||
* @zh 帧序号
|
||||
* @en Frame number
|
||||
*/
|
||||
frame: number
|
||||
|
||||
/**
|
||||
* @zh 客户端时间戳
|
||||
* @en Client timestamp
|
||||
*/
|
||||
timestamp: number
|
||||
|
||||
/**
|
||||
* @zh 移动方向
|
||||
* @en Move direction
|
||||
@@ -41,9 +53,41 @@ export interface PlayerInput {
|
||||
* @en Entity sync state
|
||||
*/
|
||||
export interface EntitySyncState {
|
||||
/**
|
||||
* @zh 网络实体 ID
|
||||
* @en Network entity ID
|
||||
*/
|
||||
netId: number
|
||||
|
||||
/**
|
||||
* @zh 位置
|
||||
* @en Position
|
||||
*/
|
||||
pos?: { x: number; y: number }
|
||||
|
||||
/**
|
||||
* @zh 旋转角度
|
||||
* @en Rotation angle
|
||||
*/
|
||||
rot?: number
|
||||
|
||||
/**
|
||||
* @zh 速度(用于外推)
|
||||
* @en Velocity (for extrapolation)
|
||||
*/
|
||||
vel?: { x: number; y: number }
|
||||
|
||||
/**
|
||||
* @zh 角速度
|
||||
* @en Angular velocity
|
||||
*/
|
||||
angVel?: number
|
||||
|
||||
/**
|
||||
* @zh 自定义数据
|
||||
* @en Custom data
|
||||
*/
|
||||
custom?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,6 +101,18 @@ export interface SyncData {
|
||||
*/
|
||||
frame: number
|
||||
|
||||
/**
|
||||
* @zh 服务器时间戳(用于插值)
|
||||
* @en Server timestamp (for interpolation)
|
||||
*/
|
||||
timestamp: number
|
||||
|
||||
/**
|
||||
* @zh 已确认的输入序列号(用于客户端预测校正)
|
||||
* @en Acknowledged input sequence (for client prediction reconciliation)
|
||||
*/
|
||||
ackSeq?: number
|
||||
|
||||
/**
|
||||
* @zh 实体状态列表
|
||||
* @en Entity state list
|
||||
@@ -84,6 +140,30 @@ export interface DespawnData {
|
||||
netId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 完整状态快照(用于重连)
|
||||
* @en Full state snapshot (for reconnection)
|
||||
*/
|
||||
export interface FullStateData {
|
||||
/**
|
||||
* @zh 服务器帧号
|
||||
* @en Server frame number
|
||||
*/
|
||||
frame: number
|
||||
|
||||
/**
|
||||
* @zh 服务器时间戳
|
||||
* @en Server timestamp
|
||||
*/
|
||||
timestamp: number
|
||||
|
||||
/**
|
||||
* @zh 所有实体状态
|
||||
* @en All entity states
|
||||
*/
|
||||
entities: Array<SpawnData & { state?: EntitySyncState }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Types | API 类型
|
||||
// ============================================================================
|
||||
@@ -106,6 +186,54 @@ export interface JoinResponse {
|
||||
roomId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重连请求
|
||||
* @en Reconnect request
|
||||
*/
|
||||
export interface ReconnectRequest {
|
||||
/**
|
||||
* @zh 之前的玩家 ID
|
||||
* @en Previous player ID
|
||||
*/
|
||||
playerId: number
|
||||
|
||||
/**
|
||||
* @zh 房间 ID
|
||||
* @en Room ID
|
||||
*/
|
||||
roomId: string
|
||||
|
||||
/**
|
||||
* @zh 重连令牌
|
||||
* @en Reconnection token
|
||||
*/
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重连响应
|
||||
* @en Reconnect response
|
||||
*/
|
||||
export interface ReconnectResponse {
|
||||
/**
|
||||
* @zh 是否成功
|
||||
* @en Whether successful
|
||||
*/
|
||||
success: boolean
|
||||
|
||||
/**
|
||||
* @zh 完整状态(成功时)
|
||||
* @en Full state (when successful)
|
||||
*/
|
||||
state?: FullStateData
|
||||
|
||||
/**
|
||||
* @zh 错误信息(失败时)
|
||||
* @en Error message (when failed)
|
||||
*/
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protocol Definition | 协议定义
|
||||
// ============================================================================
|
||||
@@ -145,6 +273,12 @@ export const gameProtocol = rpc.define({
|
||||
* @en Leave room
|
||||
*/
|
||||
leave: rpc.api<void, void>(),
|
||||
|
||||
/**
|
||||
* @zh 重连
|
||||
* @en Reconnect
|
||||
*/
|
||||
reconnect: rpc.api<ReconnectRequest, ReconnectResponse>(),
|
||||
},
|
||||
msg: {
|
||||
/**
|
||||
@@ -170,6 +304,12 @@ export const gameProtocol = rpc.define({
|
||||
* @en Entity despawn
|
||||
*/
|
||||
despawn: rpc.msg<DespawnData>(),
|
||||
|
||||
/**
|
||||
* @zh 完整状态快照
|
||||
* @en Full state snapshot
|
||||
*/
|
||||
fullState: rpc.msg<FullStateData>(),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
408
packages/framework/network/src/sync/ComponentSync.ts
Normal file
408
packages/framework/network/src/sync/ComponentSync.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* @zh 组件同步系统
|
||||
* @en Component Sync System
|
||||
*
|
||||
* @zh 基于 @sync 装饰器的组件状态同步,与 ecs-framework 的 Sync 模块集成
|
||||
* @en Component state synchronization based on @sync decorator, integrated with ecs-framework Sync module
|
||||
*/
|
||||
|
||||
import {
|
||||
EntitySystem,
|
||||
Matcher,
|
||||
type Entity,
|
||||
// Sync types
|
||||
SyncOperation,
|
||||
SYNC_METADATA,
|
||||
CHANGE_TRACKER,
|
||||
type SyncMetadata,
|
||||
type ChangeTracker,
|
||||
// Encoding
|
||||
encodeSnapshot,
|
||||
encodeSpawn,
|
||||
encodeDespawn,
|
||||
decodeSnapshot,
|
||||
decodeSpawn,
|
||||
processDespawn,
|
||||
GlobalComponentRegistry,
|
||||
type DecodeSnapshotResult,
|
||||
type DecodeSpawnResult,
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
import { NetworkIdentity } from '../components/NetworkIdentity';
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 组件同步事件类型
|
||||
* @en Component sync event type
|
||||
*/
|
||||
export type ComponentSyncEventType =
|
||||
| 'entitySpawned'
|
||||
| 'entityDespawned'
|
||||
| 'stateUpdated';
|
||||
|
||||
/**
|
||||
* @zh 组件同步事件
|
||||
* @en Component sync event
|
||||
*/
|
||||
export interface ComponentSyncEvent {
|
||||
type: ComponentSyncEventType;
|
||||
entityId: number;
|
||||
prefabType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 组件同步事件监听器
|
||||
* @en Component sync event listener
|
||||
*/
|
||||
export type ComponentSyncEventListener = (event: ComponentSyncEvent) => void;
|
||||
|
||||
/**
|
||||
* @zh 组件同步配置
|
||||
* @en Component sync configuration
|
||||
*/
|
||||
export interface ComponentSyncConfig {
|
||||
/**
|
||||
* @zh 是否启用增量同步
|
||||
* @en Whether to enable delta sync
|
||||
*/
|
||||
enableDeltaSync: boolean;
|
||||
|
||||
/**
|
||||
* @zh 同步间隔(毫秒)
|
||||
* @en Sync interval in milliseconds
|
||||
*/
|
||||
syncInterval: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ComponentSyncConfig = {
|
||||
enableDeltaSync: true,
|
||||
syncInterval: 50, // 20 Hz
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ComponentSyncSystem | 组件同步系统
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 组件同步系统
|
||||
* @en Component sync system
|
||||
*
|
||||
* @zh 基于 @sync 装饰器自动同步组件状态
|
||||
* @en Automatically syncs component state based on @sync decorator
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Server-side: broadcast state
|
||||
* const syncSystem = scene.getSystem(ComponentSyncSystem);
|
||||
* const data = syncSystem.encodeAllEntities(false); // delta
|
||||
* broadcast(data);
|
||||
*
|
||||
* // Client-side: receive state
|
||||
* const syncSystem = scene.getSystem(ComponentSyncSystem);
|
||||
* syncSystem.applySnapshot(data);
|
||||
* ```
|
||||
*/
|
||||
export class ComponentSyncSystem extends EntitySystem {
|
||||
private readonly _config: ComponentSyncConfig;
|
||||
private readonly _syncEntityMap: Map<number, Entity> = new Map();
|
||||
private readonly _syncListeners: Set<ComponentSyncEventListener> = new Set();
|
||||
private _lastSyncTime: number = 0;
|
||||
private _isServer: boolean = false;
|
||||
|
||||
constructor(config?: Partial<ComponentSyncConfig>, isServer: boolean = false) {
|
||||
super(Matcher.all(NetworkIdentity));
|
||||
this._config = { ...DEFAULT_CONFIG, ...config };
|
||||
this._isServer = isServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置是否为服务端模式
|
||||
* @en Set whether in server mode
|
||||
*/
|
||||
public set isServer(value: boolean) {
|
||||
this._isServer = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取是否为服务端模式
|
||||
* @en Get whether in server mode
|
||||
*/
|
||||
public get isServer(): boolean {
|
||||
return this._isServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
public get config(): Readonly<ComponentSyncConfig> {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 添加同步事件监听器
|
||||
* @en Add sync event listener
|
||||
*/
|
||||
public addSyncListener(listener: ComponentSyncEventListener): void {
|
||||
this._syncListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除同步事件监听器
|
||||
* @en Remove sync event listener
|
||||
*/
|
||||
public removeSyncListener(listener: ComponentSyncEventListener): void {
|
||||
this._syncListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注册同步组件类型
|
||||
* @en Register sync component type
|
||||
*
|
||||
* @zh 客户端需要调用此方法注册所有需要同步的组件类型
|
||||
* @en Client needs to call this to register all component types to be synced
|
||||
*/
|
||||
public registerComponent<T extends new () => any>(componentClass: T): void {
|
||||
GlobalComponentRegistry.register(componentClass as any);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Server-side: Encoding | 服务端:编码
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 编码所有实体状态
|
||||
* @en Encode all entities state
|
||||
*
|
||||
* @param fullSync - @zh 是否完整同步(首次连接时使用)@en Whether to do full sync (for initial connection)
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
public encodeAllEntities(fullSync: boolean = false): Uint8Array {
|
||||
const entities = this.getMatchingEntities();
|
||||
const operation = fullSync ? SyncOperation.FULL : SyncOperation.DELTA;
|
||||
|
||||
const data = encodeSnapshot(entities, operation);
|
||||
|
||||
// Clear change trackers after encoding delta
|
||||
if (!fullSync) {
|
||||
this._clearChangeTrackers(entities);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码有变更的实体
|
||||
* @en Encode entities with changes
|
||||
*
|
||||
* @returns @zh 编码后的二进制数据,如果没有变更返回 null @en Encoded binary data, or null if no changes
|
||||
*/
|
||||
public encodeDelta(): Uint8Array | null {
|
||||
const entities = this.getMatchingEntities();
|
||||
const changedEntities = entities.filter(entity => this._hasChanges(entity));
|
||||
|
||||
if (changedEntities.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = encodeSnapshot(changedEntities, SyncOperation.DELTA);
|
||||
this._clearChangeTrackers(changedEntities);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体生成消息
|
||||
* @en Encode entity spawn message
|
||||
*/
|
||||
public encodeSpawn(entity: Entity, prefabType?: string): Uint8Array {
|
||||
return encodeSpawn(entity, prefabType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体销毁消息
|
||||
* @en Encode entity despawn message
|
||||
*/
|
||||
public encodeDespawn(entityId: number): Uint8Array {
|
||||
return encodeDespawn(entityId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Client-side: Decoding | 客户端:解码
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 应用状态快照
|
||||
* @en Apply state snapshot
|
||||
*
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @returns @zh 解码结果 @en Decode result
|
||||
*/
|
||||
public applySnapshot(data: Uint8Array): DecodeSnapshotResult {
|
||||
if (!this.scene) {
|
||||
throw new Error('ComponentSyncSystem not attached to a scene');
|
||||
}
|
||||
|
||||
const result = decodeSnapshot(this.scene, data, this._syncEntityMap);
|
||||
|
||||
// Emit events
|
||||
for (const entityResult of result.entities) {
|
||||
if (entityResult.isNew) {
|
||||
this._emitEvent({
|
||||
type: 'entitySpawned',
|
||||
entityId: entityResult.entityId,
|
||||
});
|
||||
} else {
|
||||
this._emitEvent({
|
||||
type: 'stateUpdated',
|
||||
entityId: entityResult.entityId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 应用实体生成消息
|
||||
* @en Apply entity spawn message
|
||||
*
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @returns @zh 解码结果,如果不是 SPAWN 消息返回 null @en Decode result, or null if not a SPAWN message
|
||||
*/
|
||||
public applySpawn(data: Uint8Array): DecodeSpawnResult | null {
|
||||
if (!this.scene) {
|
||||
throw new Error('ComponentSyncSystem not attached to a scene');
|
||||
}
|
||||
|
||||
const result = decodeSpawn(this.scene, data, this._syncEntityMap);
|
||||
|
||||
if (result) {
|
||||
this._emitEvent({
|
||||
type: 'entitySpawned',
|
||||
entityId: result.entity.id,
|
||||
prefabType: result.prefabType,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 应用实体销毁消息
|
||||
* @en Apply entity despawn message
|
||||
*
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @returns @zh 销毁的实体 ID 列表 @en List of despawned entity IDs
|
||||
*/
|
||||
public applyDespawn(data: Uint8Array): number[] {
|
||||
if (!this.scene) {
|
||||
throw new Error('ComponentSyncSystem not attached to a scene');
|
||||
}
|
||||
|
||||
const entityIds = processDespawn(this.scene, data, this._syncEntityMap);
|
||||
|
||||
for (const entityId of entityIds) {
|
||||
this._emitEvent({
|
||||
type: 'entityDespawned',
|
||||
entityId,
|
||||
});
|
||||
}
|
||||
|
||||
return entityIds;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Entity Management | 实体管理
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 通过网络 ID 获取实体
|
||||
* @en Get entity by network ID
|
||||
*/
|
||||
public getEntityById(entityId: number): Entity | undefined {
|
||||
return this._syncEntityMap.get(entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有匹配的实体
|
||||
* @en Get all matching entities
|
||||
*/
|
||||
public getMatchingEntities(): Entity[] {
|
||||
return this.entities.slice();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal | 内部方法
|
||||
// =========================================================================
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
// Server mode: auto-sync at interval
|
||||
if (this._isServer && this._config.enableDeltaSync) {
|
||||
const now = Date.now();
|
||||
if (now - this._lastSyncTime >= this._config.syncInterval) {
|
||||
// Note: actual broadcast should be done by the user
|
||||
// This just updates the sync time
|
||||
this._lastSyncTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Update entity ID map
|
||||
for (const entity of entities) {
|
||||
const identity = entity.getComponent(NetworkIdentity);
|
||||
if (identity) {
|
||||
this._syncEntityMap.set(entity.id, entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _hasChanges(entity: Entity): boolean {
|
||||
for (const component of entity.components) {
|
||||
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (tracker?.hasChanges()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _clearChangeTrackers(entities: Entity[]): void {
|
||||
for (const entity of entities) {
|
||||
for (const component of entity.components) {
|
||||
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (tracker) {
|
||||
tracker.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _emitEvent(event: ComponentSyncEvent): void {
|
||||
for (const listener of this._syncListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error('ComponentSyncSystem: event listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
this._syncEntityMap.clear();
|
||||
this._syncListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建组件同步系统
|
||||
* @en Create component sync system
|
||||
*/
|
||||
export function createComponentSyncSystem(
|
||||
config?: Partial<ComponentSyncConfig>,
|
||||
isServer: boolean = false
|
||||
): ComponentSyncSystem {
|
||||
return new ComponentSyncSystem(config, isServer);
|
||||
}
|
||||
440
packages/framework/network/src/sync/StateDelta.ts
Normal file
440
packages/framework/network/src/sync/StateDelta.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* @zh 状态增量压缩
|
||||
* @en State Delta Compression
|
||||
*
|
||||
* @zh 通过只发送变化的字段来减少网络带宽
|
||||
* @en Reduces network bandwidth by only sending changed fields
|
||||
*/
|
||||
|
||||
import type { EntitySyncState, SyncData } from '../protocol'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 增量类型标志
|
||||
* @en Delta type flags
|
||||
*/
|
||||
export const DeltaFlags = {
|
||||
NONE: 0,
|
||||
POSITION: 1 << 0,
|
||||
ROTATION: 1 << 1,
|
||||
VELOCITY: 1 << 2,
|
||||
ANGULAR_VELOCITY: 1 << 3,
|
||||
CUSTOM: 1 << 4,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* @zh 增量状态(只包含变化的字段)
|
||||
* @en Delta state (only contains changed fields)
|
||||
*/
|
||||
export interface EntityDeltaState {
|
||||
/**
|
||||
* @zh 网络标识
|
||||
* @en Network identity
|
||||
*/
|
||||
netId: number
|
||||
|
||||
/**
|
||||
* @zh 变化标志
|
||||
* @en Change flags
|
||||
*/
|
||||
flags: number
|
||||
|
||||
/**
|
||||
* @zh 位置(如果变化)
|
||||
* @en Position (if changed)
|
||||
*/
|
||||
pos?: { x: number; y: number }
|
||||
|
||||
/**
|
||||
* @zh 旋转(如果变化)
|
||||
* @en Rotation (if changed)
|
||||
*/
|
||||
rot?: number
|
||||
|
||||
/**
|
||||
* @zh 速度(如果变化)
|
||||
* @en Velocity (if changed)
|
||||
*/
|
||||
vel?: { x: number; y: number }
|
||||
|
||||
/**
|
||||
* @zh 角速度(如果变化)
|
||||
* @en Angular velocity (if changed)
|
||||
*/
|
||||
angVel?: number
|
||||
|
||||
/**
|
||||
* @zh 自定义数据(如果变化)
|
||||
* @en Custom data (if changed)
|
||||
*/
|
||||
custom?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 增量同步数据
|
||||
* @en Delta sync data
|
||||
*/
|
||||
export interface DeltaSyncData {
|
||||
/**
|
||||
* @zh 帧号
|
||||
* @en Frame number
|
||||
*/
|
||||
frame: number
|
||||
|
||||
/**
|
||||
* @zh 时间戳
|
||||
* @en Timestamp
|
||||
*/
|
||||
timestamp: number
|
||||
|
||||
/**
|
||||
* @zh 已确认的输入序列号
|
||||
* @en Acknowledged input sequence
|
||||
*/
|
||||
ackSeq?: number
|
||||
|
||||
/**
|
||||
* @zh 增量实体状态
|
||||
* @en Delta entity states
|
||||
*/
|
||||
entities: EntityDeltaState[]
|
||||
|
||||
/**
|
||||
* @zh 是否为完整快照
|
||||
* @en Whether this is a full snapshot
|
||||
*/
|
||||
isFullSnapshot?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 增量压缩配置
|
||||
* @en Delta compression configuration
|
||||
*/
|
||||
export interface DeltaCompressionConfig {
|
||||
/**
|
||||
* @zh 位置变化阈值
|
||||
* @en Position change threshold
|
||||
*/
|
||||
positionThreshold: number
|
||||
|
||||
/**
|
||||
* @zh 旋转变化阈值(弧度)
|
||||
* @en Rotation change threshold (radians)
|
||||
*/
|
||||
rotationThreshold: number
|
||||
|
||||
/**
|
||||
* @zh 速度变化阈值
|
||||
* @en Velocity change threshold
|
||||
*/
|
||||
velocityThreshold: number
|
||||
|
||||
/**
|
||||
* @zh 强制完整快照间隔(帧数)
|
||||
* @en Forced full snapshot interval (frames)
|
||||
*/
|
||||
fullSnapshotInterval: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: DeltaCompressionConfig = {
|
||||
positionThreshold: 0.01,
|
||||
rotationThreshold: 0.001,
|
||||
velocityThreshold: 0.1,
|
||||
fullSnapshotInterval: 60,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// StateDeltaCompressor | 状态增量压缩器
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 状态增量压缩器
|
||||
* @en State delta compressor
|
||||
*
|
||||
* @zh 追踪实体状态变化,生成增量更新
|
||||
* @en Tracks entity state changes and generates delta updates
|
||||
*/
|
||||
export class StateDeltaCompressor {
|
||||
private readonly _config: DeltaCompressionConfig
|
||||
private readonly _lastStates: Map<number, EntitySyncState> = new Map()
|
||||
private _frameCounter: number = 0
|
||||
|
||||
constructor(config?: Partial<DeltaCompressionConfig>) {
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
get config(): Readonly<DeltaCompressionConfig> {
|
||||
return this._config
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 压缩同步数据为增量格式
|
||||
* @en Compress sync data to delta format
|
||||
*/
|
||||
compress(data: SyncData): DeltaSyncData {
|
||||
this._frameCounter++
|
||||
|
||||
const isFullSnapshot = this._frameCounter % this._config.fullSnapshotInterval === 0
|
||||
const deltaEntities: EntityDeltaState[] = []
|
||||
|
||||
for (const entity of data.entities) {
|
||||
const lastState = this._lastStates.get(entity.netId)
|
||||
|
||||
if (isFullSnapshot || !lastState) {
|
||||
// Send full state
|
||||
deltaEntities.push(this._createFullDelta(entity))
|
||||
} else {
|
||||
// Calculate delta
|
||||
const delta = this._calculateDelta(lastState, entity)
|
||||
if (delta) {
|
||||
deltaEntities.push(delta)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last state
|
||||
this._lastStates.set(entity.netId, { ...entity })
|
||||
}
|
||||
|
||||
return {
|
||||
frame: data.frame,
|
||||
timestamp: data.timestamp,
|
||||
ackSeq: data.ackSeq,
|
||||
entities: deltaEntities,
|
||||
isFullSnapshot,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解压增量数据为完整同步数据
|
||||
* @en Decompress delta data to full sync data
|
||||
*/
|
||||
decompress(data: DeltaSyncData): SyncData {
|
||||
const entities: EntitySyncState[] = []
|
||||
|
||||
for (const delta of data.entities) {
|
||||
const lastState = this._lastStates.get(delta.netId)
|
||||
const fullState = this._applyDelta(lastState, delta)
|
||||
entities.push(fullState)
|
||||
|
||||
// Update last state
|
||||
this._lastStates.set(delta.netId, fullState)
|
||||
}
|
||||
|
||||
return {
|
||||
frame: data.frame,
|
||||
timestamp: data.timestamp,
|
||||
ackSeq: data.ackSeq,
|
||||
entities,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除实体状态
|
||||
* @en Remove entity state
|
||||
*/
|
||||
removeEntity(netId: number): void {
|
||||
this._lastStates.delete(netId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除所有状态
|
||||
* @en Clear all states
|
||||
*/
|
||||
clear(): void {
|
||||
this._lastStates.clear()
|
||||
this._frameCounter = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 强制下一次发送完整快照
|
||||
* @en Force next send to be a full snapshot
|
||||
*/
|
||||
forceFullSnapshot(): void {
|
||||
this._frameCounter = this._config.fullSnapshotInterval - 1
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 私有方法 | Private Methods
|
||||
// =========================================================================
|
||||
|
||||
private _createFullDelta(entity: EntitySyncState): EntityDeltaState {
|
||||
let flags = 0
|
||||
|
||||
if (entity.pos) flags |= DeltaFlags.POSITION
|
||||
if (entity.rot !== undefined) flags |= DeltaFlags.ROTATION
|
||||
if (entity.vel) flags |= DeltaFlags.VELOCITY
|
||||
if (entity.angVel !== undefined) flags |= DeltaFlags.ANGULAR_VELOCITY
|
||||
if (entity.custom) flags |= DeltaFlags.CUSTOM
|
||||
|
||||
return {
|
||||
netId: entity.netId,
|
||||
flags,
|
||||
pos: entity.pos,
|
||||
rot: entity.rot,
|
||||
vel: entity.vel,
|
||||
angVel: entity.angVel,
|
||||
custom: entity.custom,
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateDelta(
|
||||
lastState: EntitySyncState,
|
||||
currentState: EntitySyncState
|
||||
): EntityDeltaState | null {
|
||||
let flags = 0
|
||||
const delta: EntityDeltaState = {
|
||||
netId: currentState.netId,
|
||||
flags: 0,
|
||||
}
|
||||
|
||||
// Check position change
|
||||
if (currentState.pos) {
|
||||
const posChanged = !lastState.pos ||
|
||||
Math.abs(currentState.pos.x - lastState.pos.x) > this._config.positionThreshold ||
|
||||
Math.abs(currentState.pos.y - lastState.pos.y) > this._config.positionThreshold
|
||||
|
||||
if (posChanged) {
|
||||
flags |= DeltaFlags.POSITION
|
||||
delta.pos = currentState.pos
|
||||
}
|
||||
}
|
||||
|
||||
// Check rotation change
|
||||
if (currentState.rot !== undefined) {
|
||||
const rotChanged = lastState.rot === undefined ||
|
||||
Math.abs(currentState.rot - lastState.rot) > this._config.rotationThreshold
|
||||
|
||||
if (rotChanged) {
|
||||
flags |= DeltaFlags.ROTATION
|
||||
delta.rot = currentState.rot
|
||||
}
|
||||
}
|
||||
|
||||
// Check velocity change
|
||||
if (currentState.vel) {
|
||||
const velChanged = !lastState.vel ||
|
||||
Math.abs(currentState.vel.x - lastState.vel.x) > this._config.velocityThreshold ||
|
||||
Math.abs(currentState.vel.y - lastState.vel.y) > this._config.velocityThreshold
|
||||
|
||||
if (velChanged) {
|
||||
flags |= DeltaFlags.VELOCITY
|
||||
delta.vel = currentState.vel
|
||||
}
|
||||
}
|
||||
|
||||
// Check angular velocity change
|
||||
if (currentState.angVel !== undefined) {
|
||||
const angVelChanged = lastState.angVel === undefined ||
|
||||
Math.abs(currentState.angVel - lastState.angVel) > this._config.velocityThreshold
|
||||
|
||||
if (angVelChanged) {
|
||||
flags |= DeltaFlags.ANGULAR_VELOCITY
|
||||
delta.angVel = currentState.angVel
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom data change (simple reference comparison)
|
||||
if (currentState.custom) {
|
||||
const customChanged = !this._customDataEqual(lastState.custom, currentState.custom)
|
||||
|
||||
if (customChanged) {
|
||||
flags |= DeltaFlags.CUSTOM
|
||||
delta.custom = currentState.custom
|
||||
}
|
||||
}
|
||||
|
||||
// Return null if no changes
|
||||
if (flags === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
delta.flags = flags
|
||||
return delta
|
||||
}
|
||||
|
||||
private _applyDelta(
|
||||
lastState: EntitySyncState | undefined,
|
||||
delta: EntityDeltaState
|
||||
): EntitySyncState {
|
||||
const state: EntitySyncState = {
|
||||
netId: delta.netId,
|
||||
}
|
||||
|
||||
// Apply position
|
||||
if (delta.flags & DeltaFlags.POSITION) {
|
||||
state.pos = delta.pos
|
||||
} else if (lastState?.pos) {
|
||||
state.pos = lastState.pos
|
||||
}
|
||||
|
||||
// Apply rotation
|
||||
if (delta.flags & DeltaFlags.ROTATION) {
|
||||
state.rot = delta.rot
|
||||
} else if (lastState?.rot !== undefined) {
|
||||
state.rot = lastState.rot
|
||||
}
|
||||
|
||||
// Apply velocity
|
||||
if (delta.flags & DeltaFlags.VELOCITY) {
|
||||
state.vel = delta.vel
|
||||
} else if (lastState?.vel) {
|
||||
state.vel = lastState.vel
|
||||
}
|
||||
|
||||
// Apply angular velocity
|
||||
if (delta.flags & DeltaFlags.ANGULAR_VELOCITY) {
|
||||
state.angVel = delta.angVel
|
||||
} else if (lastState?.angVel !== undefined) {
|
||||
state.angVel = lastState.angVel
|
||||
}
|
||||
|
||||
// Apply custom data
|
||||
if (delta.flags & DeltaFlags.CUSTOM) {
|
||||
state.custom = delta.custom
|
||||
} else if (lastState?.custom) {
|
||||
state.custom = lastState.custom
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
private _customDataEqual(
|
||||
a: Record<string, unknown> | undefined,
|
||||
b: Record<string, unknown> | undefined
|
||||
): boolean {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
|
||||
const keysA = Object.keys(a)
|
||||
const keysB = Object.keys(b)
|
||||
|
||||
if (keysA.length !== keysB.length) return false
|
||||
|
||||
for (const key of keysA) {
|
||||
if (a[key] !== b[key]) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工厂函数 | Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 创建状态增量压缩器
|
||||
* @en Create state delta compressor
|
||||
*/
|
||||
export function createStateDeltaCompressor(
|
||||
config?: Partial<DeltaCompressionConfig>
|
||||
): StateDeltaCompressor {
|
||||
return new StateDeltaCompressor(config)
|
||||
}
|
||||
@@ -46,3 +46,35 @@ export type {
|
||||
} from './ClientPrediction';
|
||||
|
||||
export { ClientPrediction, createClientPrediction } from './ClientPrediction';
|
||||
|
||||
// =============================================================================
|
||||
// 状态增量压缩 | State Delta Compression
|
||||
// =============================================================================
|
||||
|
||||
export type {
|
||||
EntityDeltaState,
|
||||
DeltaSyncData,
|
||||
DeltaCompressionConfig
|
||||
} from './StateDelta';
|
||||
|
||||
export {
|
||||
DeltaFlags,
|
||||
StateDeltaCompressor,
|
||||
createStateDeltaCompressor
|
||||
} from './StateDelta';
|
||||
|
||||
// =============================================================================
|
||||
// 组件同步 | Component Sync (@sync decorator based)
|
||||
// =============================================================================
|
||||
|
||||
export type {
|
||||
ComponentSyncEventType,
|
||||
ComponentSyncEvent,
|
||||
ComponentSyncEventListener,
|
||||
ComponentSyncConfig
|
||||
} from './ComponentSync';
|
||||
|
||||
export {
|
||||
ComponentSyncSystem,
|
||||
createComponentSyncSystem
|
||||
} from './ComponentSync';
|
||||
|
||||
500
packages/framework/network/src/systems/NetworkAOISystem.ts
Normal file
500
packages/framework/network/src/systems/NetworkAOISystem.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* @zh 网络 AOI 系统
|
||||
* @en Network AOI System
|
||||
*
|
||||
* @zh 集成 AOI 兴趣区域管理,过滤网络同步数据
|
||||
* @en Integrates AOI interest management to filter network sync data
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, type Entity } from '@esengine/ecs-framework'
|
||||
import { NetworkIdentity } from '../components/NetworkIdentity'
|
||||
import { NetworkTransform } from '../components/NetworkTransform'
|
||||
import type { EntitySyncState } from '../protocol'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh AOI 事件类型
|
||||
* @en AOI event type
|
||||
*/
|
||||
export type NetworkAOIEventType = 'enter' | 'exit'
|
||||
|
||||
/**
|
||||
* @zh AOI 事件
|
||||
* @en AOI event
|
||||
*/
|
||||
export interface NetworkAOIEvent {
|
||||
/**
|
||||
* @zh 事件类型
|
||||
* @en Event type
|
||||
*/
|
||||
type: NetworkAOIEventType
|
||||
|
||||
/**
|
||||
* @zh 观察者网络 ID(玩家)
|
||||
* @en Observer network ID (player)
|
||||
*/
|
||||
observerNetId: number
|
||||
|
||||
/**
|
||||
* @zh 目标网络 ID(进入/离开视野的实体)
|
||||
* @en Target network ID (entity entering/exiting view)
|
||||
*/
|
||||
targetNetId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh AOI 事件监听器
|
||||
* @en AOI event listener
|
||||
*/
|
||||
export type NetworkAOIEventListener = (event: NetworkAOIEvent) => void
|
||||
|
||||
/**
|
||||
* @zh 网络 AOI 配置
|
||||
* @en Network AOI configuration
|
||||
*/
|
||||
export interface NetworkAOIConfig {
|
||||
/**
|
||||
* @zh 网格单元格大小
|
||||
* @en Grid cell size
|
||||
*/
|
||||
cellSize: number
|
||||
|
||||
/**
|
||||
* @zh 默认视野范围
|
||||
* @en Default view range
|
||||
*/
|
||||
defaultViewRange: number
|
||||
|
||||
/**
|
||||
* @zh 是否启用 AOI 过滤
|
||||
* @en Whether to enable AOI filtering
|
||||
*/
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: NetworkAOIConfig = {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 观察者数据
|
||||
* @en Observer data
|
||||
*/
|
||||
interface ObserverData {
|
||||
netId: number
|
||||
position: { x: number; y: number }
|
||||
viewRange: number
|
||||
viewRangeSq: number
|
||||
cellKey: string
|
||||
visibleEntities: Set<number>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NetworkAOISystem | 网络 AOI 系统
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络 AOI 系统
|
||||
* @en Network AOI system
|
||||
*
|
||||
* @zh 管理网络实体的兴趣区域,过滤同步数据
|
||||
* @en Manages network entities' areas of interest and filters sync data
|
||||
*/
|
||||
export class NetworkAOISystem extends EntitySystem {
|
||||
private readonly _config: NetworkAOIConfig
|
||||
private readonly _observers: Map<number, ObserverData> = new Map()
|
||||
private readonly _cells: Map<string, Set<number>> = new Map()
|
||||
private readonly _listeners: Set<NetworkAOIEventListener> = new Set()
|
||||
private readonly _entityNetIdMap: Map<Entity, number> = new Map()
|
||||
private readonly _netIdEntityMap: Map<number, Entity> = new Map()
|
||||
|
||||
constructor(config?: Partial<NetworkAOIConfig>) {
|
||||
super(Matcher.all(NetworkIdentity, NetworkTransform))
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
get config(): Readonly<NetworkAOIConfig> {
|
||||
return this._config
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否启用
|
||||
* @en Is enabled
|
||||
*/
|
||||
get enabled(): boolean {
|
||||
return this._config.enabled
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
this._config.enabled = value
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 观察者数量
|
||||
* @en Observer count
|
||||
*/
|
||||
get observerCount(): number {
|
||||
return this._observers.size
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 观察者管理 | Observer Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 添加观察者(通常是玩家实体)
|
||||
* @en Add observer (usually player entity)
|
||||
*/
|
||||
addObserver(netId: number, x: number, y: number, viewRange?: number): void {
|
||||
if (this._observers.has(netId)) {
|
||||
this.updateObserverPosition(netId, x, y)
|
||||
return
|
||||
}
|
||||
|
||||
const range = viewRange ?? this._config.defaultViewRange
|
||||
const cellKey = this._getCellKey(x, y)
|
||||
const data: ObserverData = {
|
||||
netId,
|
||||
position: { x, y },
|
||||
viewRange: range,
|
||||
viewRangeSq: range * range,
|
||||
cellKey,
|
||||
visibleEntities: new Set(),
|
||||
}
|
||||
|
||||
this._observers.set(netId, data)
|
||||
this._addToCell(cellKey, netId)
|
||||
this._updateVisibility(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除观察者
|
||||
* @en Remove observer
|
||||
*/
|
||||
removeObserver(netId: number): boolean {
|
||||
const data = this._observers.get(netId)
|
||||
if (!data) return false
|
||||
|
||||
// Emit exit events for all visible entities
|
||||
for (const visibleNetId of data.visibleEntities) {
|
||||
this._emitEvent({
|
||||
type: 'exit',
|
||||
observerNetId: netId,
|
||||
targetNetId: visibleNetId,
|
||||
})
|
||||
}
|
||||
|
||||
this._removeFromCell(data.cellKey, netId)
|
||||
this._observers.delete(netId)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 更新观察者位置
|
||||
* @en Update observer position
|
||||
*/
|
||||
updateObserverPosition(netId: number, x: number, y: number): void {
|
||||
const data = this._observers.get(netId)
|
||||
if (!data) return
|
||||
|
||||
const newCellKey = this._getCellKey(x, y)
|
||||
if (newCellKey !== data.cellKey) {
|
||||
this._removeFromCell(data.cellKey, netId)
|
||||
data.cellKey = newCellKey
|
||||
this._addToCell(newCellKey, netId)
|
||||
}
|
||||
|
||||
data.position.x = x
|
||||
data.position.y = y
|
||||
this._updateVisibility(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 更新观察者视野范围
|
||||
* @en Update observer view range
|
||||
*/
|
||||
updateObserverViewRange(netId: number, viewRange: number): void {
|
||||
const data = this._observers.get(netId)
|
||||
if (!data) return
|
||||
|
||||
data.viewRange = viewRange
|
||||
data.viewRangeSq = viewRange * viewRange
|
||||
this._updateVisibility(data)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 实体管理 | Entity Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 注册网络实体
|
||||
* @en Register network entity
|
||||
*/
|
||||
registerEntity(entity: Entity, netId: number): void {
|
||||
this._entityNetIdMap.set(entity, netId)
|
||||
this._netIdEntityMap.set(netId, entity)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注销网络实体
|
||||
* @en Unregister network entity
|
||||
*/
|
||||
unregisterEntity(entity: Entity): void {
|
||||
const netId = this._entityNetIdMap.get(entity)
|
||||
if (netId !== undefined) {
|
||||
// Remove from all observers' visible sets
|
||||
for (const [, data] of this._observers) {
|
||||
if (data.visibleEntities.has(netId)) {
|
||||
data.visibleEntities.delete(netId)
|
||||
this._emitEvent({
|
||||
type: 'exit',
|
||||
observerNetId: data.netId,
|
||||
targetNetId: netId,
|
||||
})
|
||||
}
|
||||
}
|
||||
this._netIdEntityMap.delete(netId)
|
||||
}
|
||||
this._entityNetIdMap.delete(entity)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 查询接口 | Query Interface
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 获取观察者能看到的实体网络 ID 列表
|
||||
* @en Get list of entity network IDs visible to observer
|
||||
*/
|
||||
getVisibleEntities(observerNetId: number): number[] {
|
||||
const data = this._observers.get(observerNetId)
|
||||
return data ? Array.from(data.visibleEntities) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取能看到指定实体的观察者网络 ID 列表
|
||||
* @en Get list of observer network IDs that can see the entity
|
||||
*/
|
||||
getObserversOf(entityNetId: number): number[] {
|
||||
const observers: number[] = []
|
||||
for (const [, data] of this._observers) {
|
||||
if (data.visibleEntities.has(entityNetId)) {
|
||||
observers.push(data.netId)
|
||||
}
|
||||
}
|
||||
return observers
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查观察者是否能看到目标
|
||||
* @en Check if observer can see target
|
||||
*/
|
||||
canSee(observerNetId: number, targetNetId: number): boolean {
|
||||
const data = this._observers.get(observerNetId)
|
||||
return data?.visibleEntities.has(targetNetId) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 过滤同步数据,只保留观察者能看到的实体
|
||||
* @en Filter sync data to only include entities visible to observer
|
||||
*/
|
||||
filterSyncData(observerNetId: number, entities: EntitySyncState[]): EntitySyncState[] {
|
||||
if (!this._config.enabled) {
|
||||
return entities
|
||||
}
|
||||
|
||||
const data = this._observers.get(observerNetId)
|
||||
if (!data) {
|
||||
return entities
|
||||
}
|
||||
|
||||
return entities.filter(entity => {
|
||||
// Always include the observer's own entity
|
||||
if (entity.netId === observerNetId) return true
|
||||
// Include entities in view
|
||||
return data.visibleEntities.has(entity.netId)
|
||||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 事件系统 | Event System
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 添加事件监听器
|
||||
* @en Add event listener
|
||||
*/
|
||||
addListener(listener: NetworkAOIEventListener): void {
|
||||
this._listeners.add(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除事件监听器
|
||||
* @en Remove event listener
|
||||
*/
|
||||
removeListener(listener: NetworkAOIEventListener): void {
|
||||
this._listeners.delete(listener)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 系统生命周期 | System Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
if (!this._config.enabled) return
|
||||
|
||||
// Update entity positions for AOI calculations
|
||||
for (const entity of entities) {
|
||||
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||
const transform = this.requireComponent(entity, NetworkTransform)
|
||||
|
||||
// Register entity if not already registered
|
||||
if (!this._entityNetIdMap.has(entity)) {
|
||||
this.registerEntity(entity, identity.netId)
|
||||
}
|
||||
|
||||
// If this entity is an observer (has authority), update its position
|
||||
if (identity.bHasAuthority && this._observers.has(identity.netId)) {
|
||||
this.updateObserverPosition(
|
||||
identity.netId,
|
||||
transform.currentX,
|
||||
transform.currentY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update all observers' visibility based on entity positions
|
||||
this._updateAllObserversVisibility(entities)
|
||||
}
|
||||
|
||||
private _updateAllObserversVisibility(entities: readonly Entity[]): void {
|
||||
for (const [, data] of this._observers) {
|
||||
const newVisible = new Set<number>()
|
||||
|
||||
// Check all entities
|
||||
for (const entity of entities) {
|
||||
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||
const transform = this.requireComponent(entity, NetworkTransform)
|
||||
|
||||
// Skip self
|
||||
if (identity.netId === data.netId) continue
|
||||
|
||||
// Check distance
|
||||
const dx = transform.currentX - data.position.x
|
||||
const dy = transform.currentY - data.position.y
|
||||
const distSq = dx * dx + dy * dy
|
||||
|
||||
if (distSq <= data.viewRangeSq) {
|
||||
newVisible.add(identity.netId)
|
||||
}
|
||||
}
|
||||
|
||||
// Find entities that entered view
|
||||
for (const netId of newVisible) {
|
||||
if (!data.visibleEntities.has(netId)) {
|
||||
this._emitEvent({
|
||||
type: 'enter',
|
||||
observerNetId: data.netId,
|
||||
targetNetId: netId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Find entities that exited view
|
||||
for (const netId of data.visibleEntities) {
|
||||
if (!newVisible.has(netId)) {
|
||||
this._emitEvent({
|
||||
type: 'exit',
|
||||
observerNetId: data.netId,
|
||||
targetNetId: netId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data.visibleEntities = newVisible
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除所有数据
|
||||
* @en Clear all data
|
||||
*/
|
||||
clear(): void {
|
||||
this._observers.clear()
|
||||
this._cells.clear()
|
||||
this._entityNetIdMap.clear()
|
||||
this._netIdEntityMap.clear()
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
this.clear()
|
||||
this._listeners.clear()
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 私有方法 | Private Methods
|
||||
// =========================================================================
|
||||
|
||||
private _getCellKey(x: number, y: number): string {
|
||||
const cellX = Math.floor(x / this._config.cellSize)
|
||||
const cellY = Math.floor(y / this._config.cellSize)
|
||||
return `${cellX},${cellY}`
|
||||
}
|
||||
|
||||
private _addToCell(cellKey: string, netId: number): void {
|
||||
let cell = this._cells.get(cellKey)
|
||||
if (!cell) {
|
||||
cell = new Set()
|
||||
this._cells.set(cellKey, cell)
|
||||
}
|
||||
cell.add(netId)
|
||||
}
|
||||
|
||||
private _removeFromCell(cellKey: string, netId: number): void {
|
||||
const cell = this._cells.get(cellKey)
|
||||
if (cell) {
|
||||
cell.delete(netId)
|
||||
if (cell.size === 0) {
|
||||
this._cells.delete(cellKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _updateVisibility(data: ObserverData): void {
|
||||
// This is called when an observer moves
|
||||
// The full visibility update happens in process() with all entities
|
||||
}
|
||||
|
||||
private _emitEvent(event: NetworkAOIEvent): void {
|
||||
for (const listener of this._listeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (e) {
|
||||
console.error('[NetworkAOISystem] Listener error:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工厂函数 | Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 创建网络 AOI 系统
|
||||
* @en Create network AOI system
|
||||
*/
|
||||
export function createNetworkAOISystem(
|
||||
config?: Partial<NetworkAOIConfig>
|
||||
): NetworkAOISystem {
|
||||
return new NetworkAOISystem(config)
|
||||
}
|
||||
@@ -1,11 +1,63 @@
|
||||
/**
|
||||
* @zh 网络输入系统
|
||||
* @en Network Input System
|
||||
*
|
||||
* @zh 收集本地玩家输入并发送到服务器,支持与预测系统集成
|
||||
* @en Collects local player input and sends to server, supports integration with prediction system
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher } from '@esengine/ecs-framework'
|
||||
import type { PlayerInput } from '../protocol'
|
||||
import type { NetworkService } from '../services/NetworkService'
|
||||
import type { NetworkPredictionSystem } from './NetworkPredictionSystem'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 输入配置
|
||||
* @en Input configuration
|
||||
*/
|
||||
export interface NetworkInputConfig {
|
||||
/**
|
||||
* @zh 发送输入的最小间隔(毫秒)
|
||||
* @en Minimum interval between input sends (milliseconds)
|
||||
*/
|
||||
sendInterval: number
|
||||
|
||||
/**
|
||||
* @zh 是否合并相同输入
|
||||
* @en Whether to merge identical inputs
|
||||
*/
|
||||
mergeIdenticalInputs: boolean
|
||||
|
||||
/**
|
||||
* @zh 最大输入队列长度
|
||||
* @en Maximum input queue length
|
||||
*/
|
||||
maxQueueLength: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: NetworkInputConfig = {
|
||||
sendInterval: 16, // ~60fps
|
||||
mergeIdenticalInputs: true,
|
||||
maxQueueLength: 10,
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 待发送输入
|
||||
* @en Pending input
|
||||
*/
|
||||
interface PendingInput {
|
||||
moveDir?: { x: number; y: number }
|
||||
actions?: string[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NetworkInputSystem | 网络输入系统
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络输入系统
|
||||
@@ -15,13 +67,52 @@ import type { NetworkService } from '../services/NetworkService'
|
||||
* @en Collects local player input and sends to server
|
||||
*/
|
||||
export class NetworkInputSystem extends EntitySystem {
|
||||
private _networkService: NetworkService
|
||||
private _frame: number = 0
|
||||
private _inputQueue: PlayerInput[] = []
|
||||
private readonly _networkService: NetworkService
|
||||
private readonly _config: NetworkInputConfig
|
||||
private _predictionSystem: NetworkPredictionSystem | null = null
|
||||
|
||||
constructor(networkService: NetworkService) {
|
||||
private _frame: number = 0
|
||||
private _inputSequence: number = 0
|
||||
private _inputQueue: PendingInput[] = []
|
||||
private _lastSendTime: number = 0
|
||||
private _lastMoveDir: { x: number; y: number } = { x: 0, y: 0 }
|
||||
|
||||
constructor(networkService: NetworkService, config?: Partial<NetworkInputConfig>) {
|
||||
super(Matcher.nothing())
|
||||
this._networkService = networkService
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
get config(): Readonly<NetworkInputConfig> {
|
||||
return this._config
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前帧号
|
||||
* @en Get current frame number
|
||||
*/
|
||||
get frame(): number {
|
||||
return this._frame
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前输入序列号
|
||||
* @en Get current input sequence
|
||||
*/
|
||||
get inputSequence(): number {
|
||||
return this._inputSequence
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置预测系统引用
|
||||
* @en Set prediction system reference
|
||||
*/
|
||||
setPredictionSystem(system: NetworkPredictionSystem): void {
|
||||
this._predictionSystem = system
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,11 +123,64 @@ export class NetworkInputSystem extends EntitySystem {
|
||||
if (!this._networkService.isConnected) return
|
||||
|
||||
this._frame++
|
||||
const now = Date.now()
|
||||
|
||||
while (this._inputQueue.length > 0) {
|
||||
const input = this._inputQueue.shift()!
|
||||
input.frame = this._frame
|
||||
this._networkService.sendInput(input)
|
||||
// Rate limiting
|
||||
if (now - this._lastSendTime < this._config.sendInterval) return
|
||||
|
||||
// If using prediction system, get input from there
|
||||
if (this._predictionSystem) {
|
||||
const predictedInput = this._predictionSystem.getInputToSend()
|
||||
if (predictedInput) {
|
||||
this._networkService.sendInput(predictedInput)
|
||||
this._lastSendTime = now
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise process queue
|
||||
if (this._inputQueue.length === 0) return
|
||||
|
||||
// Merge inputs if configured
|
||||
let mergedInput: PendingInput
|
||||
if (this._config.mergeIdenticalInputs && this._inputQueue.length > 1) {
|
||||
mergedInput = this._mergeInputs(this._inputQueue)
|
||||
this._inputQueue.length = 0
|
||||
} else {
|
||||
mergedInput = this._inputQueue.shift()!
|
||||
}
|
||||
|
||||
// Build and send input
|
||||
this._inputSequence++
|
||||
const input: PlayerInput = {
|
||||
seq: this._inputSequence,
|
||||
frame: this._frame,
|
||||
timestamp: mergedInput.timestamp,
|
||||
moveDir: mergedInput.moveDir,
|
||||
actions: mergedInput.actions,
|
||||
}
|
||||
|
||||
this._networkService.sendInput(input)
|
||||
this._lastSendTime = now
|
||||
}
|
||||
|
||||
private _mergeInputs(inputs: PendingInput[]): PendingInput {
|
||||
const allActions: string[] = []
|
||||
let lastMoveDir: { x: number; y: number } | undefined
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.moveDir) {
|
||||
lastMoveDir = input.moveDir
|
||||
}
|
||||
if (input.actions) {
|
||||
allActions.push(...input.actions)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
moveDir: lastMoveDir,
|
||||
actions: allActions.length > 0 ? allActions : undefined,
|
||||
timestamp: inputs[inputs.length - 1].timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +189,24 @@ export class NetworkInputSystem extends EntitySystem {
|
||||
* @en Add move input
|
||||
*/
|
||||
public addMoveInput(x: number, y: number): void {
|
||||
this._inputQueue.push({
|
||||
frame: 0,
|
||||
moveDir: { x, y },
|
||||
})
|
||||
// Skip if same as last input
|
||||
if (
|
||||
this._config.mergeIdenticalInputs &&
|
||||
this._lastMoveDir.x === x &&
|
||||
this._lastMoveDir.y === y &&
|
||||
this._inputQueue.length > 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this._lastMoveDir = { x, y }
|
||||
|
||||
// Also set input on prediction system
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.setInput(x, y)
|
||||
}
|
||||
|
||||
this._addToQueue({ moveDir: { x, y }, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,19 +214,70 @@ export class NetworkInputSystem extends EntitySystem {
|
||||
* @en Add action input
|
||||
*/
|
||||
public addActionInput(action: string): void {
|
||||
// Try to add to last input in queue
|
||||
const lastInput = this._inputQueue[this._inputQueue.length - 1]
|
||||
if (lastInput) {
|
||||
lastInput.actions = lastInput.actions || []
|
||||
lastInput.actions.push(action)
|
||||
} else {
|
||||
this._inputQueue.push({
|
||||
frame: 0,
|
||||
actions: [action],
|
||||
})
|
||||
this._addToQueue({ actions: [action], timestamp: Date.now() })
|
||||
}
|
||||
|
||||
// Also set on prediction system
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.setInput(
|
||||
this._lastMoveDir.x,
|
||||
this._lastMoveDir.y,
|
||||
[action]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private _addToQueue(input: PendingInput): void {
|
||||
this._inputQueue.push(input)
|
||||
|
||||
// Limit queue size
|
||||
while (this._inputQueue.length > this._config.maxQueueLength) {
|
||||
this._inputQueue.shift()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清空输入队列
|
||||
* @en Clear input queue
|
||||
*/
|
||||
public clearQueue(): void {
|
||||
this._inputQueue.length = 0
|
||||
this._lastMoveDir = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置状态
|
||||
* @en Reset state
|
||||
*/
|
||||
public reset(): void {
|
||||
this._frame = 0
|
||||
this._inputSequence = 0
|
||||
this.clearQueue()
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
this._inputQueue.length = 0
|
||||
this._predictionSystem = null
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工厂函数 | Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 创建网络输入系统
|
||||
* @en Create network input system
|
||||
*/
|
||||
export function createNetworkInputSystem(
|
||||
networkService: NetworkService,
|
||||
config?: Partial<NetworkInputConfig>
|
||||
): NetworkInputSystem {
|
||||
return new NetworkInputSystem(networkService, config)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user