feat(network): 网络模块增强 - 预测、AOI、增量压缩 (#379)
- 添加 NetworkPredictionSystem 客户端预测系统 - 添加 NetworkAOISystem 兴趣区域管理 - 添加 StateDeltaCompressor 状态增量压缩 - 添加断线重连和状态恢复 - 增强协议支持时间戳、序列号、速度 - 添加中英文文档
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user