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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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>(),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
440
packages/framework/network/src/sync/StateDelta.ts
Normal file
440
packages/framework/network/src/sync/StateDelta.ts
Normal 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)
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
500
packages/framework/network/src/systems/NetworkAOISystem.ts
Normal file
500
packages/framework/network/src/systems/NetworkAOISystem.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user