feat(network): 网络模块增强 - 预测、AOI、增量压缩 (#379)

- 添加 NetworkPredictionSystem 客户端预测系统
- 添加 NetworkAOISystem 兴趣区域管理
- 添加 StateDeltaCompressor 状态增量压缩
- 添加断线重连和状态恢复
- 增强协议支持时间戳、序列号、速度
- 添加中英文文档
This commit is contained in:
YHH
2025-12-29 10:42:48 +08:00
committed by GitHub
parent 30437dc5d5
commit fb8bde6485
19 changed files with 4125 additions and 51 deletions

View File

@@ -1,13 +1,126 @@
/**
* @zh 网络插件
* @en Network Plugin
*
* @zh 提供基于 @esengine/rpc 的网络同步功能,支持客户端预测和断线重连
* @en Provides @esengine/rpc based network synchronization with client prediction and reconnection
*/
import { type IPlugin, Core, type ServiceContainer, type Scene } from '@esengine/ecs-framework'
import { GameNetworkService, type NetworkServiceOptions } from './services/NetworkService'
import { NetworkSyncSystem } from './systems/NetworkSyncSystem'
import {
GameNetworkService,
type NetworkServiceOptions,
NetworkState,
} from './services/NetworkService'
import { NetworkSyncSystem, type NetworkSyncConfig } from './systems/NetworkSyncSystem'
import { NetworkSpawnSystem, type PrefabFactory } from './systems/NetworkSpawnSystem'
import { NetworkInputSystem } from './systems/NetworkInputSystem'
import { NetworkInputSystem, type NetworkInputConfig } from './systems/NetworkInputSystem'
import {
NetworkPredictionSystem,
type NetworkPredictionConfig,
} from './systems/NetworkPredictionSystem'
import {
NetworkAOISystem,
type NetworkAOIConfig,
} from './systems/NetworkAOISystem'
import type { FullStateData, SyncData } from './protocol'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 网络插件配置
* @en Network plugin configuration
*/
export interface NetworkPluginConfig {
/**
* @zh 是否启用客户端预测
* @en Whether to enable client prediction
*/
enablePrediction: boolean
/**
* @zh 是否启用自动重连
* @en Whether to enable auto reconnection
*/
enableAutoReconnect: boolean
/**
* @zh 重连最大尝试次数
* @en Maximum reconnection attempts
*/
maxReconnectAttempts: number
/**
* @zh 重连间隔(毫秒)
* @en Reconnection interval in milliseconds
*/
reconnectInterval: number
/**
* @zh 同步系统配置
* @en Sync system configuration
*/
syncConfig?: Partial<NetworkSyncConfig>
/**
* @zh 输入系统配置
* @en Input system configuration
*/
inputConfig?: Partial<NetworkInputConfig>
/**
* @zh 预测系统配置
* @en Prediction system configuration
*/
predictionConfig?: Partial<NetworkPredictionConfig>
/**
* @zh 是否启用 AOI 兴趣管理
* @en Whether to enable AOI interest management
*/
enableAOI: boolean
/**
* @zh AOI 系统配置
* @en AOI system configuration
*/
aoiConfig?: Partial<NetworkAOIConfig>
}
const DEFAULT_CONFIG: NetworkPluginConfig = {
enablePrediction: true,
enableAutoReconnect: true,
maxReconnectAttempts: 5,
reconnectInterval: 2000,
enableAOI: false,
}
/**
* @zh 连接选项
* @en Connection options
*/
export interface ConnectOptions extends NetworkServiceOptions {
playerName: string
roomId?: string
}
/**
* @zh 重连状态
* @en Reconnection state
*/
interface ReconnectState {
token: string
playerId: number
roomId: string
attempts: number
isReconnecting: boolean
}
// =============================================================================
// NetworkPlugin | 网络插件
// =============================================================================
/**
* @zh 网络插件
@@ -21,7 +134,10 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
* import { Core } from '@esengine/ecs-framework'
* import { NetworkPlugin } from '@esengine/network'
*
* const networkPlugin = new NetworkPlugin()
* const networkPlugin = new NetworkPlugin({
* enablePrediction: true,
* enableAutoReconnect: true
* })
* await Core.installPlugin(networkPlugin)
*
* // 连接到服务器
@@ -36,13 +152,28 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
*/
export class NetworkPlugin implements IPlugin {
public readonly name = '@esengine/network'
public readonly version = '2.0.0'
public readonly version = '2.1.0'
private readonly _config: NetworkPluginConfig
private _networkService!: GameNetworkService
private _syncSystem!: NetworkSyncSystem
private _spawnSystem!: NetworkSpawnSystem
private _inputSystem!: NetworkInputSystem
private _predictionSystem: NetworkPredictionSystem | null = null
private _aoiSystem: NetworkAOISystem | null = null
private _localPlayerId: number = 0
private _reconnectState: ReconnectState | null = null
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
private _lastConnectOptions: ConnectOptions | null = null
constructor(config?: Partial<NetworkPluginConfig>) {
this._config = { ...DEFAULT_CONFIG, ...config }
}
// =========================================================================
// Getters | 属性访问器
// =========================================================================
/**
* @zh 网络服务
@@ -76,6 +207,22 @@ export class NetworkPlugin implements IPlugin {
return this._inputSystem
}
/**
* @zh 预测系统
* @en Prediction system
*/
get predictionSystem(): NetworkPredictionSystem | null {
return this._predictionSystem
}
/**
* @zh AOI 系统
* @en AOI system
*/
get aoiSystem(): NetworkAOISystem | null {
return this._aoiSystem
}
/**
* @zh 本地玩家 ID
* @en Local player ID
@@ -92,6 +239,34 @@ export class NetworkPlugin implements IPlugin {
return this._networkService?.isConnected ?? false
}
/**
* @zh 是否正在重连
* @en Is reconnecting
*/
get isReconnecting(): boolean {
return this._reconnectState?.isReconnecting ?? false
}
/**
* @zh 是否启用预测
* @en Is prediction enabled
*/
get isPredictionEnabled(): boolean {
return this._config.enablePrediction && this._predictionSystem !== null
}
/**
* @zh 是否启用 AOI
* @en Is AOI enabled
*/
get isAOIEnabled(): boolean {
return this._config.enableAOI && this._aoiSystem !== null
}
// =========================================================================
// Plugin Lifecycle | 插件生命周期
// =========================================================================
/**
* @zh 安装插件
* @en Install plugin
@@ -110,13 +285,28 @@ export class NetworkPlugin implements IPlugin {
* @en Uninstall plugin
*/
uninstall(): void {
this._clearReconnectTimer()
this._networkService?.disconnect()
}
private _setupSystems(scene: Scene): void {
this._syncSystem = new NetworkSyncSystem()
// Create systems
this._syncSystem = new NetworkSyncSystem(this._config.syncConfig)
this._spawnSystem = new NetworkSpawnSystem(this._syncSystem)
this._inputSystem = new NetworkInputSystem(this._networkService)
this._inputSystem = new NetworkInputSystem(this._networkService, this._config.inputConfig)
// Create prediction system if enabled
if (this._config.enablePrediction) {
this._predictionSystem = new NetworkPredictionSystem(this._config.predictionConfig)
this._inputSystem.setPredictionSystem(this._predictionSystem)
scene.addSystem(this._predictionSystem)
}
// Create AOI system if enabled
if (this._config.enableAOI) {
this._aoiSystem = new NetworkAOISystem(this._config.aoiConfig)
scene.addSystem(this._aoiSystem)
}
scene.addSystem(this._syncSystem)
scene.addSystem(this._spawnSystem)
@@ -127,8 +317,14 @@ export class NetworkPlugin implements IPlugin {
private _setupMessageHandlers(): void {
this._networkService
.onSync((data) => {
this._syncSystem.handleSync({ entities: data.entities })
.onSync((data: SyncData) => {
// Use new sync handler with timestamps
this._syncSystem.handleSyncData(data)
// Reconcile prediction if enabled
if (this._predictionSystem) {
this._predictionSystem.reconcileWithServer(data)
}
})
.onSpawn((data) => {
this._spawnSystem.handleSpawn(data)
@@ -136,14 +332,32 @@ export class NetworkPlugin implements IPlugin {
.onDespawn((data) => {
this._spawnSystem.handleDespawn(data)
})
// Handle full state for reconnection
this._networkService.on('fullState', (data: FullStateData) => {
this._handleFullState(data)
})
}
// =========================================================================
// Connection | 连接管理
// =========================================================================
/**
* @zh 连接到服务器
* @en Connect to server
*/
public async connect(options: NetworkServiceOptions & { playerName: string; roomId?: string }): Promise<boolean> {
public async connect(options: ConnectOptions): Promise<boolean> {
this._lastConnectOptions = options
try {
// Setup disconnect handler for auto-reconnect
const originalOnDisconnect = options.onDisconnect
options.onDisconnect = (reason) => {
originalOnDisconnect?.(reason)
this._handleDisconnect(reason)
}
await this._networkService.connect(options)
const result = await this._networkService.call('join', {
@@ -154,8 +368,25 @@ export class NetworkPlugin implements IPlugin {
this._localPlayerId = result.playerId
this._spawnSystem.setLocalPlayerId(this._localPlayerId)
// Setup prediction for local player
if (this._predictionSystem) {
// Will be set when local player entity is spawned
}
// Save reconnect state
if (this._config.enableAutoReconnect) {
this._reconnectState = {
token: this._generateReconnectToken(),
playerId: result.playerId,
roomId: result.roomId,
attempts: 0,
isReconnecting: false,
}
}
return true
} catch (err) {
console.error('[NetworkPlugin] Connection failed:', err)
return false
}
}
@@ -165,14 +396,114 @@ export class NetworkPlugin implements IPlugin {
* @en Disconnect
*/
public async disconnect(): Promise<void> {
this._clearReconnectTimer()
this._reconnectState = null
try {
await this._networkService.call('leave', undefined)
} catch {
// ignore
}
this._networkService.disconnect()
this._cleanup()
}
private _handleDisconnect(reason?: string): void {
console.log('[NetworkPlugin] Disconnected:', reason)
if (this._config.enableAutoReconnect && this._reconnectState && !this._reconnectState.isReconnecting) {
this._attemptReconnect()
}
}
private _attemptReconnect(): void {
if (!this._reconnectState || !this._lastConnectOptions) return
if (this._reconnectState.attempts >= this._config.maxReconnectAttempts) {
console.error('[NetworkPlugin] Max reconnection attempts reached')
this._reconnectState = null
return
}
this._reconnectState.isReconnecting = true
this._reconnectState.attempts++
console.log(`[NetworkPlugin] Attempting reconnection (${this._reconnectState.attempts}/${this._config.maxReconnectAttempts})`)
this._reconnectTimer = setTimeout(async () => {
try {
await this._networkService.connect(this._lastConnectOptions!)
const result = await this._networkService.call('reconnect', {
playerId: this._reconnectState!.playerId,
roomId: this._reconnectState!.roomId,
token: this._reconnectState!.token,
})
if (result.success) {
console.log('[NetworkPlugin] Reconnection successful')
this._reconnectState!.isReconnecting = false
this._reconnectState!.attempts = 0
// Restore state
if (result.state) {
this._handleFullState(result.state)
}
} else {
console.error('[NetworkPlugin] Reconnection rejected:', result.error)
this._attemptReconnect()
}
} catch (err) {
console.error('[NetworkPlugin] Reconnection failed:', err)
if (this._reconnectState) {
this._reconnectState.isReconnecting = false
}
this._attemptReconnect()
}
}, this._config.reconnectInterval)
}
private _handleFullState(data: FullStateData): void {
// Clear existing entities
this._syncSystem.clearSnapshots()
// Spawn all entities from full state
for (const entityData of data.entities) {
this._spawnSystem.handleSpawn(entityData)
// Apply initial state if available
if (entityData.state) {
this._syncSystem.handleSyncData({
frame: data.frame,
timestamp: data.timestamp,
entities: [entityData.state],
})
}
}
}
private _clearReconnectTimer(): void {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer)
this._reconnectTimer = null
}
}
private _generateReconnectToken(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
}
private _cleanup(): void {
this._localPlayerId = 0
this._syncSystem?.clearSnapshots()
this._predictionSystem?.reset()
this._inputSystem?.reset()
}
// =========================================================================
// Game API | 游戏接口
// =========================================================================
/**
* @zh 注册预制体工厂
* @en Register prefab factory
@@ -196,4 +527,78 @@ export class NetworkPlugin implements IPlugin {
public sendActionInput(action: string): void {
this._inputSystem?.addActionInput(action)
}
/**
* @zh 设置本地玩家网络 ID用于预测
* @en Set local player network ID (for prediction)
*/
public setLocalPlayerNetId(netId: number): void {
if (this._predictionSystem) {
this._predictionSystem.setLocalPlayerNetId(netId)
}
}
/**
* @zh 启用/禁用预测
* @en Enable/disable prediction
*/
public setPredictionEnabled(enabled: boolean): void {
if (this._predictionSystem) {
this._predictionSystem.enabled = enabled
}
}
// =========================================================================
// AOI API | AOI 接口
// =========================================================================
/**
* @zh 添加 AOI 观察者(玩家)
* @en Add AOI observer (player)
*/
public addAOIObserver(netId: number, x: number, y: number, viewRange?: number): void {
this._aoiSystem?.addObserver(netId, x, y, viewRange)
}
/**
* @zh 移除 AOI 观察者
* @en Remove AOI observer
*/
public removeAOIObserver(netId: number): void {
this._aoiSystem?.removeObserver(netId)
}
/**
* @zh 更新 AOI 观察者位置
* @en Update AOI observer position
*/
public updateAOIObserverPosition(netId: number, x: number, y: number): void {
this._aoiSystem?.updateObserverPosition(netId, x, y)
}
/**
* @zh 获取观察者可见的实体
* @en Get entities visible to observer
*/
public getVisibleEntities(observerNetId: number): number[] {
return this._aoiSystem?.getVisibleEntities(observerNetId) ?? []
}
/**
* @zh 检查是否可见
* @en Check if visible
*/
public canSee(observerNetId: number, targetNetId: number): boolean {
return this._aoiSystem?.canSee(observerNetId, targetNetId) ?? true
}
/**
* @zh 启用/禁用 AOI
* @en Enable/disable AOI
*/
public setAOIEnabled(enabled: boolean): void {
if (this._aoiSystem) {
this._aoiSystem.enabled = enabled
}
}
}

View File

@@ -35,8 +35,11 @@ export {
type SyncData,
type SpawnData,
type DespawnData,
type FullStateData,
type JoinRequest,
type JoinResponse,
type ReconnectRequest,
type ReconnectResponse,
} from './protocol'
// ============================================================================
@@ -48,6 +51,8 @@ export {
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken,
NetworkPredictionSystemToken,
NetworkAOISystemToken,
} from './tokens'
// ============================================================================
@@ -81,10 +86,30 @@ export { NetworkTransform } from './components/NetworkTransform'
// ============================================================================
export { NetworkSyncSystem } from './systems/NetworkSyncSystem'
export type { SyncMessage } from './systems/NetworkSyncSystem'
export type { SyncMessage, NetworkSyncConfig } from './systems/NetworkSyncSystem'
export { NetworkSpawnSystem } from './systems/NetworkSpawnSystem'
export type { PrefabFactory, SpawnMessage, DespawnMessage } from './systems/NetworkSpawnSystem'
export { NetworkInputSystem } from './systems/NetworkInputSystem'
export { NetworkInputSystem, createNetworkInputSystem } from './systems/NetworkInputSystem'
export type { NetworkInputConfig } from './systems/NetworkInputSystem'
export {
NetworkPredictionSystem,
createNetworkPredictionSystem,
} from './systems/NetworkPredictionSystem'
export type {
NetworkPredictionConfig,
MovementInput,
PredictedTransform,
} from './systems/NetworkPredictionSystem'
export {
NetworkAOISystem,
createNetworkAOISystem,
} from './systems/NetworkAOISystem'
export type {
NetworkAOIConfig,
NetworkAOIEvent,
NetworkAOIEventType,
NetworkAOIEventListener,
} from './systems/NetworkAOISystem'
// ============================================================================
// State Sync | 状态同步
@@ -105,6 +130,9 @@ export type {
IPredictedState,
IPredictor,
ClientPredictionConfig,
EntityDeltaState,
DeltaSyncData,
DeltaCompressionConfig,
} from './sync'
export {
@@ -119,6 +147,9 @@ export {
createHermiteTransformInterpolator,
ClientPrediction,
createClientPrediction,
DeltaFlags,
StateDeltaCompressor,
createStateDeltaCompressor,
} from './sync'
// ============================================================================

View File

@@ -17,12 +17,24 @@ import { rpc } from '@esengine/rpc'
* @en Player input
*/
export interface PlayerInput {
/**
* @zh 输入序列号(用于客户端预测)
* @en Input sequence number (for client prediction)
*/
seq: number
/**
* @zh 帧序号
* @en Frame number
*/
frame: number
/**
* @zh 客户端时间戳
* @en Client timestamp
*/
timestamp: number
/**
* @zh 移动方向
* @en Move direction
@@ -41,9 +53,41 @@ export interface PlayerInput {
* @en Entity sync state
*/
export interface EntitySyncState {
/**
* @zh 网络实体 ID
* @en Network entity ID
*/
netId: number
/**
* @zh 位置
* @en Position
*/
pos?: { x: number; y: number }
/**
* @zh 旋转角度
* @en Rotation angle
*/
rot?: number
/**
* @zh 速度(用于外推)
* @en Velocity (for extrapolation)
*/
vel?: { x: number; y: number }
/**
* @zh 角速度
* @en Angular velocity
*/
angVel?: number
/**
* @zh 自定义数据
* @en Custom data
*/
custom?: Record<string, unknown>
}
/**
@@ -57,6 +101,18 @@ export interface SyncData {
*/
frame: number
/**
* @zh 服务器时间戳(用于插值)
* @en Server timestamp (for interpolation)
*/
timestamp: number
/**
* @zh 已确认的输入序列号(用于客户端预测校正)
* @en Acknowledged input sequence (for client prediction reconciliation)
*/
ackSeq?: number
/**
* @zh 实体状态列表
* @en Entity state list
@@ -84,6 +140,30 @@ export interface DespawnData {
netId: number
}
/**
* @zh 完整状态快照(用于重连)
* @en Full state snapshot (for reconnection)
*/
export interface FullStateData {
/**
* @zh 服务器帧号
* @en Server frame number
*/
frame: number
/**
* @zh 服务器时间戳
* @en Server timestamp
*/
timestamp: number
/**
* @zh 所有实体状态
* @en All entity states
*/
entities: Array<SpawnData & { state?: EntitySyncState }>
}
// ============================================================================
// API Types | API 类型
// ============================================================================
@@ -106,6 +186,54 @@ export interface JoinResponse {
roomId: string
}
/**
* @zh 重连请求
* @en Reconnect request
*/
export interface ReconnectRequest {
/**
* @zh 之前的玩家 ID
* @en Previous player ID
*/
playerId: number
/**
* @zh 房间 ID
* @en Room ID
*/
roomId: string
/**
* @zh 重连令牌
* @en Reconnection token
*/
token: string
}
/**
* @zh 重连响应
* @en Reconnect response
*/
export interface ReconnectResponse {
/**
* @zh 是否成功
* @en Whether successful
*/
success: boolean
/**
* @zh 完整状态(成功时)
* @en Full state (when successful)
*/
state?: FullStateData
/**
* @zh 错误信息(失败时)
* @en Error message (when failed)
*/
error?: string
}
// ============================================================================
// Protocol Definition | 协议定义
// ============================================================================
@@ -145,6 +273,12 @@ export const gameProtocol = rpc.define({
* @en Leave room
*/
leave: rpc.api<void, void>(),
/**
* @zh 重连
* @en Reconnect
*/
reconnect: rpc.api<ReconnectRequest, ReconnectResponse>(),
},
msg: {
/**
@@ -170,6 +304,12 @@ export const gameProtocol = rpc.define({
* @en Entity despawn
*/
despawn: rpc.msg<DespawnData>(),
/**
* @zh 完整状态快照
* @en Full state snapshot
*/
fullState: rpc.msg<FullStateData>(),
},
})

View File

@@ -0,0 +1,440 @@
/**
* @zh 状态增量压缩
* @en State Delta Compression
*
* @zh 通过只发送变化的字段来减少网络带宽
* @en Reduces network bandwidth by only sending changed fields
*/
import type { EntitySyncState, SyncData } from '../protocol'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 增量类型标志
* @en Delta type flags
*/
export const DeltaFlags = {
NONE: 0,
POSITION: 1 << 0,
ROTATION: 1 << 1,
VELOCITY: 1 << 2,
ANGULAR_VELOCITY: 1 << 3,
CUSTOM: 1 << 4,
} as const
/**
* @zh 增量状态(只包含变化的字段)
* @en Delta state (only contains changed fields)
*/
export interface EntityDeltaState {
/**
* @zh 网络标识
* @en Network identity
*/
netId: number
/**
* @zh 变化标志
* @en Change flags
*/
flags: number
/**
* @zh 位置(如果变化)
* @en Position (if changed)
*/
pos?: { x: number; y: number }
/**
* @zh 旋转(如果变化)
* @en Rotation (if changed)
*/
rot?: number
/**
* @zh 速度(如果变化)
* @en Velocity (if changed)
*/
vel?: { x: number; y: number }
/**
* @zh 角速度(如果变化)
* @en Angular velocity (if changed)
*/
angVel?: number
/**
* @zh 自定义数据(如果变化)
* @en Custom data (if changed)
*/
custom?: Record<string, unknown>
}
/**
* @zh 增量同步数据
* @en Delta sync data
*/
export interface DeltaSyncData {
/**
* @zh 帧号
* @en Frame number
*/
frame: number
/**
* @zh 时间戳
* @en Timestamp
*/
timestamp: number
/**
* @zh 已确认的输入序列号
* @en Acknowledged input sequence
*/
ackSeq?: number
/**
* @zh 增量实体状态
* @en Delta entity states
*/
entities: EntityDeltaState[]
/**
* @zh 是否为完整快照
* @en Whether this is a full snapshot
*/
isFullSnapshot?: boolean
}
/**
* @zh 增量压缩配置
* @en Delta compression configuration
*/
export interface DeltaCompressionConfig {
/**
* @zh 位置变化阈值
* @en Position change threshold
*/
positionThreshold: number
/**
* @zh 旋转变化阈值(弧度)
* @en Rotation change threshold (radians)
*/
rotationThreshold: number
/**
* @zh 速度变化阈值
* @en Velocity change threshold
*/
velocityThreshold: number
/**
* @zh 强制完整快照间隔(帧数)
* @en Forced full snapshot interval (frames)
*/
fullSnapshotInterval: number
}
const DEFAULT_CONFIG: DeltaCompressionConfig = {
positionThreshold: 0.01,
rotationThreshold: 0.001,
velocityThreshold: 0.1,
fullSnapshotInterval: 60,
}
// =============================================================================
// StateDeltaCompressor | 状态增量压缩器
// =============================================================================
/**
* @zh 状态增量压缩器
* @en State delta compressor
*
* @zh 追踪实体状态变化,生成增量更新
* @en Tracks entity state changes and generates delta updates
*/
export class StateDeltaCompressor {
private readonly _config: DeltaCompressionConfig
private readonly _lastStates: Map<number, EntitySyncState> = new Map()
private _frameCounter: number = 0
constructor(config?: Partial<DeltaCompressionConfig>) {
this._config = { ...DEFAULT_CONFIG, ...config }
}
/**
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<DeltaCompressionConfig> {
return this._config
}
/**
* @zh 压缩同步数据为增量格式
* @en Compress sync data to delta format
*/
compress(data: SyncData): DeltaSyncData {
this._frameCounter++
const isFullSnapshot = this._frameCounter % this._config.fullSnapshotInterval === 0
const deltaEntities: EntityDeltaState[] = []
for (const entity of data.entities) {
const lastState = this._lastStates.get(entity.netId)
if (isFullSnapshot || !lastState) {
// Send full state
deltaEntities.push(this._createFullDelta(entity))
} else {
// Calculate delta
const delta = this._calculateDelta(lastState, entity)
if (delta) {
deltaEntities.push(delta)
}
}
// Update last state
this._lastStates.set(entity.netId, { ...entity })
}
return {
frame: data.frame,
timestamp: data.timestamp,
ackSeq: data.ackSeq,
entities: deltaEntities,
isFullSnapshot,
}
}
/**
* @zh 解压增量数据为完整同步数据
* @en Decompress delta data to full sync data
*/
decompress(data: DeltaSyncData): SyncData {
const entities: EntitySyncState[] = []
for (const delta of data.entities) {
const lastState = this._lastStates.get(delta.netId)
const fullState = this._applyDelta(lastState, delta)
entities.push(fullState)
// Update last state
this._lastStates.set(delta.netId, fullState)
}
return {
frame: data.frame,
timestamp: data.timestamp,
ackSeq: data.ackSeq,
entities,
}
}
/**
* @zh 移除实体状态
* @en Remove entity state
*/
removeEntity(netId: number): void {
this._lastStates.delete(netId)
}
/**
* @zh 清除所有状态
* @en Clear all states
*/
clear(): void {
this._lastStates.clear()
this._frameCounter = 0
}
/**
* @zh 强制下一次发送完整快照
* @en Force next send to be a full snapshot
*/
forceFullSnapshot(): void {
this._frameCounter = this._config.fullSnapshotInterval - 1
}
// =========================================================================
// 私有方法 | Private Methods
// =========================================================================
private _createFullDelta(entity: EntitySyncState): EntityDeltaState {
let flags = 0
if (entity.pos) flags |= DeltaFlags.POSITION
if (entity.rot !== undefined) flags |= DeltaFlags.ROTATION
if (entity.vel) flags |= DeltaFlags.VELOCITY
if (entity.angVel !== undefined) flags |= DeltaFlags.ANGULAR_VELOCITY
if (entity.custom) flags |= DeltaFlags.CUSTOM
return {
netId: entity.netId,
flags,
pos: entity.pos,
rot: entity.rot,
vel: entity.vel,
angVel: entity.angVel,
custom: entity.custom,
}
}
private _calculateDelta(
lastState: EntitySyncState,
currentState: EntitySyncState
): EntityDeltaState | null {
let flags = 0
const delta: EntityDeltaState = {
netId: currentState.netId,
flags: 0,
}
// Check position change
if (currentState.pos) {
const posChanged = !lastState.pos ||
Math.abs(currentState.pos.x - lastState.pos.x) > this._config.positionThreshold ||
Math.abs(currentState.pos.y - lastState.pos.y) > this._config.positionThreshold
if (posChanged) {
flags |= DeltaFlags.POSITION
delta.pos = currentState.pos
}
}
// Check rotation change
if (currentState.rot !== undefined) {
const rotChanged = lastState.rot === undefined ||
Math.abs(currentState.rot - lastState.rot) > this._config.rotationThreshold
if (rotChanged) {
flags |= DeltaFlags.ROTATION
delta.rot = currentState.rot
}
}
// Check velocity change
if (currentState.vel) {
const velChanged = !lastState.vel ||
Math.abs(currentState.vel.x - lastState.vel.x) > this._config.velocityThreshold ||
Math.abs(currentState.vel.y - lastState.vel.y) > this._config.velocityThreshold
if (velChanged) {
flags |= DeltaFlags.VELOCITY
delta.vel = currentState.vel
}
}
// Check angular velocity change
if (currentState.angVel !== undefined) {
const angVelChanged = lastState.angVel === undefined ||
Math.abs(currentState.angVel - lastState.angVel) > this._config.velocityThreshold
if (angVelChanged) {
flags |= DeltaFlags.ANGULAR_VELOCITY
delta.angVel = currentState.angVel
}
}
// Check custom data change (simple reference comparison)
if (currentState.custom) {
const customChanged = !this._customDataEqual(lastState.custom, currentState.custom)
if (customChanged) {
flags |= DeltaFlags.CUSTOM
delta.custom = currentState.custom
}
}
// Return null if no changes
if (flags === 0) {
return null
}
delta.flags = flags
return delta
}
private _applyDelta(
lastState: EntitySyncState | undefined,
delta: EntityDeltaState
): EntitySyncState {
const state: EntitySyncState = {
netId: delta.netId,
}
// Apply position
if (delta.flags & DeltaFlags.POSITION) {
state.pos = delta.pos
} else if (lastState?.pos) {
state.pos = lastState.pos
}
// Apply rotation
if (delta.flags & DeltaFlags.ROTATION) {
state.rot = delta.rot
} else if (lastState?.rot !== undefined) {
state.rot = lastState.rot
}
// Apply velocity
if (delta.flags & DeltaFlags.VELOCITY) {
state.vel = delta.vel
} else if (lastState?.vel) {
state.vel = lastState.vel
}
// Apply angular velocity
if (delta.flags & DeltaFlags.ANGULAR_VELOCITY) {
state.angVel = delta.angVel
} else if (lastState?.angVel !== undefined) {
state.angVel = lastState.angVel
}
// Apply custom data
if (delta.flags & DeltaFlags.CUSTOM) {
state.custom = delta.custom
} else if (lastState?.custom) {
state.custom = lastState.custom
}
return state
}
private _customDataEqual(
a: Record<string, unknown> | undefined,
b: Record<string, unknown> | undefined
): boolean {
if (a === b) return true
if (!a || !b) return false
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
for (const key of keysA) {
if (a[key] !== b[key]) return false
}
return true
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建状态增量压缩器
* @en Create state delta compressor
*/
export function createStateDeltaCompressor(
config?: Partial<DeltaCompressionConfig>
): StateDeltaCompressor {
return new StateDeltaCompressor(config)
}

View File

@@ -46,3 +46,19 @@ export type {
} from './ClientPrediction';
export { ClientPrediction, createClientPrediction } from './ClientPrediction';
// =============================================================================
// 状态增量压缩 | State Delta Compression
// =============================================================================
export type {
EntityDeltaState,
DeltaSyncData,
DeltaCompressionConfig
} from './StateDelta';
export {
DeltaFlags,
StateDeltaCompressor,
createStateDeltaCompressor
} from './StateDelta';

View File

@@ -0,0 +1,500 @@
/**
* @zh 网络 AOI 系统
* @en Network AOI System
*
* @zh 集成 AOI 兴趣区域管理,过滤网络同步数据
* @en Integrates AOI interest management to filter network sync data
*/
import { EntitySystem, Matcher, type Entity } from '@esengine/ecs-framework'
import { NetworkIdentity } from '../components/NetworkIdentity'
import { NetworkTransform } from '../components/NetworkTransform'
import type { EntitySyncState } from '../protocol'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh AOI 事件类型
* @en AOI event type
*/
export type NetworkAOIEventType = 'enter' | 'exit'
/**
* @zh AOI 事件
* @en AOI event
*/
export interface NetworkAOIEvent {
/**
* @zh 事件类型
* @en Event type
*/
type: NetworkAOIEventType
/**
* @zh 观察者网络 ID玩家
* @en Observer network ID (player)
*/
observerNetId: number
/**
* @zh 目标网络 ID进入/离开视野的实体)
* @en Target network ID (entity entering/exiting view)
*/
targetNetId: number
}
/**
* @zh AOI 事件监听器
* @en AOI event listener
*/
export type NetworkAOIEventListener = (event: NetworkAOIEvent) => void
/**
* @zh 网络 AOI 配置
* @en Network AOI configuration
*/
export interface NetworkAOIConfig {
/**
* @zh 网格单元格大小
* @en Grid cell size
*/
cellSize: number
/**
* @zh 默认视野范围
* @en Default view range
*/
defaultViewRange: number
/**
* @zh 是否启用 AOI 过滤
* @en Whether to enable AOI filtering
*/
enabled: boolean
}
const DEFAULT_CONFIG: NetworkAOIConfig = {
cellSize: 100,
defaultViewRange: 500,
enabled: true,
}
/**
* @zh 观察者数据
* @en Observer data
*/
interface ObserverData {
netId: number
position: { x: number; y: number }
viewRange: number
viewRangeSq: number
cellKey: string
visibleEntities: Set<number>
}
// =============================================================================
// NetworkAOISystem | 网络 AOI 系统
// =============================================================================
/**
* @zh 网络 AOI 系统
* @en Network AOI system
*
* @zh 管理网络实体的兴趣区域,过滤同步数据
* @en Manages network entities' areas of interest and filters sync data
*/
export class NetworkAOISystem extends EntitySystem {
private readonly _config: NetworkAOIConfig
private readonly _observers: Map<number, ObserverData> = new Map()
private readonly _cells: Map<string, Set<number>> = new Map()
private readonly _listeners: Set<NetworkAOIEventListener> = new Set()
private readonly _entityNetIdMap: Map<Entity, number> = new Map()
private readonly _netIdEntityMap: Map<number, Entity> = new Map()
constructor(config?: Partial<NetworkAOIConfig>) {
super(Matcher.all(NetworkIdentity, NetworkTransform))
this._config = { ...DEFAULT_CONFIG, ...config }
}
/**
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<NetworkAOIConfig> {
return this._config
}
/**
* @zh 是否启用
* @en Is enabled
*/
get enabled(): boolean {
return this._config.enabled
}
set enabled(value: boolean) {
this._config.enabled = value
}
/**
* @zh 观察者数量
* @en Observer count
*/
get observerCount(): number {
return this._observers.size
}
// =========================================================================
// 观察者管理 | Observer Management
// =========================================================================
/**
* @zh 添加观察者(通常是玩家实体)
* @en Add observer (usually player entity)
*/
addObserver(netId: number, x: number, y: number, viewRange?: number): void {
if (this._observers.has(netId)) {
this.updateObserverPosition(netId, x, y)
return
}
const range = viewRange ?? this._config.defaultViewRange
const cellKey = this._getCellKey(x, y)
const data: ObserverData = {
netId,
position: { x, y },
viewRange: range,
viewRangeSq: range * range,
cellKey,
visibleEntities: new Set(),
}
this._observers.set(netId, data)
this._addToCell(cellKey, netId)
this._updateVisibility(data)
}
/**
* @zh 移除观察者
* @en Remove observer
*/
removeObserver(netId: number): boolean {
const data = this._observers.get(netId)
if (!data) return false
// Emit exit events for all visible entities
for (const visibleNetId of data.visibleEntities) {
this._emitEvent({
type: 'exit',
observerNetId: netId,
targetNetId: visibleNetId,
})
}
this._removeFromCell(data.cellKey, netId)
this._observers.delete(netId)
return true
}
/**
* @zh 更新观察者位置
* @en Update observer position
*/
updateObserverPosition(netId: number, x: number, y: number): void {
const data = this._observers.get(netId)
if (!data) return
const newCellKey = this._getCellKey(x, y)
if (newCellKey !== data.cellKey) {
this._removeFromCell(data.cellKey, netId)
data.cellKey = newCellKey
this._addToCell(newCellKey, netId)
}
data.position.x = x
data.position.y = y
this._updateVisibility(data)
}
/**
* @zh 更新观察者视野范围
* @en Update observer view range
*/
updateObserverViewRange(netId: number, viewRange: number): void {
const data = this._observers.get(netId)
if (!data) return
data.viewRange = viewRange
data.viewRangeSq = viewRange * viewRange
this._updateVisibility(data)
}
// =========================================================================
// 实体管理 | Entity Management
// =========================================================================
/**
* @zh 注册网络实体
* @en Register network entity
*/
registerEntity(entity: Entity, netId: number): void {
this._entityNetIdMap.set(entity, netId)
this._netIdEntityMap.set(netId, entity)
}
/**
* @zh 注销网络实体
* @en Unregister network entity
*/
unregisterEntity(entity: Entity): void {
const netId = this._entityNetIdMap.get(entity)
if (netId !== undefined) {
// Remove from all observers' visible sets
for (const [, data] of this._observers) {
if (data.visibleEntities.has(netId)) {
data.visibleEntities.delete(netId)
this._emitEvent({
type: 'exit',
observerNetId: data.netId,
targetNetId: netId,
})
}
}
this._netIdEntityMap.delete(netId)
}
this._entityNetIdMap.delete(entity)
}
// =========================================================================
// 查询接口 | Query Interface
// =========================================================================
/**
* @zh 获取观察者能看到的实体网络 ID 列表
* @en Get list of entity network IDs visible to observer
*/
getVisibleEntities(observerNetId: number): number[] {
const data = this._observers.get(observerNetId)
return data ? Array.from(data.visibleEntities) : []
}
/**
* @zh 获取能看到指定实体的观察者网络 ID 列表
* @en Get list of observer network IDs that can see the entity
*/
getObserversOf(entityNetId: number): number[] {
const observers: number[] = []
for (const [, data] of this._observers) {
if (data.visibleEntities.has(entityNetId)) {
observers.push(data.netId)
}
}
return observers
}
/**
* @zh 检查观察者是否能看到目标
* @en Check if observer can see target
*/
canSee(observerNetId: number, targetNetId: number): boolean {
const data = this._observers.get(observerNetId)
return data?.visibleEntities.has(targetNetId) ?? false
}
/**
* @zh 过滤同步数据,只保留观察者能看到的实体
* @en Filter sync data to only include entities visible to observer
*/
filterSyncData(observerNetId: number, entities: EntitySyncState[]): EntitySyncState[] {
if (!this._config.enabled) {
return entities
}
const data = this._observers.get(observerNetId)
if (!data) {
return entities
}
return entities.filter(entity => {
// Always include the observer's own entity
if (entity.netId === observerNetId) return true
// Include entities in view
return data.visibleEntities.has(entity.netId)
})
}
// =========================================================================
// 事件系统 | Event System
// =========================================================================
/**
* @zh 添加事件监听器
* @en Add event listener
*/
addListener(listener: NetworkAOIEventListener): void {
this._listeners.add(listener)
}
/**
* @zh 移除事件监听器
* @en Remove event listener
*/
removeListener(listener: NetworkAOIEventListener): void {
this._listeners.delete(listener)
}
// =========================================================================
// 系统生命周期 | System Lifecycle
// =========================================================================
protected override process(entities: readonly Entity[]): void {
if (!this._config.enabled) return
// Update entity positions for AOI calculations
for (const entity of entities) {
const identity = this.requireComponent(entity, NetworkIdentity)
const transform = this.requireComponent(entity, NetworkTransform)
// Register entity if not already registered
if (!this._entityNetIdMap.has(entity)) {
this.registerEntity(entity, identity.netId)
}
// If this entity is an observer (has authority), update its position
if (identity.bHasAuthority && this._observers.has(identity.netId)) {
this.updateObserverPosition(
identity.netId,
transform.currentX,
transform.currentY
)
}
}
// Update all observers' visibility based on entity positions
this._updateAllObserversVisibility(entities)
}
private _updateAllObserversVisibility(entities: readonly Entity[]): void {
for (const [, data] of this._observers) {
const newVisible = new Set<number>()
// Check all entities
for (const entity of entities) {
const identity = this.requireComponent(entity, NetworkIdentity)
const transform = this.requireComponent(entity, NetworkTransform)
// Skip self
if (identity.netId === data.netId) continue
// Check distance
const dx = transform.currentX - data.position.x
const dy = transform.currentY - data.position.y
const distSq = dx * dx + dy * dy
if (distSq <= data.viewRangeSq) {
newVisible.add(identity.netId)
}
}
// Find entities that entered view
for (const netId of newVisible) {
if (!data.visibleEntities.has(netId)) {
this._emitEvent({
type: 'enter',
observerNetId: data.netId,
targetNetId: netId,
})
}
}
// Find entities that exited view
for (const netId of data.visibleEntities) {
if (!newVisible.has(netId)) {
this._emitEvent({
type: 'exit',
observerNetId: data.netId,
targetNetId: netId,
})
}
}
data.visibleEntities = newVisible
}
}
/**
* @zh 清除所有数据
* @en Clear all data
*/
clear(): void {
this._observers.clear()
this._cells.clear()
this._entityNetIdMap.clear()
this._netIdEntityMap.clear()
}
protected override onDestroy(): void {
this.clear()
this._listeners.clear()
}
// =========================================================================
// 私有方法 | Private Methods
// =========================================================================
private _getCellKey(x: number, y: number): string {
const cellX = Math.floor(x / this._config.cellSize)
const cellY = Math.floor(y / this._config.cellSize)
return `${cellX},${cellY}`
}
private _addToCell(cellKey: string, netId: number): void {
let cell = this._cells.get(cellKey)
if (!cell) {
cell = new Set()
this._cells.set(cellKey, cell)
}
cell.add(netId)
}
private _removeFromCell(cellKey: string, netId: number): void {
const cell = this._cells.get(cellKey)
if (cell) {
cell.delete(netId)
if (cell.size === 0) {
this._cells.delete(cellKey)
}
}
}
private _updateVisibility(data: ObserverData): void {
// This is called when an observer moves
// The full visibility update happens in process() with all entities
}
private _emitEvent(event: NetworkAOIEvent): void {
for (const listener of this._listeners) {
try {
listener(event)
} catch (e) {
console.error('[NetworkAOISystem] Listener error:', e)
}
}
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建网络 AOI 系统
* @en Create network AOI system
*/
export function createNetworkAOISystem(
config?: Partial<NetworkAOIConfig>
): NetworkAOISystem {
return new NetworkAOISystem(config)
}

View File

@@ -1,11 +1,63 @@
/**
* @zh 网络输入系统
* @en Network Input System
*
* @zh 收集本地玩家输入并发送到服务器,支持与预测系统集成
* @en Collects local player input and sends to server, supports integration with prediction system
*/
import { EntitySystem, Matcher } from '@esengine/ecs-framework'
import type { PlayerInput } from '../protocol'
import type { NetworkService } from '../services/NetworkService'
import type { NetworkPredictionSystem } from './NetworkPredictionSystem'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 输入配置
* @en Input configuration
*/
export interface NetworkInputConfig {
/**
* @zh 发送输入的最小间隔(毫秒)
* @en Minimum interval between input sends (milliseconds)
*/
sendInterval: number
/**
* @zh 是否合并相同输入
* @en Whether to merge identical inputs
*/
mergeIdenticalInputs: boolean
/**
* @zh 最大输入队列长度
* @en Maximum input queue length
*/
maxQueueLength: number
}
const DEFAULT_CONFIG: NetworkInputConfig = {
sendInterval: 16, // ~60fps
mergeIdenticalInputs: true,
maxQueueLength: 10,
}
/**
* @zh 待发送输入
* @en Pending input
*/
interface PendingInput {
moveDir?: { x: number; y: number }
actions?: string[]
timestamp: number
}
// =============================================================================
// NetworkInputSystem | 网络输入系统
// =============================================================================
/**
* @zh 网络输入系统
@@ -15,13 +67,52 @@ import type { NetworkService } from '../services/NetworkService'
* @en Collects local player input and sends to server
*/
export class NetworkInputSystem extends EntitySystem {
private _networkService: NetworkService
private _frame: number = 0
private _inputQueue: PlayerInput[] = []
private readonly _networkService: NetworkService
private readonly _config: NetworkInputConfig
private _predictionSystem: NetworkPredictionSystem | null = null
constructor(networkService: NetworkService) {
private _frame: number = 0
private _inputSequence: number = 0
private _inputQueue: PendingInput[] = []
private _lastSendTime: number = 0
private _lastMoveDir: { x: number; y: number } = { x: 0, y: 0 }
constructor(networkService: NetworkService, config?: Partial<NetworkInputConfig>) {
super(Matcher.nothing())
this._networkService = networkService
this._config = { ...DEFAULT_CONFIG, ...config }
}
/**
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<NetworkInputConfig> {
return this._config
}
/**
* @zh 获取当前帧号
* @en Get current frame number
*/
get frame(): number {
return this._frame
}
/**
* @zh 获取当前输入序列号
* @en Get current input sequence
*/
get inputSequence(): number {
return this._inputSequence
}
/**
* @zh 设置预测系统引用
* @en Set prediction system reference
*/
setPredictionSystem(system: NetworkPredictionSystem): void {
this._predictionSystem = system
}
/**
@@ -32,11 +123,64 @@ export class NetworkInputSystem extends EntitySystem {
if (!this._networkService.isConnected) return
this._frame++
const now = Date.now()
while (this._inputQueue.length > 0) {
const input = this._inputQueue.shift()!
input.frame = this._frame
this._networkService.sendInput(input)
// Rate limiting
if (now - this._lastSendTime < this._config.sendInterval) return
// If using prediction system, get input from there
if (this._predictionSystem) {
const predictedInput = this._predictionSystem.getInputToSend()
if (predictedInput) {
this._networkService.sendInput(predictedInput)
this._lastSendTime = now
}
return
}
// Otherwise process queue
if (this._inputQueue.length === 0) return
// Merge inputs if configured
let mergedInput: PendingInput
if (this._config.mergeIdenticalInputs && this._inputQueue.length > 1) {
mergedInput = this._mergeInputs(this._inputQueue)
this._inputQueue.length = 0
} else {
mergedInput = this._inputQueue.shift()!
}
// Build and send input
this._inputSequence++
const input: PlayerInput = {
seq: this._inputSequence,
frame: this._frame,
timestamp: mergedInput.timestamp,
moveDir: mergedInput.moveDir,
actions: mergedInput.actions,
}
this._networkService.sendInput(input)
this._lastSendTime = now
}
private _mergeInputs(inputs: PendingInput[]): PendingInput {
const allActions: string[] = []
let lastMoveDir: { x: number; y: number } | undefined
for (const input of inputs) {
if (input.moveDir) {
lastMoveDir = input.moveDir
}
if (input.actions) {
allActions.push(...input.actions)
}
}
return {
moveDir: lastMoveDir,
actions: allActions.length > 0 ? allActions : undefined,
timestamp: inputs[inputs.length - 1].timestamp,
}
}
@@ -45,10 +189,24 @@ export class NetworkInputSystem extends EntitySystem {
* @en Add move input
*/
public addMoveInput(x: number, y: number): void {
this._inputQueue.push({
frame: 0,
moveDir: { x, y },
})
// Skip if same as last input
if (
this._config.mergeIdenticalInputs &&
this._lastMoveDir.x === x &&
this._lastMoveDir.y === y &&
this._inputQueue.length > 0
) {
return
}
this._lastMoveDir = { x, y }
// Also set input on prediction system
if (this._predictionSystem) {
this._predictionSystem.setInput(x, y)
}
this._addToQueue({ moveDir: { x, y }, timestamp: Date.now() })
}
/**
@@ -56,19 +214,70 @@ export class NetworkInputSystem extends EntitySystem {
* @en Add action input
*/
public addActionInput(action: string): void {
// Try to add to last input in queue
const lastInput = this._inputQueue[this._inputQueue.length - 1]
if (lastInput) {
lastInput.actions = lastInput.actions || []
lastInput.actions.push(action)
} else {
this._inputQueue.push({
frame: 0,
actions: [action],
})
this._addToQueue({ actions: [action], timestamp: Date.now() })
}
// Also set on prediction system
if (this._predictionSystem) {
this._predictionSystem.setInput(
this._lastMoveDir.x,
this._lastMoveDir.y,
[action]
)
}
}
private _addToQueue(input: PendingInput): void {
this._inputQueue.push(input)
// Limit queue size
while (this._inputQueue.length > this._config.maxQueueLength) {
this._inputQueue.shift()
}
}
/**
* @zh 清空输入队列
* @en Clear input queue
*/
public clearQueue(): void {
this._inputQueue.length = 0
this._lastMoveDir = { x: 0, y: 0 }
}
/**
* @zh 重置状态
* @en Reset state
*/
public reset(): void {
this._frame = 0
this._inputSequence = 0
this.clearQueue()
}
protected override onDestroy(): void {
this._inputQueue.length = 0
this._predictionSystem = null
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建网络输入系统
* @en Create network input system
*/
export function createNetworkInputSystem(
networkService: NetworkService,
config?: Partial<NetworkInputConfig>
): NetworkInputSystem {
return new NetworkInputSystem(networkService, config)
}

View File

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

View File

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

View File

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