diff --git a/.changeset/network-enhancement.md b/.changeset/network-enhancement.md new file mode 100644 index 00000000..0979b64a --- /dev/null +++ b/.changeset/network-enhancement.md @@ -0,0 +1,40 @@ +--- +"@esengine/network": minor +--- + +feat(network): 网络模块增强 + +### 新增功能 + +- **客户端预测 (NetworkPredictionSystem)** + - 本地输入预测和服务器校正 + - 平滑的校正偏移应用 + - 可配置移动速度、校正阈值等 + +- **兴趣区域管理 (NetworkAOISystem)** + - 基于网格的 AOI 实现 + - 观察者进入/离开事件 + - 同步数据过滤 + +- **状态增量压缩 (StateDeltaCompressor)** + - 只发送变化的字段 + - 可配置变化阈值 + - 定期完整快照 + +- **断线重连** + - 自动重连机制 + - Token 认证 + - 完整状态恢复 + +### 协议增强 + +- 添加输入序列号和时间戳 +- 添加速度和角速度字段 +- 添加自定义数据字段 +- 新增重连协议 + +### 文档 + +- 添加客户端预测文档(中英文) +- 添加 AOI 文档(中英文) +- 添加增量压缩文档(中英文) diff --git a/docs/src/content/docs/en/modules/network/aoi.md b/docs/src/content/docs/en/modules/network/aoi.md new file mode 100644 index 00000000..54d47ece --- /dev/null +++ b/docs/src/content/docs/en/modules/network/aoi.md @@ -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); +} +``` diff --git a/docs/src/content/docs/en/modules/network/delta.md b/docs/src/content/docs/en/modules/network/delta.md new file mode 100644 index 00000000..d9591683 --- /dev/null +++ b/docs/src/content/docs/en/modules/network/delta.md @@ -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; +} +``` + +### 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; // 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), + }); +} +``` diff --git a/docs/src/content/docs/en/modules/network/index.md b/docs/src/content/docs/en/modules/network/index.md index db40edf1..4660c3e6 100644 --- a/docs/src/content/docs/en/modules/network/index.md +++ b/docs/src/content/docs/en/modules/network/index.md @@ -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 diff --git a/docs/src/content/docs/en/modules/network/prediction.md b/docs/src/content/docs/en/modules/network/prediction.md new file mode 100644 index 00000000..aaf4246d --- /dev/null +++ b/docs/src/content/docs/en/modules/network/prediction.md @@ -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 = { + 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); +} +``` diff --git a/docs/src/content/docs/modules/network/aoi.md b/docs/src/content/docs/modules/network/aoi.md new file mode 100644 index 00000000..31d07ea2 --- /dev/null +++ b/docs/src/content/docs/modules/network/aoi.md @@ -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); +} +``` diff --git a/docs/src/content/docs/modules/network/delta.md b/docs/src/content/docs/modules/network/delta.md new file mode 100644 index 00000000..2d2d799a --- /dev/null +++ b/docs/src/content/docs/modules/network/delta.md @@ -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; +} +``` + +### 增量状态 + +```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; // 仅在 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), + }); +} +``` diff --git a/docs/src/content/docs/modules/network/index.md b/docs/src/content/docs/modules/network/index.md index 7299ccc9..6729496c 100644 --- a/docs/src/content/docs/modules/network/index.md +++ b/docs/src/content/docs/modules/network/index.md @@ -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); ``` ## 蓝图节点 diff --git a/docs/src/content/docs/modules/network/prediction.md b/docs/src/content/docs/modules/network/prediction.md new file mode 100644 index 00000000..3c51982e --- /dev/null +++ b/docs/src/content/docs/modules/network/prediction.md @@ -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 = { + 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); +} +``` diff --git a/packages/framework/network/src/NetworkPlugin.ts b/packages/framework/network/src/NetworkPlugin.ts index c1fb4b9d..d148c2b5 100644 --- a/packages/framework/network/src/NetworkPlugin.ts +++ b/packages/framework/network/src/NetworkPlugin.ts @@ -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 + + /** + * @zh 输入系统配置 + * @en Input system configuration + */ + inputConfig?: Partial + + /** + * @zh 预测系统配置 + * @en Prediction system configuration + */ + predictionConfig?: Partial + + /** + * @zh 是否启用 AOI 兴趣管理 + * @en Whether to enable AOI interest management + */ + enableAOI: boolean + + /** + * @zh AOI 系统配置 + * @en AOI system configuration + */ + aoiConfig?: Partial +} + +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 | null = null + private _lastConnectOptions: ConnectOptions | null = null + + constructor(config?: Partial) { + 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 { + public async connect(options: ConnectOptions): Promise { + 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 { + 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 + } + } } diff --git a/packages/framework/network/src/index.ts b/packages/framework/network/src/index.ts index 1ea3194f..7fbd1e0b 100644 --- a/packages/framework/network/src/index.ts +++ b/packages/framework/network/src/index.ts @@ -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' // ============================================================================ diff --git a/packages/framework/network/src/protocol.ts b/packages/framework/network/src/protocol.ts index 472e0f7d..ca93e114 100644 --- a/packages/framework/network/src/protocol.ts +++ b/packages/framework/network/src/protocol.ts @@ -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 } /** @@ -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 +} + // ============================================================================ // 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(), + + /** + * @zh 重连 + * @en Reconnect + */ + reconnect: rpc.api(), }, msg: { /** @@ -170,6 +304,12 @@ export const gameProtocol = rpc.define({ * @en Entity despawn */ despawn: rpc.msg(), + + /** + * @zh 完整状态快照 + * @en Full state snapshot + */ + fullState: rpc.msg(), }, }) diff --git a/packages/framework/network/src/sync/StateDelta.ts b/packages/framework/network/src/sync/StateDelta.ts new file mode 100644 index 00000000..c6808b88 --- /dev/null +++ b/packages/framework/network/src/sync/StateDelta.ts @@ -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 +} + +/** + * @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 = new Map() + private _frameCounter: number = 0 + + constructor(config?: Partial) { + this._config = { ...DEFAULT_CONFIG, ...config } + } + + /** + * @zh 获取配置 + * @en Get configuration + */ + get config(): Readonly { + 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 | undefined, + b: Record | 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 +): StateDeltaCompressor { + return new StateDeltaCompressor(config) +} diff --git a/packages/framework/network/src/sync/index.ts b/packages/framework/network/src/sync/index.ts index 70be28d5..ead601f1 100644 --- a/packages/framework/network/src/sync/index.ts +++ b/packages/framework/network/src/sync/index.ts @@ -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'; diff --git a/packages/framework/network/src/systems/NetworkAOISystem.ts b/packages/framework/network/src/systems/NetworkAOISystem.ts new file mode 100644 index 00000000..98a6bdb6 --- /dev/null +++ b/packages/framework/network/src/systems/NetworkAOISystem.ts @@ -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 +} + +// ============================================================================= +// 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 = new Map() + private readonly _cells: Map> = new Map() + private readonly _listeners: Set = new Set() + private readonly _entityNetIdMap: Map = new Map() + private readonly _netIdEntityMap: Map = new Map() + + constructor(config?: Partial) { + super(Matcher.all(NetworkIdentity, NetworkTransform)) + this._config = { ...DEFAULT_CONFIG, ...config } + } + + /** + * @zh 获取配置 + * @en Get configuration + */ + get config(): Readonly { + 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() + + // 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 +): NetworkAOISystem { + return new NetworkAOISystem(config) +} diff --git a/packages/framework/network/src/systems/NetworkInputSystem.ts b/packages/framework/network/src/systems/NetworkInputSystem.ts index 8fa81ed7..3c8353fb 100644 --- a/packages/framework/network/src/systems/NetworkInputSystem.ts +++ b/packages/framework/network/src/systems/NetworkInputSystem.ts @@ -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) { super(Matcher.nothing()) this._networkService = networkService + this._config = { ...DEFAULT_CONFIG, ...config } + } + + /** + * @zh 获取配置 + * @en Get configuration + */ + get config(): Readonly { + 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 +): NetworkInputSystem { + return new NetworkInputSystem(networkService, config) +} diff --git a/packages/framework/network/src/systems/NetworkPredictionSystem.ts b/packages/framework/network/src/systems/NetworkPredictionSystem.ts new file mode 100644 index 00000000..7e910505 --- /dev/null +++ b/packages/framework/network/src/systems/NetworkPredictionSystem.ts @@ -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 { + /** + * @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 { + 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 + private _prediction: ClientPrediction | null = null + private _localPlayerNetId: number = -1 + private _currentInput: MovementInput = { x: 0, y: 0 } + private _inputSequence: number = 0 + + constructor(config?: Partial) { + super(Matcher.all(NetworkIdentity, NetworkTransform)) + this._config = { ...DEFAULT_CONFIG, ...config } + this._predictor = new SimpleMovementPredictor(this._config.moveSpeed) + } + + /** + * @zh 获取配置 + * @en Get configuration + */ + get config(): Readonly { + 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( + 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 +): NetworkPredictionSystem { + return new NetworkPredictionSystem(config) +} diff --git a/packages/framework/network/src/systems/NetworkSyncSystem.ts b/packages/framework/network/src/systems/NetworkSyncSystem.ts index dbb160cf..e33e8b85 100644 --- a/packages/framework/network/src/systems/NetworkSyncSystem.ts +++ b/packages/framework/network/src/systems/NetworkSyncSystem.ts @@ -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 + 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 = new Map() + private readonly _netIdToEntity: Map = new Map() + private readonly _entitySnapshots: Map = 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) { 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 { + 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( + 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 = { + 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 | 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() } } diff --git a/packages/framework/network/src/tokens.ts b/packages/framework/network/src/tokens.ts index 17db474e..b89e0b16 100644 --- a/packages/framework/network/src/tokens.ts +++ b/packages/framework/network/src/tokens.ts @@ -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('n * Network input system token */ export const NetworkInputSystemToken = createServiceToken('networkInputSystem'); + +/** + * 网络预测系统令牌 + * Network prediction system token + */ +export const NetworkPredictionSystemToken = createServiceToken('networkPredictionSystem'); + +/** + * 网络 AOI 系统令牌 + * Network AOI system token + */ +export const NetworkAOISystemToken = createServiceToken('networkAOISystem');