Compare commits

..

3 Commits

Author SHA1 Message Date
github-actions[bot]
2d46ccf896 chore: release packages (#380)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 10:44:48 +08:00
YHH
fb8bde6485 feat(network): 网络模块增强 - 预测、AOI、增量压缩 (#379)
- 添加 NetworkPredictionSystem 客户端预测系统
- 添加 NetworkAOISystem 兴趣区域管理
- 添加 StateDeltaCompressor 状态增量压缩
- 添加断线重连和状态恢复
- 增强协议支持时间戳、序列号、速度
- 添加中英文文档
2025-12-29 10:42:48 +08:00
yhh
30437dc5d5 docs: add world-streaming to sidebar navigation 2025-12-28 19:50:44 +08:00
21 changed files with 4132 additions and 52 deletions

View File

@@ -268,6 +268,17 @@ export default defineConfig({
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
],
},
{
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' } },
],
},
],
},
{

View File

@@ -0,0 +1,283 @@
---
title: "Area of Interest (AOI)"
description: "View range based network entity filtering"
---
AOI (Area of Interest) is a key technique in large-scale multiplayer games for optimizing network bandwidth. By only synchronizing entities within a player's view range, network traffic can be significantly reduced.
## NetworkAOISystem
`NetworkAOISystem` provides grid-based area of interest management.
### Enable AOI
```typescript
import { NetworkPlugin } from '@esengine/network';
const networkPlugin = new NetworkPlugin({
enableAOI: true,
aoiConfig: {
cellSize: 100, // Grid cell size
defaultViewRange: 500, // Default view range
enabled: true,
}
});
await Core.installPlugin(networkPlugin);
```
### Adding Observers
Each player that needs to receive sync data must be added as an observer:
```typescript
// Add observer when player joins
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
// ... setup components
// Add player as AOI observer
networkPlugin.addAOIObserver(
spawn.netId, // Network ID
spawn.pos.x, // Initial X position
spawn.pos.y, // Initial Y position
600 // View range (optional)
);
return entity;
});
// Remove observer when player leaves
networkPlugin.removeAOIObserver(playerNetId);
```
### Updating Observer Position
When a player moves, update their AOI position:
```typescript
// Update in game loop or sync callback
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
```
## AOI Configuration
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `cellSize` | `number` | 100 | Grid cell size |
| `defaultViewRange` | `number` | 500 | Default view range |
| `enabled` | `boolean` | true | Whether AOI is enabled |
### Grid Size Recommendations
Grid size should be set based on game view range:
```typescript
// Recommendation: cellSize = defaultViewRange / 3 to / 5
aoiConfig: {
cellSize: 100,
defaultViewRange: 500, // Grid is about 1/5 of view range
}
```
## Query Interface
### Get Visible Entities
```typescript
// Get all entities visible to player
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
console.log('Visible entities:', visibleEntities);
```
### Check Visibility
```typescript
// Check if player can see an entity
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
// Target is in view
}
```
## Event Listening
The AOI system triggers events when entities enter/exit view:
```typescript
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
aoiSystem.addListener((event) => {
if (event.type === 'enter') {
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
// Can send entity's initial state here
} else if (event.type === 'exit') {
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
// Can cleanup resources here
}
});
}
```
## Server-Side Filtering
AOI is most commonly used server-side to filter sync data for each client:
```typescript
// Server-side example
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
class GameServer {
private aoiSystem = createNetworkAOISystem({
cellSize: 100,
defaultViewRange: 500,
});
// Player joins
onPlayerJoin(playerId: number, x: number, y: number) {
this.aoiSystem.addObserver(playerId, x, y);
}
// Player moves
onPlayerMove(playerId: number, x: number, y: number) {
this.aoiSystem.updateObserverPosition(playerId, x, y);
}
// Send sync data
broadcastSync(allEntities: EntitySyncState[]) {
for (const playerId of this.players) {
// Filter using AOI
const filteredEntities = this.aoiSystem.filterSyncData(
playerId,
allEntities
);
// Send only visible entities
this.sendToPlayer(playerId, { entities: filteredEntities });
}
}
}
```
## How It Works
```
┌─────────────────────────────────────────────────────────────┐
│ Game World │
│ ┌─────┬─────┬─────┬─────┬─────┐ │
│ │ │ │ E │ │ │ │
│ ├─────┼─────┼─────┼─────┼─────┤ E = Enemy entity │
│ │ │ P │ ● │ │ │ P = Player │
│ ├─────┼─────┼─────┼─────┼─────┤ ● = Player view center │
│ │ │ │ E │ E │ │ ○ = View range │
│ ├─────┼─────┼─────┼─────┼─────┤ │
│ │ │ │ │ │ E │ Player only sees E in view│
│ └─────┴─────┴─────┴─────┴─────┘ │
│ │
│ View range (circle): Contains 3 enemies │
│ Grid optimization: Only check cells covered by view │
└─────────────────────────────────────────────────────────────┘
```
### Grid Optimization
AOI uses spatial grid to accelerate queries:
1. **Add Entity**: Calculate grid cell based on position
2. **View Detection**: Only check cells covered by view range
3. **Move Update**: Update cell assignment when crossing cells
4. **Event Trigger**: Detect enter/exit view
## Dynamic View Range
Different player types can have different view ranges:
```typescript
// Regular player
networkPlugin.addAOIObserver(playerId, x, y, 500);
// VIP player (larger view)
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
// Adjust view range at runtime
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
aoiSystem.updateObserverViewRange(playerId, 600);
}
```
## Best Practices
### 1. Server-Side Usage
AOI filtering should be done server-side; clients should not trust their own AOI judgment:
```typescript
// Filter on server before sending
const filtered = aoiSystem.filterSyncData(playerId, entities);
sendToClient(playerId, filtered);
```
### 2. Edge Handling
Add buffer zone at view edge to prevent flickering:
```typescript
// Add immediately when entering view
// Remove with delay when exiting (keep for 1-2 extra seconds)
aoiSystem.addListener((event) => {
if (event.type === 'exit') {
setTimeout(() => {
// Re-check if really exited
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
removeFromClient(event.observerNetId, event.targetNetId);
}
}, 1000);
}
});
```
### 3. Large Entities
Large entities (like bosses) may need special handling:
```typescript
// Boss is always visible to everyone
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
const filtered = aoiSystem.filterSyncData(playerId, entities);
// Add boss entity
const bossState = entities.find(e => e.netId === bossNetId);
if (bossState && !filtered.includes(bossState)) {
filtered.push(bossState);
}
return filtered;
}
```
### 4. Performance Considerations
```typescript
// Large-scale game recommended config
aoiConfig: {
cellSize: 200, // Larger grid reduces cell count
defaultViewRange: 800, // Set based on actual view
}
```
## Debugging
```typescript
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
console.log('AOI enabled:', aoiSystem.enabled);
console.log('Observer count:', aoiSystem.observerCount);
// Get visible entities for specific player
const visible = aoiSystem.getVisibleEntities(playerId);
console.log('Visible entities:', visible.length);
}
```

View File

@@ -0,0 +1,316 @@
---
title: "State Delta Compression"
description: "Reduce network bandwidth with incremental sync"
---
State delta compression reduces network bandwidth by only sending fields that have changed. For frequently synchronized game state, this can significantly reduce data transmission.
## StateDeltaCompressor
The `StateDeltaCompressor` class is used to compress and decompress state deltas.
### Basic Usage
```typescript
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
// Create compressor
const compressor = createStateDeltaCompressor({
positionThreshold: 0.01, // Position change threshold
rotationThreshold: 0.001, // Rotation change threshold (radians)
velocityThreshold: 0.1, // Velocity change threshold
fullSnapshotInterval: 60, // Full snapshot interval (frames)
});
// Compress sync data
const syncData: SyncData = {
frame: 100,
timestamp: Date.now(),
entities: [
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
],
};
const deltaData = compressor.compress(syncData);
// deltaData only contains changed fields
// Decompress delta data
const fullData = compressor.decompress(deltaData);
```
## Configuration Options
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `positionThreshold` | `number` | 0.01 | Position change threshold |
| `rotationThreshold` | `number` | 0.001 | Rotation change threshold (radians) |
| `velocityThreshold` | `number` | 0.1 | Velocity change threshold |
| `fullSnapshotInterval` | `number` | 60 | Full snapshot interval (frames) |
## Delta Flags
Bit flags indicate which fields have changed:
```typescript
import { DeltaFlags } from '@esengine/network';
// Flag definitions
DeltaFlags.NONE // 0 - No change
DeltaFlags.POSITION // 1 - Position changed
DeltaFlags.ROTATION // 2 - Rotation changed
DeltaFlags.VELOCITY // 4 - Velocity changed
DeltaFlags.ANGULAR_VELOCITY // 8 - Angular velocity changed
DeltaFlags.CUSTOM // 16 - Custom data changed
```
## Data Format
### Full State
```typescript
interface EntitySyncState {
netId: number;
pos?: { x: number; y: number };
rot?: number;
vel?: { x: number; y: number };
angVel?: number;
custom?: Record<string, unknown>;
}
```
### Delta State
```typescript
interface EntityDeltaState {
netId: number;
flags: number; // Change flags
pos?: { x: number; y: number }; // Only present when POSITION flag set
rot?: number; // Only present when ROTATION flag set
vel?: { x: number; y: number }; // Only present when VELOCITY flag set
angVel?: number; // Only present when ANGULAR_VELOCITY flag set
custom?: Record<string, unknown>; // Only present when CUSTOM flag set
}
```
## How It Works
```
Frame 1 (full snapshot):
Entity 1: pos=(100, 200), rot=0
Frame 2 (delta):
Entity 1: flags=POSITION, pos=(101, 200) // Only X changed
Frame 3 (delta):
Entity 1: flags=0 // No change, not sent
Frame 4 (delta):
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
Frame 60 (forced full snapshot):
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
```
## Server-Side Usage
```typescript
import { createStateDeltaCompressor } from '@esengine/network';
class GameServer {
private compressor = createStateDeltaCompressor();
// Broadcast state updates
broadcastState(entities: EntitySyncState[]) {
const syncData: SyncData = {
frame: this.currentFrame,
timestamp: Date.now(),
entities,
};
// Compress data
const deltaData = this.compressor.compress(syncData);
// Send delta data
this.broadcast('sync', deltaData);
}
// Cleanup when player leaves
onPlayerLeave(netId: number) {
this.compressor.removeEntity(netId);
}
}
```
## Client-Side Usage
```typescript
class GameClient {
private compressor = createStateDeltaCompressor();
// Receive delta data
onSyncReceived(deltaData: DeltaSyncData) {
// Decompress to full state
const fullData = this.compressor.decompress(deltaData);
// Apply state
for (const entity of fullData.entities) {
this.applyEntityState(entity);
}
}
}
```
## Bandwidth Savings Example
Assume each entity has the following data:
| Field | Size (bytes) |
|-------|-------------|
| netId | 4 |
| pos.x | 8 |
| pos.y | 8 |
| rot | 8 |
| vel.x | 8 |
| vel.y | 8 |
| angVel | 8 |
| **Total** | **52** |
With delta compression:
| Scenario | Original | Compressed | Savings |
|----------|----------|------------|---------|
| Only position changed | 52 | 4+1+16 = 21 | 60% |
| Only rotation changed | 52 | 4+1+8 = 13 | 75% |
| Stationary | 52 | 0 | 100% |
| Position + rotation changed | 52 | 4+1+24 = 29 | 44% |
## Forcing Full Snapshot
Some situations require sending full snapshots:
```typescript
// When new player joins
compressor.forceFullSnapshot();
const data = compressor.compress(syncData);
// This will send full state
// On reconnection
compressor.clear(); // Clear history
compressor.forceFullSnapshot();
```
## Custom Data
Support for syncing custom game data:
```typescript
const syncData: SyncData = {
frame: 100,
timestamp: Date.now(),
entities: [
{
netId: 1,
pos: { x: 100, y: 200 },
custom: {
health: 80,
mana: 50,
buffs: ['speed', 'shield'],
},
},
],
};
// Custom data is also delta compressed
const deltaData = compressor.compress(syncData);
```
## Best Practices
### 1. Set Appropriate Thresholds
```typescript
// High precision games (e.g., competitive)
const compressor = createStateDeltaCompressor({
positionThreshold: 0.001,
rotationThreshold: 0.0001,
});
// Casual games
const compressor = createStateDeltaCompressor({
positionThreshold: 0.1,
rotationThreshold: 0.01,
});
```
### 2. Adjust Full Snapshot Interval
```typescript
// High reliability (unstable network)
fullSnapshotInterval: 30, // Full snapshot every 30 frames
// Low bandwidth priority
fullSnapshotInterval: 120, // Full snapshot every 120 frames
```
### 3. Combine with AOI
```typescript
// Filter with AOI first, then delta compress
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
const syncData = { frame, timestamp, entities: filteredEntities };
const deltaData = compressor.compress(syncData);
```
### 4. Handle Entity Removal
```typescript
// Clean up compressor state when entity despawns
function onEntityDespawn(netId: number) {
compressor.removeEntity(netId);
}
```
## Integration with Other Features
```
┌─────────────────┐
│ Game State │
└────────┬────────┘
┌────────▼────────┐
│ AOI Filter │ ← Only process entities in view
└────────┬────────┘
┌────────▼────────┐
│ Delta Compress │ ← Only send changed fields
└────────┬────────┘
┌────────▼────────┐
│ Network Send │
└─────────────────┘
```
## Debugging
```typescript
const compressor = createStateDeltaCompressor();
// Check compression efficiency
const original = syncData;
const compressed = compressor.compress(original);
console.log('Original entities:', original.entities.length);
console.log('Compressed entities:', compressed.entities.length);
console.log('Is full snapshot:', compressed.isFullSnapshot);
// View each entity's changes
for (const delta of compressed.entities) {
console.log(`Entity ${delta.netId}:`, {
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
});
}
```

View File

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

View File

@@ -0,0 +1,254 @@
---
title: "Client Prediction"
description: "Local input prediction and server reconciliation"
---
Client prediction is a key technique in networked games to reduce input latency. By immediately applying player inputs locally while waiting for server confirmation, games feel more responsive.
## NetworkPredictionSystem
`NetworkPredictionSystem` is an ECS system dedicated to handling local player prediction.
### Basic Usage
```typescript
import { NetworkPlugin } from '@esengine/network';
const networkPlugin = new NetworkPlugin({
enablePrediction: true,
predictionConfig: {
moveSpeed: 200, // Movement speed (units/second)
maxUnacknowledgedInputs: 60, // Max unacknowledged inputs
reconciliationThreshold: 0.5, // Reconciliation threshold
reconciliationSpeed: 10, // Reconciliation speed
}
});
await Core.installPlugin(networkPlugin);
```
### Setting Up Local Player
After the local player entity spawns, set its network ID:
```typescript
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
const identity = entity.addComponent(new NetworkIdentity());
identity.netId = spawn.netId;
identity.ownerId = spawn.ownerId;
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
identity.bIsLocalPlayer = identity.bHasAuthority;
entity.addComponent(new NetworkTransform());
// Set local player for prediction
if (identity.bIsLocalPlayer) {
networkPlugin.setLocalPlayerNetId(spawn.netId);
}
return entity;
});
```
### Sending Input
```typescript
// Send movement input in game loop
function onUpdate() {
const moveX = Input.getAxis('horizontal');
const moveY = Input.getAxis('vertical');
if (moveX !== 0 || moveY !== 0) {
networkPlugin.sendMoveInput(moveX, moveY);
}
// Send action input
if (Input.isPressed('attack')) {
networkPlugin.sendActionInput('attack');
}
}
```
## Prediction Configuration
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `moveSpeed` | `number` | 200 | Movement speed (units/second) |
| `enabled` | `boolean` | true | Whether prediction is enabled |
| `maxUnacknowledgedInputs` | `number` | 60 | Max unacknowledged inputs |
| `reconciliationThreshold` | `number` | 0.5 | Position difference threshold for reconciliation |
| `reconciliationSpeed` | `number` | 10 | Reconciliation smoothing speed |
## How It Works
```
Client Server
│ │
├─ 1. Capture input (seq=1) │
├─ 2. Predict movement locally │
├─ 3. Send input to server ─────────►
│ │
├─ 4. Continue capturing (seq=2,3...) │
├─ 5. Continue predicting │
│ │
│ ├─ 6. Process input (seq=1)
│ │
◄──────── 7. Return state (ackSeq=1) ─
│ │
├─ 8. Compare prediction with server │
├─ 9. Replay inputs seq=2,3... │
├─ 10. Smooth correction │
│ │
```
### Step by Step
1. **Input Capture**: Capture player input and assign sequence number
2. **Local Prediction**: Immediately apply input to local state
3. **Send Input**: Send input to server
4. **Cache Input**: Save input for later reconciliation
5. **Receive Acknowledgment**: Server returns authoritative state with ack sequence
6. **State Comparison**: Compare predicted state with server state
7. **Input Replay**: Recalculate state using cached unacknowledged inputs
8. **Smooth Correction**: Interpolate smoothly to correct position
## Low-Level API
For fine-grained control, use the `ClientPrediction` class directly:
```typescript
import { createClientPrediction, type IPredictor } from '@esengine/network';
// Define state type
interface PlayerState {
x: number;
y: number;
rotation: number;
}
// Define input type
interface PlayerInput {
dx: number;
dy: number;
}
// Define predictor
const predictor: IPredictor<PlayerState, PlayerInput> = {
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
return {
x: state.x + input.dx * MOVE_SPEED * dt,
y: state.y + input.dy * MOVE_SPEED * dt,
rotation: state.rotation,
};
}
};
// Create client prediction
const prediction = createClientPrediction(predictor, {
maxUnacknowledgedInputs: 60,
reconciliationThreshold: 0.5,
reconciliationSpeed: 10,
});
// Record input and get predicted state
const input = { dx: 1, dy: 0 };
const predictedState = prediction.recordInput(input, currentState, deltaTime);
// Get input to send
const inputToSend = prediction.getInputToSend();
// Reconcile with server state
prediction.reconcile(
serverState,
serverAckSeq,
(state) => ({ x: state.x, y: state.y }),
deltaTime
);
// Get correction offset
const offset = prediction.correctionOffset;
```
## Enable/Disable Prediction
```typescript
// Toggle prediction at runtime
networkPlugin.setPredictionEnabled(false);
// Check prediction status
if (networkPlugin.isPredictionEnabled) {
console.log('Prediction is active');
}
```
## Best Practices
### 1. Set Appropriate Reconciliation Threshold
```typescript
// Action games: lower threshold, more precise
predictionConfig: {
reconciliationThreshold: 0.1,
}
// Casual games: higher threshold, smoother
predictionConfig: {
reconciliationThreshold: 1.0,
}
```
### 2. Prediction Only for Local Player
Remote players should use interpolation, not prediction:
```typescript
const identity = entity.getComponent(NetworkIdentity);
if (identity.bIsLocalPlayer) {
// Use prediction system
} else {
// Use NetworkSyncSystem interpolation
}
```
### 3. Handle High Latency
```typescript
// High latency network: increase buffer
predictionConfig: {
maxUnacknowledgedInputs: 120, // Increase buffer
reconciliationSpeed: 5, // Slower correction
}
```
### 4. Deterministic Prediction
Ensure client and server use the same physics calculations:
```typescript
// Use fixed timestep
const FIXED_DT = 1 / 60;
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
// Use fixed timestep instead of actual deltaTime
return {
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
rotation: state.rotation,
};
}
```
## Debugging
```typescript
// Get prediction system instance
const predictionSystem = networkPlugin.predictionSystem;
if (predictionSystem) {
console.log('Pending inputs:', predictionSystem.pendingInputCount);
console.log('Current sequence:', predictionSystem.inputSequence);
}
```

View File

@@ -0,0 +1,283 @@
---
title: "兴趣区域管理 (AOI)"
description: "基于视野范围的网络实体过滤"
---
AOIArea of Interest兴趣区域是大规模多人游戏中用于优化网络带宽的关键技术。通过只同步玩家视野范围内的实体可以大幅减少网络流量。
## NetworkAOISystem
`NetworkAOISystem` 提供基于网格的兴趣区域管理。
### 启用 AOI
```typescript
import { NetworkPlugin } from '@esengine/network';
const networkPlugin = new NetworkPlugin({
enableAOI: true,
aoiConfig: {
cellSize: 100, // 网格单元大小
defaultViewRange: 500, // 默认视野范围
enabled: true,
}
});
await Core.installPlugin(networkPlugin);
```
### 添加观察者
每个需要接收同步数据的玩家都需要作为观察者添加:
```typescript
// 玩家加入时添加观察者
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
// ... 设置组件
// 将玩家添加为 AOI 观察者
networkPlugin.addAOIObserver(
spawn.netId, // 网络 ID
spawn.pos.x, // 初始 X 位置
spawn.pos.y, // 初始 Y 位置
600 // 视野范围(可选)
);
return entity;
});
// 玩家离开时移除观察者
networkPlugin.removeAOIObserver(playerNetId);
```
### 更新观察者位置
当玩家移动时,需要更新其 AOI 位置:
```typescript
// 在游戏循环或同步回调中更新
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
```
## AOI 配置
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `cellSize` | `number` | 100 | 网格单元大小 |
| `defaultViewRange` | `number` | 500 | 默认视野范围 |
| `enabled` | `boolean` | true | 是否启用 AOI |
### 网格大小建议
网格大小应根据游戏视野范围设置:
```typescript
// 建议cellSize = defaultViewRange / 3 到 / 5
aoiConfig: {
cellSize: 100,
defaultViewRange: 500, // 网格大约是视野的 1/5
}
```
## 查询接口
### 获取可见实体
```typescript
// 获取玩家能看到的所有实体
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
console.log('Visible entities:', visibleEntities);
```
### 检查可见性
```typescript
// 检查玩家是否能看到某个实体
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
// 目标在视野内
}
```
## 事件监听
AOI 系统会在实体进入/离开视野时触发事件:
```typescript
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
aoiSystem.addListener((event) => {
if (event.type === 'enter') {
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
// 可以在这里发送实体的初始状态
} else if (event.type === 'exit') {
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
// 可以在这里清理资源
}
});
}
```
## 服务器端过滤
AOI 最常用于服务器端,过滤发送给每个客户端的同步数据:
```typescript
// 服务器端示例
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
class GameServer {
private aoiSystem = createNetworkAOISystem({
cellSize: 100,
defaultViewRange: 500,
});
// 玩家加入
onPlayerJoin(playerId: number, x: number, y: number) {
this.aoiSystem.addObserver(playerId, x, y);
}
// 玩家移动
onPlayerMove(playerId: number, x: number, y: number) {
this.aoiSystem.updateObserverPosition(playerId, x, y);
}
// 发送同步数据
broadcastSync(allEntities: EntitySyncState[]) {
for (const playerId of this.players) {
// 使用 AOI 过滤
const filteredEntities = this.aoiSystem.filterSyncData(
playerId,
allEntities
);
// 只发送可见实体
this.sendToPlayer(playerId, { entities: filteredEntities });
}
}
}
```
## 工作原理
```
┌─────────────────────────────────────────────────────────────┐
│ 游戏世界 │
│ ┌─────┬─────┬─────┬─────┬─────┐ │
│ │ │ │ E │ │ │ │
│ ├─────┼─────┼─────┼─────┼─────┤ E = 敌人实体 │
│ │ │ P │ ● │ │ │ P = 玩家 │
│ ├─────┼─────┼─────┼─────┼─────┤ ● = 玩家视野中心 │
│ │ │ │ E │ E │ │ ○ = 视野范围 │
│ ├─────┼─────┼─────┼─────┼─────┤ │
│ │ │ │ │ │ E │ 玩家只能看到视野内的 E │
│ └─────┴─────┴─────┴─────┴─────┘ │
│ │
│ 视野范围(圆形):包含 3 个敌人 │
│ 网格优化:只检查视野覆盖的网格单元 │
└─────────────────────────────────────────────────────────────┘
```
### 网格优化
AOI 使用空间网格加速查询:
1. **添加实体**:根据位置计算所在网格
2. **视野检测**:只检查视野范围覆盖的网格
3. **移动更新**:跨网格时更新网格归属
4. **事件触发**:检测进入/离开视野
## 动态视野范围
可以为不同类型的玩家设置不同的视野:
```typescript
// 普通玩家
networkPlugin.addAOIObserver(playerId, x, y, 500);
// VIP 玩家(更大视野)
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
// 运行时调整视野
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
aoiSystem.updateObserverViewRange(playerId, 600);
}
```
## 最佳实践
### 1. 服务器端使用
AOI 过滤应在服务器端进行,客户端不应信任自己的 AOI 判断:
```typescript
// 服务器端过滤后再发送
const filtered = aoiSystem.filterSyncData(playerId, entities);
sendToClient(playerId, filtered);
```
### 2. 边界处理
在视野边缘添加缓冲区防止闪烁:
```typescript
// 进入视野时立即添加
// 离开视野时延迟移除(保持额外 1-2 秒)
aoiSystem.addListener((event) => {
if (event.type === 'exit') {
setTimeout(() => {
// 再次检查是否真的离开
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
removeFromClient(event.observerNetId, event.targetNetId);
}
}, 1000);
}
});
```
### 3. 大型实体
对于大型实体(如 Boss可能需要特殊处理
```typescript
// Boss 总是对所有人可见
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
const filtered = aoiSystem.filterSyncData(playerId, entities);
// 添加 Boss 实体
const bossState = entities.find(e => e.netId === bossNetId);
if (bossState && !filtered.includes(bossState)) {
filtered.push(bossState);
}
return filtered;
}
```
### 4. 性能考虑
```typescript
// 大规模游戏建议配置
aoiConfig: {
cellSize: 200, // 较大的网格减少网格数量
defaultViewRange: 800, // 根据实际视野设置
}
```
## 调试
```typescript
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
console.log('AOI enabled:', aoiSystem.enabled);
console.log('Observer count:', aoiSystem.observerCount);
// 获取特定玩家的可见实体
const visible = aoiSystem.getVisibleEntities(playerId);
console.log('Visible entities:', visible.length);
}
```

View File

@@ -0,0 +1,316 @@
---
title: "状态增量压缩"
description: "减少网络带宽的增量同步"
---
状态增量压缩通过只发送变化的字段来减少网络带宽。对于频繁同步的游戏状态,这可以显著降低数据传输量。
## StateDeltaCompressor
`StateDeltaCompressor` 类用于压缩和解压状态增量。
### 基本用法
```typescript
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
// 创建压缩器
const compressor = createStateDeltaCompressor({
positionThreshold: 0.01, // 位置变化阈值
rotationThreshold: 0.001, // 旋转变化阈值(弧度)
velocityThreshold: 0.1, // 速度变化阈值
fullSnapshotInterval: 60, // 完整快照间隔(帧数)
});
// 压缩同步数据
const syncData: SyncData = {
frame: 100,
timestamp: Date.now(),
entities: [
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
],
};
const deltaData = compressor.compress(syncData);
// deltaData 只包含变化的字段
// 解压增量数据
const fullData = compressor.decompress(deltaData);
```
## 配置选项
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `positionThreshold` | `number` | 0.01 | 位置变化阈值 |
| `rotationThreshold` | `number` | 0.001 | 旋转变化阈值(弧度) |
| `velocityThreshold` | `number` | 0.1 | 速度变化阈值 |
| `fullSnapshotInterval` | `number` | 60 | 完整快照间隔(帧数) |
## 增量标志
使用位标志表示哪些字段发生了变化:
```typescript
import { DeltaFlags } from '@esengine/network';
// 位标志定义
DeltaFlags.NONE // 0 - 无变化
DeltaFlags.POSITION // 1 - 位置变化
DeltaFlags.ROTATION // 2 - 旋转变化
DeltaFlags.VELOCITY // 4 - 速度变化
DeltaFlags.ANGULAR_VELOCITY // 8 - 角速度变化
DeltaFlags.CUSTOM // 16 - 自定义数据变化
```
## 数据格式
### 完整状态
```typescript
interface EntitySyncState {
netId: number;
pos?: { x: number; y: number };
rot?: number;
vel?: { x: number; y: number };
angVel?: number;
custom?: Record<string, unknown>;
}
```
### 增量状态
```typescript
interface EntityDeltaState {
netId: number;
flags: number; // 变化标志位
pos?: { x: number; y: number }; // 仅在 POSITION 标志时存在
rot?: number; // 仅在 ROTATION 标志时存在
vel?: { x: number; y: number }; // 仅在 VELOCITY 标志时存在
angVel?: number; // 仅在 ANGULAR_VELOCITY 标志时存在
custom?: Record<string, unknown>; // 仅在 CUSTOM 标志时存在
}
```
## 工作原理
```
帧 1 (完整快照)
Entity 1: pos=(100, 200), rot=0
帧 2 (增量)
Entity 1: flags=POSITION, pos=(101, 200) // 只有 X 变化
帧 3 (增量)
Entity 1: flags=0 // 无变化,不发送
帧 4 (增量)
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
帧 60 (强制完整快照)
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
```
## 服务器端使用
```typescript
import { createStateDeltaCompressor } from '@esengine/network';
class GameServer {
private compressor = createStateDeltaCompressor();
// 广播状态更新
broadcastState(entities: EntitySyncState[]) {
const syncData: SyncData = {
frame: this.currentFrame,
timestamp: Date.now(),
entities,
};
// 压缩数据
const deltaData = this.compressor.compress(syncData);
// 发送增量数据
this.broadcast('sync', deltaData);
}
// 玩家离开时清理
onPlayerLeave(netId: number) {
this.compressor.removeEntity(netId);
}
}
```
## 客户端使用
```typescript
class GameClient {
private compressor = createStateDeltaCompressor();
// 接收增量数据
onSyncReceived(deltaData: DeltaSyncData) {
// 解压为完整状态
const fullData = this.compressor.decompress(deltaData);
// 应用状态
for (const entity of fullData.entities) {
this.applyEntityState(entity);
}
}
}
```
## 带宽节省示例
假设每个实体有以下数据:
| 字段 | 大小(字节) |
|------|------------|
| netId | 4 |
| pos.x | 8 |
| pos.y | 8 |
| rot | 8 |
| vel.x | 8 |
| vel.y | 8 |
| angVel | 8 |
| **总计** | **52** |
使用增量压缩:
| 场景 | 原始 | 压缩后 | 节省 |
|------|------|--------|------|
| 只有位置变化 | 52 | 4+1+16 = 21 | 60% |
| 只有旋转变化 | 52 | 4+1+8 = 13 | 75% |
| 静止不动 | 52 | 0 | 100% |
| 位置+旋转变化 | 52 | 4+1+24 = 29 | 44% |
## 强制完整快照
某些情况下需要发送完整快照:
```typescript
// 新玩家加入时
compressor.forceFullSnapshot();
const data = compressor.compress(syncData);
// 这次会发送完整状态
// 重连时
compressor.clear(); // 清除历史状态
compressor.forceFullSnapshot();
```
## 自定义数据
支持同步自定义游戏数据:
```typescript
const syncData: SyncData = {
frame: 100,
timestamp: Date.now(),
entities: [
{
netId: 1,
pos: { x: 100, y: 200 },
custom: {
health: 80,
mana: 50,
buffs: ['speed', 'shield'],
},
},
],
};
// 自定义数据也会进行增量压缩
const deltaData = compressor.compress(syncData);
```
## 最佳实践
### 1. 合理设置阈值
```typescript
// 高精度游戏(如竞技游戏)
const compressor = createStateDeltaCompressor({
positionThreshold: 0.001,
rotationThreshold: 0.0001,
});
// 普通游戏
const compressor = createStateDeltaCompressor({
positionThreshold: 0.1,
rotationThreshold: 0.01,
});
```
### 2. 调整完整快照间隔
```typescript
// 高可靠性(网络不稳定)
fullSnapshotInterval: 30, // 每 30 帧发送完整快照
// 低带宽优先
fullSnapshotInterval: 120, // 每 120 帧发送完整快照
```
### 3. 配合 AOI 使用
```typescript
// 先用 AOI 过滤,再用增量压缩
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
const syncData = { frame, timestamp, entities: filteredEntities };
const deltaData = compressor.compress(syncData);
```
### 4. 处理实体移除
```typescript
// 实体销毁时清理压缩器状态
function onEntityDespawn(netId: number) {
compressor.removeEntity(netId);
}
```
## 与其他功能配合
```
┌─────────────────┐
│ 游戏状态 │
└────────┬────────┘
┌────────▼────────┐
│ AOI 过滤 │ ← 只处理视野内实体
└────────┬────────┘
┌────────▼────────┐
│ 增量压缩 │ ← 只发送变化的字段
└────────┬────────┘
┌────────▼────────┐
│ 网络传输 │
└─────────────────┘
```
## 调试
```typescript
const compressor = createStateDeltaCompressor();
// 检查压缩效果
const original = syncData;
const compressed = compressor.compress(original);
console.log('Original entities:', original.entities.length);
console.log('Compressed entities:', compressed.entities.length);
console.log('Is full snapshot:', compressed.isFullSnapshot);
// 查看每个实体的变化
for (const delta of compressed.entities) {
console.log(`Entity ${delta.netId}:`, {
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
});
}
```

View File

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

View File

@@ -0,0 +1,254 @@
---
title: "客户端预测"
description: "本地输入预测和服务器校正"
---
客户端预测是网络游戏中用于减少输入延迟的关键技术。通过在本地立即应用玩家输入,同时等待服务器确认,可以让游戏感觉更加流畅响应。
## NetworkPredictionSystem
`NetworkPredictionSystem` 是专门处理本地玩家预测的 ECS 系统。
### 基本用法
```typescript
import { NetworkPlugin } from '@esengine/network';
const networkPlugin = new NetworkPlugin({
enablePrediction: true,
predictionConfig: {
moveSpeed: 200, // 移动速度(单位/秒)
maxUnacknowledgedInputs: 60, // 最大未确认输入数
reconciliationThreshold: 0.5, // 校正阈值
reconciliationSpeed: 10, // 校正速度
}
});
await Core.installPlugin(networkPlugin);
```
### 设置本地玩家
当本地玩家实体生成后,需要设置其网络 ID
```typescript
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
const identity = entity.addComponent(new NetworkIdentity());
identity.netId = spawn.netId;
identity.ownerId = spawn.ownerId;
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
identity.bIsLocalPlayer = identity.bHasAuthority;
entity.addComponent(new NetworkTransform());
// 设置本地玩家用于预测
if (identity.bIsLocalPlayer) {
networkPlugin.setLocalPlayerNetId(spawn.netId);
}
return entity;
});
```
### 发送输入
```typescript
// 在游戏循环中发送移动输入
function onUpdate() {
const moveX = Input.getAxis('horizontal');
const moveY = Input.getAxis('vertical');
if (moveX !== 0 || moveY !== 0) {
networkPlugin.sendMoveInput(moveX, moveY);
}
// 发送动作输入
if (Input.isPressed('attack')) {
networkPlugin.sendActionInput('attack');
}
}
```
## 预测配置
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `moveSpeed` | `number` | 200 | 移动速度(单位/秒) |
| `enabled` | `boolean` | true | 是否启用预测 |
| `maxUnacknowledgedInputs` | `number` | 60 | 最大未确认输入数 |
| `reconciliationThreshold` | `number` | 0.5 | 触发校正的位置差异阈值 |
| `reconciliationSpeed` | `number` | 10 | 校正平滑速度 |
## 工作原理
```
客户端 服务器
│ │
├─ 1. 捕获输入 (seq=1) │
├─ 2. 本地预测移动 │
├─ 3. 发送输入到服务器 ──────────────►
│ │
├─ 4. 继续捕获输入 (seq=2,3...) │
├─ 5. 继续本地预测 │
│ │
│ ├─ 6. 处理输入 (seq=1)
│ │
◄──────── 7. 返回状态 (ackSeq=1) ────
│ │
├─ 8. 比较预测和服务器状态 │
├─ 9. 重放 seq=2,3... 的输入 │
├─ 10. 平滑校正到正确位置 │
│ │
```
### 步骤详解
1. **输入捕获**:捕获玩家输入并分配序列号
2. **本地预测**:立即应用输入到本地状态
3. **发送输入**:将输入发送到服务器
4. **缓存输入**:保存输入用于后续校正
5. **接收确认**:服务器返回权威状态和已确认序列号
6. **状态比较**:比较预测状态和服务器状态
7. **输入重放**:使用缓存的未确认输入重新计算状态
8. **平滑校正**:平滑插值到正确位置
## 底层 API
如果需要更细粒度的控制,可以直接使用 `ClientPrediction` 类:
```typescript
import { createClientPrediction, type IPredictor } from '@esengine/network';
// 定义状态类型
interface PlayerState {
x: number;
y: number;
rotation: number;
}
// 定义输入类型
interface PlayerInput {
dx: number;
dy: number;
}
// 定义预测器
const predictor: IPredictor<PlayerState, PlayerInput> = {
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
return {
x: state.x + input.dx * MOVE_SPEED * dt,
y: state.y + input.dy * MOVE_SPEED * dt,
rotation: state.rotation,
};
}
};
// 创建客户端预测
const prediction = createClientPrediction(predictor, {
maxUnacknowledgedInputs: 60,
reconciliationThreshold: 0.5,
reconciliationSpeed: 10,
});
// 记录输入并获取预测状态
const input = { dx: 1, dy: 0 };
const predictedState = prediction.recordInput(input, currentState, deltaTime);
// 获取要发送的输入
const inputToSend = prediction.getInputToSend();
// 与服务器状态校正
prediction.reconcile(
serverState,
serverAckSeq,
(state) => ({ x: state.x, y: state.y }),
deltaTime
);
// 获取校正偏移
const offset = prediction.correctionOffset;
```
## 启用/禁用预测
```typescript
// 运行时切换预测
networkPlugin.setPredictionEnabled(false);
// 检查预测状态
if (networkPlugin.isPredictionEnabled) {
console.log('Prediction is active');
}
```
## 最佳实践
### 1. 合理设置校正阈值
```typescript
// 动作游戏:较低阈值,更精确
predictionConfig: {
reconciliationThreshold: 0.1,
}
// 休闲游戏:较高阈值,更平滑
predictionConfig: {
reconciliationThreshold: 1.0,
}
```
### 2. 预测仅用于本地玩家
远程玩家应使用插值而非预测:
```typescript
const identity = entity.getComponent(NetworkIdentity);
if (identity.bIsLocalPlayer) {
// 使用预测系统
} else {
// 使用 NetworkSyncSystem 的插值
}
```
### 3. 处理高延迟
```typescript
// 高延迟网络增加缓冲
predictionConfig: {
maxUnacknowledgedInputs: 120, // 增加缓冲
reconciliationSpeed: 5, // 减慢校正速度
}
```
### 4. 确定性预测
确保客户端和服务器使用相同的物理计算:
```typescript
// 使用固定时间步长
const FIXED_DT = 1 / 60;
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
// 使用固定时间步长而非实际 deltaTime
return {
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
rotation: state.rotation,
};
}
```
## 调试
```typescript
// 获取预测系统实例
const predictionSystem = networkPlugin.predictionSystem;
if (predictionSystem) {
console.log('Pending inputs:', predictionSystem.pendingInputCount);
console.log('Current sequence:', predictionSystem.inputSequence);
}
```

View File

@@ -1,5 +1,40 @@
# @esengine/network
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/network",
"version": "2.1.1",
"version": "2.2.0",
"description": "Network synchronization for multiplayer games",
"esengine": {
"plugin": true,

View File

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

View File

@@ -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,9 @@ export type {
IPredictedState,
IPredictor,
ClientPredictionConfig,
EntityDeltaState,
DeltaSyncData,
DeltaCompressionConfig,
} from './sync'
export {
@@ -119,6 +147,9 @@ export {
createHermiteTransformInterpolator,
ClientPrediction,
createClientPrediction,
DeltaFlags,
StateDeltaCompressor,
createStateDeltaCompressor,
} from './sync'
// ============================================================================

View File

@@ -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>(),
},
})

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

View File

@@ -46,3 +46,19 @@ 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';

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

View File

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

View File

@@ -0,0 +1,309 @@
/**
* @zh 网络预测系统
* @en Network Prediction System
*
* @zh 处理本地玩家的客户端预测和服务器校正
* @en Handles client-side prediction and server reconciliation for local player
*/
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
import { NetworkIdentity } from '../components/NetworkIdentity'
import { NetworkTransform } from '../components/NetworkTransform'
import type { SyncData, PlayerInput } from '../protocol'
import {
ClientPrediction,
createClientPrediction,
type IPredictor,
type ClientPredictionConfig,
type ITransformState,
} from '../sync'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 移动输入
* @en Movement input
*/
export interface MovementInput {
x: number
y: number
actions?: string[]
}
/**
* @zh 预测状态(位置 + 旋转)
* @en Predicted state (position + rotation)
*/
export interface PredictedTransform extends ITransformState {
velocityX: number
velocityY: number
}
/**
* @zh 预测系统配置
* @en Prediction system configuration
*/
export interface NetworkPredictionConfig extends Partial<ClientPredictionConfig> {
/**
* @zh 移动速度(单位/秒)
* @en Movement speed (units/second)
*/
moveSpeed: number
/**
* @zh 是否启用预测
* @en Whether prediction is enabled
*/
enabled: boolean
}
const DEFAULT_CONFIG: NetworkPredictionConfig = {
moveSpeed: 200,
enabled: true,
maxUnacknowledgedInputs: 60,
reconciliationThreshold: 0.5,
reconciliationSpeed: 10,
}
// =============================================================================
// 默认预测器 | Default Predictor
// =============================================================================
/**
* @zh 简单移动预测器
* @en Simple movement predictor
*/
class SimpleMovementPredictor implements IPredictor<PredictedTransform, MovementInput> {
constructor(private readonly _moveSpeed: number) {}
predict(state: PredictedTransform, input: MovementInput, deltaTime: number): PredictedTransform {
const velocityX = input.x * this._moveSpeed
const velocityY = input.y * this._moveSpeed
return {
x: state.x + velocityX * deltaTime,
y: state.y + velocityY * deltaTime,
rotation: state.rotation,
velocityX,
velocityY,
}
}
}
// =============================================================================
// NetworkPredictionSystem | 网络预测系统
// =============================================================================
/**
* @zh 网络预测系统
* @en Network prediction system
*
* @zh 处理本地玩家的输入预测和服务器状态校正
* @en Handles local player input prediction and server state reconciliation
*/
export class NetworkPredictionSystem extends EntitySystem {
private readonly _config: NetworkPredictionConfig
private readonly _predictor: IPredictor<PredictedTransform, MovementInput>
private _prediction: ClientPrediction<PredictedTransform, MovementInput> | null = null
private _localPlayerNetId: number = -1
private _currentInput: MovementInput = { x: 0, y: 0 }
private _inputSequence: number = 0
constructor(config?: Partial<NetworkPredictionConfig>) {
super(Matcher.all(NetworkIdentity, NetworkTransform))
this._config = { ...DEFAULT_CONFIG, ...config }
this._predictor = new SimpleMovementPredictor(this._config.moveSpeed)
}
/**
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<NetworkPredictionConfig> {
return this._config
}
/**
* @zh 获取当前输入序列号
* @en Get current input sequence number
*/
get inputSequence(): number {
return this._inputSequence
}
/**
* @zh 获取待确认输入数量
* @en Get pending input count
*/
get pendingInputCount(): number {
return this._prediction?.pendingInputCount ?? 0
}
/**
* @zh 是否启用预测
* @en Whether prediction is enabled
*/
get enabled(): boolean {
return this._config.enabled
}
set enabled(value: boolean) {
this._config.enabled = value
}
/**
* @zh 设置本地玩家网络 ID
* @en Set local player network ID
*/
setLocalPlayerNetId(netId: number): void {
this._localPlayerNetId = netId
this._prediction = createClientPrediction<PredictedTransform, MovementInput>(
this._predictor,
{
maxUnacknowledgedInputs: this._config.maxUnacknowledgedInputs,
reconciliationThreshold: this._config.reconciliationThreshold,
reconciliationSpeed: this._config.reconciliationSpeed,
}
)
}
/**
* @zh 设置移动输入
* @en Set movement input
*/
setInput(x: number, y: number, actions?: string[]): void {
this._currentInput = { x, y, actions }
}
/**
* @zh 获取下一个要发送的输入(带序列号)
* @en Get next input to send (with sequence number)
*/
getInputToSend(): PlayerInput | null {
if (!this._prediction) return null
const input = this._prediction.getInputToSend()
if (!input) return null
return {
seq: input.sequence,
frame: 0,
timestamp: input.timestamp,
moveDir: { x: input.input.x, y: input.input.y },
actions: input.input.actions,
}
}
/**
* @zh 处理服务器同步数据进行校正
* @en Process server sync data for reconciliation
*/
reconcileWithServer(data: SyncData): void {
if (!this._prediction || this._localPlayerNetId < 0) return
// Find local player state in sync data
const localState = data.entities.find(e => e.netId === this._localPlayerNetId)
if (!localState || !localState.pos) return
const serverState: PredictedTransform = {
x: localState.pos.x,
y: localState.pos.y,
rotation: localState.rot ?? 0,
velocityX: localState.vel?.x ?? 0,
velocityY: localState.vel?.y ?? 0,
}
// Reconcile prediction with server state
if (data.ackSeq !== undefined) {
this._prediction.reconcile(
serverState,
data.ackSeq,
(state) => ({ x: state.x, y: state.y }),
Time.deltaTime
)
}
}
protected override process(entities: readonly Entity[]): void {
if (!this._config.enabled || !this._prediction) return
const deltaTime = Time.deltaTime
for (const entity of entities) {
const identity = this.requireComponent(entity, NetworkIdentity)
// Only process local player with authority
if (!identity.bHasAuthority || identity.netId !== this._localPlayerNetId) continue
const transform = this.requireComponent(entity, NetworkTransform)
// Get current state
const currentState: PredictedTransform = {
x: transform.currentX,
y: transform.currentY,
rotation: transform.currentRotation,
velocityX: 0,
velocityY: 0,
}
// Record input and get predicted state
if (this._currentInput.x !== 0 || this._currentInput.y !== 0) {
const predicted = this._prediction.recordInput(
this._currentInput,
currentState,
deltaTime
)
// Apply predicted position
transform.currentX = predicted.x
transform.currentY = predicted.y
transform.currentRotation = predicted.rotation
// Update target to match (for rendering)
transform.targetX = predicted.x
transform.targetY = predicted.y
transform.targetRotation = predicted.rotation
this._inputSequence = this._prediction.currentSequence
}
// Apply correction offset smoothly
const offset = this._prediction.correctionOffset
if (Math.abs(offset.x) > 0.01 || Math.abs(offset.y) > 0.01) {
transform.currentX += offset.x * deltaTime * 5
transform.currentY += offset.y * deltaTime * 5
}
}
}
/**
* @zh 重置预测状态
* @en Reset prediction state
*/
reset(): void {
this._prediction?.clear()
this._inputSequence = 0
this._currentInput = { x: 0, y: 0 }
}
protected override onDestroy(): void {
this._prediction?.clear()
this._prediction = null
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建网络预测系统
* @en Create network prediction system
*/
export function createNetworkPredictionSystem(
config?: Partial<NetworkPredictionConfig>
): NetworkPredictionSystem {
return new NetworkPredictionSystem(config)
}

View File

@@ -1,10 +1,32 @@
/**
* @zh 网络同步系统
* @en Network Sync System
*
* @zh 处理网络实体的状态同步、快照缓冲和插值
* @en Handles state synchronization, snapshot buffering, and interpolation for networked entities
*/
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
import { NetworkIdentity } from '../components/NetworkIdentity'
import { NetworkTransform } from '../components/NetworkTransform'
import type { SyncData, EntitySyncState } from '../protocol'
import {
SnapshotBuffer,
createSnapshotBuffer,
TransformInterpolator,
createTransformInterpolator,
type ITransformState,
type ITransformStateWithVelocity,
type IStateSnapshot,
} from '../sync'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 同步消息接口
* @en Sync message interface
* @zh 同步消息接口(兼容旧版)
* @en Sync message interface (for backwards compatibility)
*/
export interface SyncMessage {
entities: Array<{
@@ -14,25 +36,134 @@ export interface SyncMessage {
}>
}
/**
* @zh 实体快照数据
* @en Entity snapshot data
*/
interface EntitySnapshotData {
buffer: SnapshotBuffer<ITransformStateWithVelocity>
lastServerTime: number
}
/**
* @zh 同步系统配置
* @en Sync system configuration
*/
export interface NetworkSyncConfig {
/**
* @zh 快照缓冲区大小
* @en Snapshot buffer size
*/
bufferSize: number
/**
* @zh 插值延迟(毫秒)
* @en Interpolation delay in milliseconds
*/
interpolationDelay: number
/**
* @zh 是否启用外推
* @en Whether to enable extrapolation
*/
enableExtrapolation: boolean
/**
* @zh 最大外推时间(毫秒)
* @en Maximum extrapolation time in milliseconds
*/
maxExtrapolationTime: number
/**
* @zh 使用赫尔米特插值(更平滑)
* @en Use Hermite interpolation (smoother)
*/
useHermiteInterpolation: boolean
}
const DEFAULT_CONFIG: NetworkSyncConfig = {
bufferSize: 30,
interpolationDelay: 100,
enableExtrapolation: true,
maxExtrapolationTime: 200,
useHermiteInterpolation: false,
}
// =============================================================================
// NetworkSyncSystem | 网络同步系统
// =============================================================================
/**
* @zh 网络同步系统
* @en Network sync system
*
* @zh 处理网络实体的状态同步和插值
* @en Handles state synchronization and interpolation for networked entities
* @zh 处理网络实体的状态同步和插值,支持快照缓冲、平滑插值和外推
* @en Handles state synchronization and interpolation for networked entities,
* supports snapshot buffering, smooth interpolation, and extrapolation
*/
export class NetworkSyncSystem extends EntitySystem {
private _netIdToEntity: Map<number, number> = new Map()
private readonly _netIdToEntity: Map<number, number> = new Map()
private readonly _entitySnapshots: Map<number, EntitySnapshotData> = new Map()
private readonly _interpolator: TransformInterpolator
private readonly _config: NetworkSyncConfig
constructor() {
private _serverTimeOffset: number = 0
private _lastSyncTime: number = 0
private _renderTime: number = 0
constructor(config?: Partial<NetworkSyncConfig>) {
super(Matcher.all(NetworkIdentity, NetworkTransform))
this._config = { ...DEFAULT_CONFIG, ...config }
this._interpolator = createTransformInterpolator()
}
/**
* @zh 处理同步消息
* @en Handle sync message
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<NetworkSyncConfig> {
return this._config
}
/**
* @zh 获取服务器时间偏移
* @en Get server time offset
*/
get serverTimeOffset(): number {
return this._serverTimeOffset
}
/**
* @zh 获取当前渲染时间
* @en Get current render time
*/
get renderTime(): number {
return this._renderTime
}
/**
* @zh 处理同步消息(新版,带时间戳)
* @en Handle sync message (new version with timestamp)
*/
handleSyncData(data: SyncData): void {
const serverTime = data.timestamp
// Update server time offset
const clientTime = Date.now()
this._serverTimeOffset = serverTime - clientTime
this._lastSyncTime = clientTime
for (const state of data.entities) {
this._processEntityState(state, serverTime)
}
}
/**
* @zh 处理同步消息(兼容旧版)
* @en Handle sync message (backwards compatible)
*/
handleSync(msg: SyncMessage): void {
const now = Date.now()
for (const state of msg.entities) {
const entityId = this._netIdToEntity.get(state.netId)
if (entityId === undefined) continue
@@ -44,22 +175,133 @@ export class NetworkSyncSystem extends EntitySystem {
if (transform && state.pos) {
transform.setTarget(state.pos.x, state.pos.y, state.rot ?? 0)
}
// Also add to snapshot buffer for interpolation
this._processEntityState({
netId: state.netId,
pos: state.pos,
rot: state.rot,
}, now)
}
}
private _processEntityState(state: EntitySyncState, serverTime: number): void {
const entityId = this._netIdToEntity.get(state.netId)
if (entityId === undefined) return
// Get or create snapshot buffer
let snapshotData = this._entitySnapshots.get(state.netId)
if (!snapshotData) {
snapshotData = {
buffer: createSnapshotBuffer<ITransformStateWithVelocity>(
this._config.bufferSize,
this._config.interpolationDelay
),
lastServerTime: 0,
}
this._entitySnapshots.set(state.netId, snapshotData)
}
// Create snapshot
const transformState: ITransformStateWithVelocity = {
x: state.pos?.x ?? 0,
y: state.pos?.y ?? 0,
rotation: state.rot ?? 0,
velocityX: state.vel?.x ?? 0,
velocityY: state.vel?.y ?? 0,
angularVelocity: state.angVel ?? 0,
}
const snapshot: IStateSnapshot<ITransformStateWithVelocity> = {
timestamp: serverTime,
state: transformState,
}
snapshotData.buffer.push(snapshot)
snapshotData.lastServerTime = serverTime
}
protected override process(entities: readonly Entity[]): void {
const deltaTime = Time.deltaTime
const clientTime = Date.now()
// Calculate render time (current time adjusted for server offset)
this._renderTime = clientTime + this._serverTimeOffset
for (const entity of entities) {
const transform = this.requireComponent(entity, NetworkTransform)
const identity = this.requireComponent(entity, NetworkIdentity)
if (!identity.bHasAuthority && transform.bInterpolate) {
this._interpolate(transform, deltaTime)
// Skip entities with authority (local player handles their own movement)
if (identity.bHasAuthority) continue
if (transform.bInterpolate) {
this._interpolateEntity(identity.netId, transform, deltaTime)
}
}
}
private _interpolateEntity(
netId: number,
transform: NetworkTransform,
deltaTime: number
): void {
const snapshotData = this._entitySnapshots.get(netId)
if (snapshotData && snapshotData.buffer.size >= 2) {
// Use snapshot buffer for interpolation
const result = snapshotData.buffer.getInterpolationSnapshots(this._renderTime)
if (result) {
const [prev, next, t] = result
const interpolated = this._interpolator.interpolate(prev.state, next.state, t)
transform.currentX = interpolated.x
transform.currentY = interpolated.y
transform.currentRotation = interpolated.rotation
// Update target for compatibility
transform.targetX = next.state.x
transform.targetY = next.state.y
transform.targetRotation = next.state.rotation
return
}
// Extrapolation if enabled and we have velocity data
if (this._config.enableExtrapolation) {
const latest = snapshotData.buffer.getLatest()
if (latest) {
const timeSinceLastSnapshot = this._renderTime - latest.timestamp
if (timeSinceLastSnapshot > 0 && timeSinceLastSnapshot < this._config.maxExtrapolationTime) {
const extrapolated = this._interpolator.extrapolate(
latest.state,
timeSinceLastSnapshot / 1000
)
transform.currentX = extrapolated.x
transform.currentY = extrapolated.y
transform.currentRotation = extrapolated.rotation
return
}
}
}
}
// Fallback: simple lerp towards target
this._simpleLerp(transform, deltaTime)
}
private _simpleLerp(transform: NetworkTransform, deltaTime: number): void {
const t = Math.min(1, transform.lerpSpeed * deltaTime)
transform.currentX += (transform.targetX - transform.currentX) * t
transform.currentY += (transform.targetY - transform.currentY) * t
let angleDiff = transform.targetRotation - transform.currentRotation
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2
transform.currentRotation += angleDiff * t
}
/**
* @zh 注册网络实体
* @en Register network entity
@@ -74,6 +316,7 @@ export class NetworkSyncSystem extends EntitySystem {
*/
unregisterEntity(netId: number): void {
this._netIdToEntity.delete(netId)
this._entitySnapshots.delete(netId)
}
/**
@@ -84,19 +327,26 @@ export class NetworkSyncSystem extends EntitySystem {
return this._netIdToEntity.get(netId)
}
private _interpolate(transform: NetworkTransform, deltaTime: number): void {
const t = Math.min(1, transform.lerpSpeed * deltaTime)
/**
* @zh 获取实体的快照缓冲区
* @en Get entity's snapshot buffer
*/
getSnapshotBuffer(netId: number): SnapshotBuffer<ITransformStateWithVelocity> | undefined {
return this._entitySnapshots.get(netId)?.buffer
}
transform.currentX += (transform.targetX - transform.currentX) * t
transform.currentY += (transform.targetY - transform.currentY) * t
let angleDiff = transform.targetRotation - transform.currentRotation
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2
transform.currentRotation += angleDiff * t
/**
* @zh 清空所有快照缓冲
* @en Clear all snapshot buffers
*/
clearSnapshots(): void {
for (const data of this._entitySnapshots.values()) {
data.buffer.clear()
}
}
protected override onDestroy(): void {
this._netIdToEntity.clear()
this._entitySnapshots.clear()
}
}

View File

@@ -8,6 +8,8 @@ import type { NetworkService } from './services/NetworkService';
import type { NetworkSyncSystem } from './systems/NetworkSyncSystem';
import type { NetworkSpawnSystem } from './systems/NetworkSpawnSystem';
import type { NetworkInputSystem } from './systems/NetworkInputSystem';
import type { NetworkPredictionSystem } from './systems/NetworkPredictionSystem';
import type { NetworkAOISystem } from './systems/NetworkAOISystem';
// ============================================================================
// Network 模块导出的令牌 | Tokens exported by Network module
@@ -36,3 +38,15 @@ export const NetworkSpawnSystemToken = createServiceToken<NetworkSpawnSystem>('n
* Network input system token
*/
export const NetworkInputSystemToken = createServiceToken<NetworkInputSystem>('networkInputSystem');
/**
* 网络预测系统令牌
* Network prediction system token
*/
export const NetworkPredictionSystemToken = createServiceToken<NetworkPredictionSystem>('networkPredictionSystem');
/**
* 网络 AOI 系统令牌
* Network AOI system token
*/
export const NetworkAOISystemToken = createServiceToken<NetworkAOISystem>('networkAOISystem');