feat(network): 网络模块增强 - 预测、AOI、增量压缩 (#379)
- 添加 NetworkPredictionSystem 客户端预测系统 - 添加 NetworkAOISystem 兴趣区域管理 - 添加 StateDeltaCompressor 状态增量压缩 - 添加断线重连和状态恢复 - 增强协议支持时间戳、序列号、速度 - 添加中英文文档
This commit is contained in:
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: "Area of Interest (AOI)"
|
||||
description: "View range based network entity filtering"
|
||||
---
|
||||
|
||||
AOI (Area of Interest) is a key technique in large-scale multiplayer games for optimizing network bandwidth. By only synchronizing entities within a player's view range, network traffic can be significantly reduced.
|
||||
|
||||
## NetworkAOISystem
|
||||
|
||||
`NetworkAOISystem` provides grid-based area of interest management.
|
||||
|
||||
### Enable AOI
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enableAOI: true,
|
||||
aoiConfig: {
|
||||
cellSize: 100, // Grid cell size
|
||||
defaultViewRange: 500, // Default view range
|
||||
enabled: true,
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### Adding Observers
|
||||
|
||||
Each player that needs to receive sync data must be added as an observer:
|
||||
|
||||
```typescript
|
||||
// Add observer when player joins
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
// ... setup components
|
||||
|
||||
// Add player as AOI observer
|
||||
networkPlugin.addAOIObserver(
|
||||
spawn.netId, // Network ID
|
||||
spawn.pos.x, // Initial X position
|
||||
spawn.pos.y, // Initial Y position
|
||||
600 // View range (optional)
|
||||
);
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// Remove observer when player leaves
|
||||
networkPlugin.removeAOIObserver(playerNetId);
|
||||
```
|
||||
|
||||
### Updating Observer Position
|
||||
|
||||
When a player moves, update their AOI position:
|
||||
|
||||
```typescript
|
||||
// Update in game loop or sync callback
|
||||
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||
```
|
||||
|
||||
## AOI Configuration
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `cellSize` | `number` | 100 | Grid cell size |
|
||||
| `defaultViewRange` | `number` | 500 | Default view range |
|
||||
| `enabled` | `boolean` | true | Whether AOI is enabled |
|
||||
|
||||
### Grid Size Recommendations
|
||||
|
||||
Grid size should be set based on game view range:
|
||||
|
||||
```typescript
|
||||
// Recommendation: cellSize = defaultViewRange / 3 to / 5
|
||||
aoiConfig: {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500, // Grid is about 1/5 of view range
|
||||
}
|
||||
```
|
||||
|
||||
## Query Interface
|
||||
|
||||
### Get Visible Entities
|
||||
|
||||
```typescript
|
||||
// Get all entities visible to player
|
||||
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||
console.log('Visible entities:', visibleEntities);
|
||||
```
|
||||
|
||||
### Check Visibility
|
||||
|
||||
```typescript
|
||||
// Check if player can see an entity
|
||||
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||
// Target is in view
|
||||
}
|
||||
```
|
||||
|
||||
## Event Listening
|
||||
|
||||
The AOI system triggers events when entities enter/exit view:
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||
// Can send entity's initial state here
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||
// Can cleanup resources here
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Filtering
|
||||
|
||||
AOI is most commonly used server-side to filter sync data for each client:
|
||||
|
||||
```typescript
|
||||
// Server-side example
|
||||
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private aoiSystem = createNetworkAOISystem({
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
});
|
||||
|
||||
// Player joins
|
||||
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.addObserver(playerId, x, y);
|
||||
}
|
||||
|
||||
// Player moves
|
||||
onPlayerMove(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||
}
|
||||
|
||||
// Send sync data
|
||||
broadcastSync(allEntities: EntitySyncState[]) {
|
||||
for (const playerId of this.players) {
|
||||
// Filter using AOI
|
||||
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||
playerId,
|
||||
allEntities
|
||||
);
|
||||
|
||||
// Send only visible entities
|
||||
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Game World │
|
||||
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||
│ │ │ │ E │ │ │ │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ E = Enemy entity │
|
||||
│ │ │ P │ ● │ │ │ P = Player │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ ● = Player view center │
|
||||
│ │ │ │ E │ E │ │ ○ = View range │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||
│ │ │ │ │ │ E │ Player only sees E in view│
|
||||
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||
│ │
|
||||
│ View range (circle): Contains 3 enemies │
|
||||
│ Grid optimization: Only check cells covered by view │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Grid Optimization
|
||||
|
||||
AOI uses spatial grid to accelerate queries:
|
||||
|
||||
1. **Add Entity**: Calculate grid cell based on position
|
||||
2. **View Detection**: Only check cells covered by view range
|
||||
3. **Move Update**: Update cell assignment when crossing cells
|
||||
4. **Event Trigger**: Detect enter/exit view
|
||||
|
||||
## Dynamic View Range
|
||||
|
||||
Different player types can have different view ranges:
|
||||
|
||||
```typescript
|
||||
// Regular player
|
||||
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||
|
||||
// VIP player (larger view)
|
||||
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||
|
||||
// Adjust view range at runtime
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
if (aoiSystem) {
|
||||
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Server-Side Usage
|
||||
|
||||
AOI filtering should be done server-side; clients should not trust their own AOI judgment:
|
||||
|
||||
```typescript
|
||||
// Filter on server before sending
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
sendToClient(playerId, filtered);
|
||||
```
|
||||
|
||||
### 2. Edge Handling
|
||||
|
||||
Add buffer zone at view edge to prevent flickering:
|
||||
|
||||
```typescript
|
||||
// Add immediately when entering view
|
||||
// Remove with delay when exiting (keep for 1-2 extra seconds)
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'exit') {
|
||||
setTimeout(() => {
|
||||
// Re-check if really exited
|
||||
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||
removeFromClient(event.observerNetId, event.targetNetId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Large Entities
|
||||
|
||||
Large entities (like bosses) may need special handling:
|
||||
|
||||
```typescript
|
||||
// Boss is always visible to everyone
|
||||
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
|
||||
// Add boss entity
|
||||
const bossState = entities.find(e => e.netId === bossNetId);
|
||||
if (bossState && !filtered.includes(bossState)) {
|
||||
filtered.push(bossState);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Performance Considerations
|
||||
|
||||
```typescript
|
||||
// Large-scale game recommended config
|
||||
aoiConfig: {
|
||||
cellSize: 200, // Larger grid reduces cell count
|
||||
defaultViewRange: 800, // Set based on actual view
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
console.log('AOI enabled:', aoiSystem.enabled);
|
||||
console.log('Observer count:', aoiSystem.observerCount);
|
||||
|
||||
// Get visible entities for specific player
|
||||
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||
console.log('Visible entities:', visible.length);
|
||||
}
|
||||
```
|
||||
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: "State Delta Compression"
|
||||
description: "Reduce network bandwidth with incremental sync"
|
||||
---
|
||||
|
||||
State delta compression reduces network bandwidth by only sending fields that have changed. For frequently synchronized game state, this can significantly reduce data transmission.
|
||||
|
||||
## StateDeltaCompressor
|
||||
|
||||
The `StateDeltaCompressor` class is used to compress and decompress state deltas.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||
|
||||
// Create compressor
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.01, // Position change threshold
|
||||
rotationThreshold: 0.001, // Rotation change threshold (radians)
|
||||
velocityThreshold: 0.1, // Velocity change threshold
|
||||
fullSnapshotInterval: 60, // Full snapshot interval (frames)
|
||||
});
|
||||
|
||||
// Compress sync data
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||
],
|
||||
};
|
||||
|
||||
const deltaData = compressor.compress(syncData);
|
||||
// deltaData only contains changed fields
|
||||
|
||||
// Decompress delta data
|
||||
const fullData = compressor.decompress(deltaData);
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `positionThreshold` | `number` | 0.01 | Position change threshold |
|
||||
| `rotationThreshold` | `number` | 0.001 | Rotation change threshold (radians) |
|
||||
| `velocityThreshold` | `number` | 0.1 | Velocity change threshold |
|
||||
| `fullSnapshotInterval` | `number` | 60 | Full snapshot interval (frames) |
|
||||
|
||||
## Delta Flags
|
||||
|
||||
Bit flags indicate which fields have changed:
|
||||
|
||||
```typescript
|
||||
import { DeltaFlags } from '@esengine/network';
|
||||
|
||||
// Flag definitions
|
||||
DeltaFlags.NONE // 0 - No change
|
||||
DeltaFlags.POSITION // 1 - Position changed
|
||||
DeltaFlags.ROTATION // 2 - Rotation changed
|
||||
DeltaFlags.VELOCITY // 4 - Velocity changed
|
||||
DeltaFlags.ANGULAR_VELOCITY // 8 - Angular velocity changed
|
||||
DeltaFlags.CUSTOM // 16 - Custom data changed
|
||||
```
|
||||
|
||||
## Data Format
|
||||
|
||||
### Full State
|
||||
|
||||
```typescript
|
||||
interface EntitySyncState {
|
||||
netId: number;
|
||||
pos?: { x: number; y: number };
|
||||
rot?: number;
|
||||
vel?: { x: number; y: number };
|
||||
angVel?: number;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### Delta State
|
||||
|
||||
```typescript
|
||||
interface EntityDeltaState {
|
||||
netId: number;
|
||||
flags: number; // Change flags
|
||||
pos?: { x: number; y: number }; // Only present when POSITION flag set
|
||||
rot?: number; // Only present when ROTATION flag set
|
||||
vel?: { x: number; y: number }; // Only present when VELOCITY flag set
|
||||
angVel?: number; // Only present when ANGULAR_VELOCITY flag set
|
||||
custom?: Record<string, unknown>; // Only present when CUSTOM flag set
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Frame 1 (full snapshot):
|
||||
Entity 1: pos=(100, 200), rot=0
|
||||
|
||||
Frame 2 (delta):
|
||||
Entity 1: flags=POSITION, pos=(101, 200) // Only X changed
|
||||
|
||||
Frame 3 (delta):
|
||||
Entity 1: flags=0 // No change, not sent
|
||||
|
||||
Frame 4 (delta):
|
||||
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||
|
||||
Frame 60 (forced full snapshot):
|
||||
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||
```
|
||||
|
||||
## Server-Side Usage
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// Broadcast state updates
|
||||
broadcastState(entities: EntitySyncState[]) {
|
||||
const syncData: SyncData = {
|
||||
frame: this.currentFrame,
|
||||
timestamp: Date.now(),
|
||||
entities,
|
||||
};
|
||||
|
||||
// Compress data
|
||||
const deltaData = this.compressor.compress(syncData);
|
||||
|
||||
// Send delta data
|
||||
this.broadcast('sync', deltaData);
|
||||
}
|
||||
|
||||
// Cleanup when player leaves
|
||||
onPlayerLeave(netId: number) {
|
||||
this.compressor.removeEntity(netId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Usage
|
||||
|
||||
```typescript
|
||||
class GameClient {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// Receive delta data
|
||||
onSyncReceived(deltaData: DeltaSyncData) {
|
||||
// Decompress to full state
|
||||
const fullData = this.compressor.decompress(deltaData);
|
||||
|
||||
// Apply state
|
||||
for (const entity of fullData.entities) {
|
||||
this.applyEntityState(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Bandwidth Savings Example
|
||||
|
||||
Assume each entity has the following data:
|
||||
|
||||
| Field | Size (bytes) |
|
||||
|-------|-------------|
|
||||
| netId | 4 |
|
||||
| pos.x | 8 |
|
||||
| pos.y | 8 |
|
||||
| rot | 8 |
|
||||
| vel.x | 8 |
|
||||
| vel.y | 8 |
|
||||
| angVel | 8 |
|
||||
| **Total** | **52** |
|
||||
|
||||
With delta compression:
|
||||
|
||||
| Scenario | Original | Compressed | Savings |
|
||||
|----------|----------|------------|---------|
|
||||
| Only position changed | 52 | 4+1+16 = 21 | 60% |
|
||||
| Only rotation changed | 52 | 4+1+8 = 13 | 75% |
|
||||
| Stationary | 52 | 0 | 100% |
|
||||
| Position + rotation changed | 52 | 4+1+24 = 29 | 44% |
|
||||
|
||||
## Forcing Full Snapshot
|
||||
|
||||
Some situations require sending full snapshots:
|
||||
|
||||
```typescript
|
||||
// When new player joins
|
||||
compressor.forceFullSnapshot();
|
||||
const data = compressor.compress(syncData);
|
||||
// This will send full state
|
||||
|
||||
// On reconnection
|
||||
compressor.clear(); // Clear history
|
||||
compressor.forceFullSnapshot();
|
||||
```
|
||||
|
||||
## Custom Data
|
||||
|
||||
Support for syncing custom game data:
|
||||
|
||||
```typescript
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{
|
||||
netId: 1,
|
||||
pos: { x: 100, y: 200 },
|
||||
custom: {
|
||||
health: 80,
|
||||
mana: 50,
|
||||
buffs: ['speed', 'shield'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Custom data is also delta compressed
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Set Appropriate Thresholds
|
||||
|
||||
```typescript
|
||||
// High precision games (e.g., competitive)
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.001,
|
||||
rotationThreshold: 0.0001,
|
||||
});
|
||||
|
||||
// Casual games
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.1,
|
||||
rotationThreshold: 0.01,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Adjust Full Snapshot Interval
|
||||
|
||||
```typescript
|
||||
// High reliability (unstable network)
|
||||
fullSnapshotInterval: 30, // Full snapshot every 30 frames
|
||||
|
||||
// Low bandwidth priority
|
||||
fullSnapshotInterval: 120, // Full snapshot every 120 frames
|
||||
```
|
||||
|
||||
### 3. Combine with AOI
|
||||
|
||||
```typescript
|
||||
// Filter with AOI first, then delta compress
|
||||
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
### 4. Handle Entity Removal
|
||||
|
||||
```typescript
|
||||
// Clean up compressor state when entity despawns
|
||||
function onEntityDespawn(netId: number) {
|
||||
compressor.removeEntity(netId);
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Other Features
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Game State │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ AOI Filter │ ← Only process entities in view
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Delta Compress │ ← Only send changed fields
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Network Send │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
const compressor = createStateDeltaCompressor();
|
||||
|
||||
// Check compression efficiency
|
||||
const original = syncData;
|
||||
const compressed = compressor.compress(original);
|
||||
|
||||
console.log('Original entities:', original.entities.length);
|
||||
console.log('Compressed entities:', compressed.entities.length);
|
||||
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||
|
||||
// View each entity's changes
|
||||
for (const delta of compressed.entities) {
|
||||
console.log(`Entity ${delta.netId}:`, {
|
||||
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -147,7 +147,10 @@ service.on('chat', (data) => {
|
||||
|
||||
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
|
||||
- [Server Side](/en/modules/network/server/) - GameServer and Room management
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation, prediction and snapshots
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
|
||||
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
|
||||
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization
|
||||
- [Delta Compression](/en/modules/network/delta/) - State delta synchronization
|
||||
- [API Reference](/en/modules/network/api/) - Complete API documentation
|
||||
|
||||
## Service Tokens
|
||||
@@ -159,10 +162,14 @@ import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from '@esengine/network';
|
||||
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
254
docs/src/content/docs/en/modules/network/prediction.md
Normal file
254
docs/src/content/docs/en/modules/network/prediction.md
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
title: "Client Prediction"
|
||||
description: "Local input prediction and server reconciliation"
|
||||
---
|
||||
|
||||
Client prediction is a key technique in networked games to reduce input latency. By immediately applying player inputs locally while waiting for server confirmation, games feel more responsive.
|
||||
|
||||
## NetworkPredictionSystem
|
||||
|
||||
`NetworkPredictionSystem` is an ECS system dedicated to handling local player prediction.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enablePrediction: true,
|
||||
predictionConfig: {
|
||||
moveSpeed: 200, // Movement speed (units/second)
|
||||
maxUnacknowledgedInputs: 60, // Max unacknowledged inputs
|
||||
reconciliationThreshold: 0.5, // Reconciliation threshold
|
||||
reconciliationSpeed: 10, // Reconciliation speed
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### Setting Up Local Player
|
||||
|
||||
After the local player entity spawns, set its network ID:
|
||||
|
||||
```typescript
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
|
||||
identity.bIsLocalPlayer = identity.bHasAuthority;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
|
||||
// Set local player for prediction
|
||||
if (identity.bIsLocalPlayer) {
|
||||
networkPlugin.setLocalPlayerNetId(spawn.netId);
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
```
|
||||
|
||||
### Sending Input
|
||||
|
||||
```typescript
|
||||
// Send movement input in game loop
|
||||
function onUpdate() {
|
||||
const moveX = Input.getAxis('horizontal');
|
||||
const moveY = Input.getAxis('vertical');
|
||||
|
||||
if (moveX !== 0 || moveY !== 0) {
|
||||
networkPlugin.sendMoveInput(moveX, moveY);
|
||||
}
|
||||
|
||||
// Send action input
|
||||
if (Input.isPressed('attack')) {
|
||||
networkPlugin.sendActionInput('attack');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prediction Configuration
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `moveSpeed` | `number` | 200 | Movement speed (units/second) |
|
||||
| `enabled` | `boolean` | true | Whether prediction is enabled |
|
||||
| `maxUnacknowledgedInputs` | `number` | 60 | Max unacknowledged inputs |
|
||||
| `reconciliationThreshold` | `number` | 0.5 | Position difference threshold for reconciliation |
|
||||
| `reconciliationSpeed` | `number` | 10 | Reconciliation smoothing speed |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
├─ 1. Capture input (seq=1) │
|
||||
├─ 2. Predict movement locally │
|
||||
├─ 3. Send input to server ─────────►
|
||||
│ │
|
||||
├─ 4. Continue capturing (seq=2,3...) │
|
||||
├─ 5. Continue predicting │
|
||||
│ │
|
||||
│ ├─ 6. Process input (seq=1)
|
||||
│ │
|
||||
◄──────── 7. Return state (ackSeq=1) ─
|
||||
│ │
|
||||
├─ 8. Compare prediction with server │
|
||||
├─ 9. Replay inputs seq=2,3... │
|
||||
├─ 10. Smooth correction │
|
||||
│ │
|
||||
```
|
||||
|
||||
### Step by Step
|
||||
|
||||
1. **Input Capture**: Capture player input and assign sequence number
|
||||
2. **Local Prediction**: Immediately apply input to local state
|
||||
3. **Send Input**: Send input to server
|
||||
4. **Cache Input**: Save input for later reconciliation
|
||||
5. **Receive Acknowledgment**: Server returns authoritative state with ack sequence
|
||||
6. **State Comparison**: Compare predicted state with server state
|
||||
7. **Input Replay**: Recalculate state using cached unacknowledged inputs
|
||||
8. **Smooth Correction**: Interpolate smoothly to correct position
|
||||
|
||||
## Low-Level API
|
||||
|
||||
For fine-grained control, use the `ClientPrediction` class directly:
|
||||
|
||||
```typescript
|
||||
import { createClientPrediction, type IPredictor } from '@esengine/network';
|
||||
|
||||
// Define state type
|
||||
interface PlayerState {
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
// Define input type
|
||||
interface PlayerInput {
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
// Define predictor
|
||||
const predictor: IPredictor<PlayerState, PlayerInput> = {
|
||||
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * dt,
|
||||
y: state.y + input.dy * MOVE_SPEED * dt,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Create client prediction
|
||||
const prediction = createClientPrediction(predictor, {
|
||||
maxUnacknowledgedInputs: 60,
|
||||
reconciliationThreshold: 0.5,
|
||||
reconciliationSpeed: 10,
|
||||
});
|
||||
|
||||
// Record input and get predicted state
|
||||
const input = { dx: 1, dy: 0 };
|
||||
const predictedState = prediction.recordInput(input, currentState, deltaTime);
|
||||
|
||||
// Get input to send
|
||||
const inputToSend = prediction.getInputToSend();
|
||||
|
||||
// Reconcile with server state
|
||||
prediction.reconcile(
|
||||
serverState,
|
||||
serverAckSeq,
|
||||
(state) => ({ x: state.x, y: state.y }),
|
||||
deltaTime
|
||||
);
|
||||
|
||||
// Get correction offset
|
||||
const offset = prediction.correctionOffset;
|
||||
```
|
||||
|
||||
## Enable/Disable Prediction
|
||||
|
||||
```typescript
|
||||
// Toggle prediction at runtime
|
||||
networkPlugin.setPredictionEnabled(false);
|
||||
|
||||
// Check prediction status
|
||||
if (networkPlugin.isPredictionEnabled) {
|
||||
console.log('Prediction is active');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Set Appropriate Reconciliation Threshold
|
||||
|
||||
```typescript
|
||||
// Action games: lower threshold, more precise
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 0.1,
|
||||
}
|
||||
|
||||
// Casual games: higher threshold, smoother
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 1.0,
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Prediction Only for Local Player
|
||||
|
||||
Remote players should use interpolation, not prediction:
|
||||
|
||||
```typescript
|
||||
const identity = entity.getComponent(NetworkIdentity);
|
||||
|
||||
if (identity.bIsLocalPlayer) {
|
||||
// Use prediction system
|
||||
} else {
|
||||
// Use NetworkSyncSystem interpolation
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Handle High Latency
|
||||
|
||||
```typescript
|
||||
// High latency network: increase buffer
|
||||
predictionConfig: {
|
||||
maxUnacknowledgedInputs: 120, // Increase buffer
|
||||
reconciliationSpeed: 5, // Slower correction
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Deterministic Prediction
|
||||
|
||||
Ensure client and server use the same physics calculations:
|
||||
|
||||
```typescript
|
||||
// Use fixed timestep
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
|
||||
// Use fixed timestep instead of actual deltaTime
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
|
||||
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
// Get prediction system instance
|
||||
const predictionSystem = networkPlugin.predictionSystem;
|
||||
|
||||
if (predictionSystem) {
|
||||
console.log('Pending inputs:', predictionSystem.pendingInputCount);
|
||||
console.log('Current sequence:', predictionSystem.inputSequence);
|
||||
}
|
||||
```
|
||||
283
docs/src/content/docs/modules/network/aoi.md
Normal file
283
docs/src/content/docs/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: "兴趣区域管理 (AOI)"
|
||||
description: "基于视野范围的网络实体过滤"
|
||||
---
|
||||
|
||||
AOI(Area of Interest,兴趣区域)是大规模多人游戏中用于优化网络带宽的关键技术。通过只同步玩家视野范围内的实体,可以大幅减少网络流量。
|
||||
|
||||
## NetworkAOISystem
|
||||
|
||||
`NetworkAOISystem` 提供基于网格的兴趣区域管理。
|
||||
|
||||
### 启用 AOI
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enableAOI: true,
|
||||
aoiConfig: {
|
||||
cellSize: 100, // 网格单元大小
|
||||
defaultViewRange: 500, // 默认视野范围
|
||||
enabled: true,
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### 添加观察者
|
||||
|
||||
每个需要接收同步数据的玩家都需要作为观察者添加:
|
||||
|
||||
```typescript
|
||||
// 玩家加入时添加观察者
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
// ... 设置组件
|
||||
|
||||
// 将玩家添加为 AOI 观察者
|
||||
networkPlugin.addAOIObserver(
|
||||
spawn.netId, // 网络 ID
|
||||
spawn.pos.x, // 初始 X 位置
|
||||
spawn.pos.y, // 初始 Y 位置
|
||||
600 // 视野范围(可选)
|
||||
);
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// 玩家离开时移除观察者
|
||||
networkPlugin.removeAOIObserver(playerNetId);
|
||||
```
|
||||
|
||||
### 更新观察者位置
|
||||
|
||||
当玩家移动时,需要更新其 AOI 位置:
|
||||
|
||||
```typescript
|
||||
// 在游戏循环或同步回调中更新
|
||||
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||
```
|
||||
|
||||
## AOI 配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `cellSize` | `number` | 100 | 网格单元大小 |
|
||||
| `defaultViewRange` | `number` | 500 | 默认视野范围 |
|
||||
| `enabled` | `boolean` | true | 是否启用 AOI |
|
||||
|
||||
### 网格大小建议
|
||||
|
||||
网格大小应根据游戏视野范围设置:
|
||||
|
||||
```typescript
|
||||
// 建议:cellSize = defaultViewRange / 3 到 / 5
|
||||
aoiConfig: {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500, // 网格大约是视野的 1/5
|
||||
}
|
||||
```
|
||||
|
||||
## 查询接口
|
||||
|
||||
### 获取可见实体
|
||||
|
||||
```typescript
|
||||
// 获取玩家能看到的所有实体
|
||||
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||
console.log('Visible entities:', visibleEntities);
|
||||
```
|
||||
|
||||
### 检查可见性
|
||||
|
||||
```typescript
|
||||
// 检查玩家是否能看到某个实体
|
||||
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||
// 目标在视野内
|
||||
}
|
||||
```
|
||||
|
||||
## 事件监听
|
||||
|
||||
AOI 系统会在实体进入/离开视野时触发事件:
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||
// 可以在这里发送实体的初始状态
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||
// 可以在这里清理资源
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 服务器端过滤
|
||||
|
||||
AOI 最常用于服务器端,过滤发送给每个客户端的同步数据:
|
||||
|
||||
```typescript
|
||||
// 服务器端示例
|
||||
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private aoiSystem = createNetworkAOISystem({
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
});
|
||||
|
||||
// 玩家加入
|
||||
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.addObserver(playerId, x, y);
|
||||
}
|
||||
|
||||
// 玩家移动
|
||||
onPlayerMove(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||
}
|
||||
|
||||
// 发送同步数据
|
||||
broadcastSync(allEntities: EntitySyncState[]) {
|
||||
for (const playerId of this.players) {
|
||||
// 使用 AOI 过滤
|
||||
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||
playerId,
|
||||
allEntities
|
||||
);
|
||||
|
||||
// 只发送可见实体
|
||||
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 游戏世界 │
|
||||
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||
│ │ │ │ E │ │ │ │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ E = 敌人实体 │
|
||||
│ │ │ P │ ● │ │ │ P = 玩家 │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ ● = 玩家视野中心 │
|
||||
│ │ │ │ E │ E │ │ ○ = 视野范围 │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||
│ │ │ │ │ │ E │ 玩家只能看到视野内的 E │
|
||||
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||
│ │
|
||||
│ 视野范围(圆形):包含 3 个敌人 │
|
||||
│ 网格优化:只检查视野覆盖的网格单元 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 网格优化
|
||||
|
||||
AOI 使用空间网格加速查询:
|
||||
|
||||
1. **添加实体**:根据位置计算所在网格
|
||||
2. **视野检测**:只检查视野范围覆盖的网格
|
||||
3. **移动更新**:跨网格时更新网格归属
|
||||
4. **事件触发**:检测进入/离开视野
|
||||
|
||||
## 动态视野范围
|
||||
|
||||
可以为不同类型的玩家设置不同的视野:
|
||||
|
||||
```typescript
|
||||
// 普通玩家
|
||||
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||
|
||||
// VIP 玩家(更大视野)
|
||||
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||
|
||||
// 运行时调整视野
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
if (aoiSystem) {
|
||||
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 服务器端使用
|
||||
|
||||
AOI 过滤应在服务器端进行,客户端不应信任自己的 AOI 判断:
|
||||
|
||||
```typescript
|
||||
// 服务器端过滤后再发送
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
sendToClient(playerId, filtered);
|
||||
```
|
||||
|
||||
### 2. 边界处理
|
||||
|
||||
在视野边缘添加缓冲区防止闪烁:
|
||||
|
||||
```typescript
|
||||
// 进入视野时立即添加
|
||||
// 离开视野时延迟移除(保持额外 1-2 秒)
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'exit') {
|
||||
setTimeout(() => {
|
||||
// 再次检查是否真的离开
|
||||
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||
removeFromClient(event.observerNetId, event.targetNetId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 大型实体
|
||||
|
||||
对于大型实体(如 Boss),可能需要特殊处理:
|
||||
|
||||
```typescript
|
||||
// Boss 总是对所有人可见
|
||||
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
|
||||
// 添加 Boss 实体
|
||||
const bossState = entities.find(e => e.netId === bossNetId);
|
||||
if (bossState && !filtered.includes(bossState)) {
|
||||
filtered.push(bossState);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 性能考虑
|
||||
|
||||
```typescript
|
||||
// 大规模游戏建议配置
|
||||
aoiConfig: {
|
||||
cellSize: 200, // 较大的网格减少网格数量
|
||||
defaultViewRange: 800, // 根据实际视野设置
|
||||
}
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
console.log('AOI enabled:', aoiSystem.enabled);
|
||||
console.log('Observer count:', aoiSystem.observerCount);
|
||||
|
||||
// 获取特定玩家的可见实体
|
||||
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||
console.log('Visible entities:', visible.length);
|
||||
}
|
||||
```
|
||||
316
docs/src/content/docs/modules/network/delta.md
Normal file
316
docs/src/content/docs/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: "状态增量压缩"
|
||||
description: "减少网络带宽的增量同步"
|
||||
---
|
||||
|
||||
状态增量压缩通过只发送变化的字段来减少网络带宽。对于频繁同步的游戏状态,这可以显著降低数据传输量。
|
||||
|
||||
## StateDeltaCompressor
|
||||
|
||||
`StateDeltaCompressor` 类用于压缩和解压状态增量。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||
|
||||
// 创建压缩器
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.01, // 位置变化阈值
|
||||
rotationThreshold: 0.001, // 旋转变化阈值(弧度)
|
||||
velocityThreshold: 0.1, // 速度变化阈值
|
||||
fullSnapshotInterval: 60, // 完整快照间隔(帧数)
|
||||
});
|
||||
|
||||
// 压缩同步数据
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||
],
|
||||
};
|
||||
|
||||
const deltaData = compressor.compress(syncData);
|
||||
// deltaData 只包含变化的字段
|
||||
|
||||
// 解压增量数据
|
||||
const fullData = compressor.decompress(deltaData);
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `positionThreshold` | `number` | 0.01 | 位置变化阈值 |
|
||||
| `rotationThreshold` | `number` | 0.001 | 旋转变化阈值(弧度) |
|
||||
| `velocityThreshold` | `number` | 0.1 | 速度变化阈值 |
|
||||
| `fullSnapshotInterval` | `number` | 60 | 完整快照间隔(帧数) |
|
||||
|
||||
## 增量标志
|
||||
|
||||
使用位标志表示哪些字段发生了变化:
|
||||
|
||||
```typescript
|
||||
import { DeltaFlags } from '@esengine/network';
|
||||
|
||||
// 位标志定义
|
||||
DeltaFlags.NONE // 0 - 无变化
|
||||
DeltaFlags.POSITION // 1 - 位置变化
|
||||
DeltaFlags.ROTATION // 2 - 旋转变化
|
||||
DeltaFlags.VELOCITY // 4 - 速度变化
|
||||
DeltaFlags.ANGULAR_VELOCITY // 8 - 角速度变化
|
||||
DeltaFlags.CUSTOM // 16 - 自定义数据变化
|
||||
```
|
||||
|
||||
## 数据格式
|
||||
|
||||
### 完整状态
|
||||
|
||||
```typescript
|
||||
interface EntitySyncState {
|
||||
netId: number;
|
||||
pos?: { x: number; y: number };
|
||||
rot?: number;
|
||||
vel?: { x: number; y: number };
|
||||
angVel?: number;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### 增量状态
|
||||
|
||||
```typescript
|
||||
interface EntityDeltaState {
|
||||
netId: number;
|
||||
flags: number; // 变化标志位
|
||||
pos?: { x: number; y: number }; // 仅在 POSITION 标志时存在
|
||||
rot?: number; // 仅在 ROTATION 标志时存在
|
||||
vel?: { x: number; y: number }; // 仅在 VELOCITY 标志时存在
|
||||
angVel?: number; // 仅在 ANGULAR_VELOCITY 标志时存在
|
||||
custom?: Record<string, unknown>; // 仅在 CUSTOM 标志时存在
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
帧 1 (完整快照):
|
||||
Entity 1: pos=(100, 200), rot=0
|
||||
|
||||
帧 2 (增量):
|
||||
Entity 1: flags=POSITION, pos=(101, 200) // 只有 X 变化
|
||||
|
||||
帧 3 (增量):
|
||||
Entity 1: flags=0 // 无变化,不发送
|
||||
|
||||
帧 4 (增量):
|
||||
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||
|
||||
帧 60 (强制完整快照):
|
||||
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||
```
|
||||
|
||||
## 服务器端使用
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// 广播状态更新
|
||||
broadcastState(entities: EntitySyncState[]) {
|
||||
const syncData: SyncData = {
|
||||
frame: this.currentFrame,
|
||||
timestamp: Date.now(),
|
||||
entities,
|
||||
};
|
||||
|
||||
// 压缩数据
|
||||
const deltaData = this.compressor.compress(syncData);
|
||||
|
||||
// 发送增量数据
|
||||
this.broadcast('sync', deltaData);
|
||||
}
|
||||
|
||||
// 玩家离开时清理
|
||||
onPlayerLeave(netId: number) {
|
||||
this.compressor.removeEntity(netId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端使用
|
||||
|
||||
```typescript
|
||||
class GameClient {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// 接收增量数据
|
||||
onSyncReceived(deltaData: DeltaSyncData) {
|
||||
// 解压为完整状态
|
||||
const fullData = this.compressor.decompress(deltaData);
|
||||
|
||||
// 应用状态
|
||||
for (const entity of fullData.entities) {
|
||||
this.applyEntityState(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 带宽节省示例
|
||||
|
||||
假设每个实体有以下数据:
|
||||
|
||||
| 字段 | 大小(字节) |
|
||||
|------|------------|
|
||||
| netId | 4 |
|
||||
| pos.x | 8 |
|
||||
| pos.y | 8 |
|
||||
| rot | 8 |
|
||||
| vel.x | 8 |
|
||||
| vel.y | 8 |
|
||||
| angVel | 8 |
|
||||
| **总计** | **52** |
|
||||
|
||||
使用增量压缩:
|
||||
|
||||
| 场景 | 原始 | 压缩后 | 节省 |
|
||||
|------|------|--------|------|
|
||||
| 只有位置变化 | 52 | 4+1+16 = 21 | 60% |
|
||||
| 只有旋转变化 | 52 | 4+1+8 = 13 | 75% |
|
||||
| 静止不动 | 52 | 0 | 100% |
|
||||
| 位置+旋转变化 | 52 | 4+1+24 = 29 | 44% |
|
||||
|
||||
## 强制完整快照
|
||||
|
||||
某些情况下需要发送完整快照:
|
||||
|
||||
```typescript
|
||||
// 新玩家加入时
|
||||
compressor.forceFullSnapshot();
|
||||
const data = compressor.compress(syncData);
|
||||
// 这次会发送完整状态
|
||||
|
||||
// 重连时
|
||||
compressor.clear(); // 清除历史状态
|
||||
compressor.forceFullSnapshot();
|
||||
```
|
||||
|
||||
## 自定义数据
|
||||
|
||||
支持同步自定义游戏数据:
|
||||
|
||||
```typescript
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{
|
||||
netId: 1,
|
||||
pos: { x: 100, y: 200 },
|
||||
custom: {
|
||||
health: 80,
|
||||
mana: 50,
|
||||
buffs: ['speed', 'shield'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 自定义数据也会进行增量压缩
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 合理设置阈值
|
||||
|
||||
```typescript
|
||||
// 高精度游戏(如竞技游戏)
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.001,
|
||||
rotationThreshold: 0.0001,
|
||||
});
|
||||
|
||||
// 普通游戏
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.1,
|
||||
rotationThreshold: 0.01,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 调整完整快照间隔
|
||||
|
||||
```typescript
|
||||
// 高可靠性(网络不稳定)
|
||||
fullSnapshotInterval: 30, // 每 30 帧发送完整快照
|
||||
|
||||
// 低带宽优先
|
||||
fullSnapshotInterval: 120, // 每 120 帧发送完整快照
|
||||
```
|
||||
|
||||
### 3. 配合 AOI 使用
|
||||
|
||||
```typescript
|
||||
// 先用 AOI 过滤,再用增量压缩
|
||||
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
### 4. 处理实体移除
|
||||
|
||||
```typescript
|
||||
// 实体销毁时清理压缩器状态
|
||||
function onEntityDespawn(netId: number) {
|
||||
compressor.removeEntity(netId);
|
||||
}
|
||||
```
|
||||
|
||||
## 与其他功能配合
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 游戏状态 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ AOI 过滤 │ ← 只处理视野内实体
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ 增量压缩 │ ← 只发送变化的字段
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ 网络传输 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
const compressor = createStateDeltaCompressor();
|
||||
|
||||
// 检查压缩效果
|
||||
const original = syncData;
|
||||
const compressed = compressor.compress(original);
|
||||
|
||||
console.log('Original entities:', original.entities.length);
|
||||
console.log('Compressed entities:', compressed.entities.length);
|
||||
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||
|
||||
// 查看每个实体的变化
|
||||
for (const delta of compressed.entities) {
|
||||
console.log(`Entity ${delta.netId}:`, {
|
||||
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -147,7 +147,10 @@ service.on('chat', (data) => {
|
||||
|
||||
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
|
||||
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
|
||||
- [状态同步](/modules/network/sync/) - 插值、预测和快照
|
||||
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
|
||||
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
|
||||
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
|
||||
- [增量压缩](/modules/network/delta/) - 状态增量同步
|
||||
- [API 参考](/modules/network/api/) - 完整 API 文档
|
||||
|
||||
## 服务令牌
|
||||
@@ -159,10 +162,14 @@ import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from '@esengine/network';
|
||||
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
254
docs/src/content/docs/modules/network/prediction.md
Normal file
254
docs/src/content/docs/modules/network/prediction.md
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
title: "客户端预测"
|
||||
description: "本地输入预测和服务器校正"
|
||||
---
|
||||
|
||||
客户端预测是网络游戏中用于减少输入延迟的关键技术。通过在本地立即应用玩家输入,同时等待服务器确认,可以让游戏感觉更加流畅响应。
|
||||
|
||||
## NetworkPredictionSystem
|
||||
|
||||
`NetworkPredictionSystem` 是专门处理本地玩家预测的 ECS 系统。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enablePrediction: true,
|
||||
predictionConfig: {
|
||||
moveSpeed: 200, // 移动速度(单位/秒)
|
||||
maxUnacknowledgedInputs: 60, // 最大未确认输入数
|
||||
reconciliationThreshold: 0.5, // 校正阈值
|
||||
reconciliationSpeed: 10, // 校正速度
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### 设置本地玩家
|
||||
|
||||
当本地玩家实体生成后,需要设置其网络 ID:
|
||||
|
||||
```typescript
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
|
||||
identity.bIsLocalPlayer = identity.bHasAuthority;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
|
||||
// 设置本地玩家用于预测
|
||||
if (identity.bIsLocalPlayer) {
|
||||
networkPlugin.setLocalPlayerNetId(spawn.netId);
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
```
|
||||
|
||||
### 发送输入
|
||||
|
||||
```typescript
|
||||
// 在游戏循环中发送移动输入
|
||||
function onUpdate() {
|
||||
const moveX = Input.getAxis('horizontal');
|
||||
const moveY = Input.getAxis('vertical');
|
||||
|
||||
if (moveX !== 0 || moveY !== 0) {
|
||||
networkPlugin.sendMoveInput(moveX, moveY);
|
||||
}
|
||||
|
||||
// 发送动作输入
|
||||
if (Input.isPressed('attack')) {
|
||||
networkPlugin.sendActionInput('attack');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 预测配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `moveSpeed` | `number` | 200 | 移动速度(单位/秒) |
|
||||
| `enabled` | `boolean` | true | 是否启用预测 |
|
||||
| `maxUnacknowledgedInputs` | `number` | 60 | 最大未确认输入数 |
|
||||
| `reconciliationThreshold` | `number` | 0.5 | 触发校正的位置差异阈值 |
|
||||
| `reconciliationSpeed` | `number` | 10 | 校正平滑速度 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
客户端 服务器
|
||||
│ │
|
||||
├─ 1. 捕获输入 (seq=1) │
|
||||
├─ 2. 本地预测移动 │
|
||||
├─ 3. 发送输入到服务器 ──────────────►
|
||||
│ │
|
||||
├─ 4. 继续捕获输入 (seq=2,3...) │
|
||||
├─ 5. 继续本地预测 │
|
||||
│ │
|
||||
│ ├─ 6. 处理输入 (seq=1)
|
||||
│ │
|
||||
◄──────── 7. 返回状态 (ackSeq=1) ────
|
||||
│ │
|
||||
├─ 8. 比较预测和服务器状态 │
|
||||
├─ 9. 重放 seq=2,3... 的输入 │
|
||||
├─ 10. 平滑校正到正确位置 │
|
||||
│ │
|
||||
```
|
||||
|
||||
### 步骤详解
|
||||
|
||||
1. **输入捕获**:捕获玩家输入并分配序列号
|
||||
2. **本地预测**:立即应用输入到本地状态
|
||||
3. **发送输入**:将输入发送到服务器
|
||||
4. **缓存输入**:保存输入用于后续校正
|
||||
5. **接收确认**:服务器返回权威状态和已确认序列号
|
||||
6. **状态比较**:比较预测状态和服务器状态
|
||||
7. **输入重放**:使用缓存的未确认输入重新计算状态
|
||||
8. **平滑校正**:平滑插值到正确位置
|
||||
|
||||
## 底层 API
|
||||
|
||||
如果需要更细粒度的控制,可以直接使用 `ClientPrediction` 类:
|
||||
|
||||
```typescript
|
||||
import { createClientPrediction, type IPredictor } from '@esengine/network';
|
||||
|
||||
// 定义状态类型
|
||||
interface PlayerState {
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
// 定义输入类型
|
||||
interface PlayerInput {
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
// 定义预测器
|
||||
const predictor: IPredictor<PlayerState, PlayerInput> = {
|
||||
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * dt,
|
||||
y: state.y + input.dy * MOVE_SPEED * dt,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 创建客户端预测
|
||||
const prediction = createClientPrediction(predictor, {
|
||||
maxUnacknowledgedInputs: 60,
|
||||
reconciliationThreshold: 0.5,
|
||||
reconciliationSpeed: 10,
|
||||
});
|
||||
|
||||
// 记录输入并获取预测状态
|
||||
const input = { dx: 1, dy: 0 };
|
||||
const predictedState = prediction.recordInput(input, currentState, deltaTime);
|
||||
|
||||
// 获取要发送的输入
|
||||
const inputToSend = prediction.getInputToSend();
|
||||
|
||||
// 与服务器状态校正
|
||||
prediction.reconcile(
|
||||
serverState,
|
||||
serverAckSeq,
|
||||
(state) => ({ x: state.x, y: state.y }),
|
||||
deltaTime
|
||||
);
|
||||
|
||||
// 获取校正偏移
|
||||
const offset = prediction.correctionOffset;
|
||||
```
|
||||
|
||||
## 启用/禁用预测
|
||||
|
||||
```typescript
|
||||
// 运行时切换预测
|
||||
networkPlugin.setPredictionEnabled(false);
|
||||
|
||||
// 检查预测状态
|
||||
if (networkPlugin.isPredictionEnabled) {
|
||||
console.log('Prediction is active');
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 合理设置校正阈值
|
||||
|
||||
```typescript
|
||||
// 动作游戏:较低阈值,更精确
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 0.1,
|
||||
}
|
||||
|
||||
// 休闲游戏:较高阈值,更平滑
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 1.0,
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 预测仅用于本地玩家
|
||||
|
||||
远程玩家应使用插值而非预测:
|
||||
|
||||
```typescript
|
||||
const identity = entity.getComponent(NetworkIdentity);
|
||||
|
||||
if (identity.bIsLocalPlayer) {
|
||||
// 使用预测系统
|
||||
} else {
|
||||
// 使用 NetworkSyncSystem 的插值
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 处理高延迟
|
||||
|
||||
```typescript
|
||||
// 高延迟网络增加缓冲
|
||||
predictionConfig: {
|
||||
maxUnacknowledgedInputs: 120, // 增加缓冲
|
||||
reconciliationSpeed: 5, // 减慢校正速度
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 确定性预测
|
||||
|
||||
确保客户端和服务器使用相同的物理计算:
|
||||
|
||||
```typescript
|
||||
// 使用固定时间步长
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
|
||||
// 使用固定时间步长而非实际 deltaTime
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
|
||||
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
// 获取预测系统实例
|
||||
const predictionSystem = networkPlugin.predictionSystem;
|
||||
|
||||
if (predictionSystem) {
|
||||
console.log('Pending inputs:', predictionSystem.pendingInputCount);
|
||||
console.log('Current sequence:', predictionSystem.inputSequence);
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user