feat(ecs): ECS 网络状态同步系统 | add ECS network state synchronization (#390)

## @esengine/ecs-framework

新增 @sync 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:

- `sync` 装饰器标记需要同步的字段
- `ChangeTracker` 组件变更追踪
- 二进制编解码器 (BinaryWriter/BinaryReader)
- `encodeSnapshot`/`decodeSnapshot` 批量编解码
- `encodeSpawn`/`decodeSpawn` 实体生成编解码
- `encodeDespawn`/`processDespawn` 实体销毁编解码

将以下方法标记为 @internal,用户应通过 Core.update() 驱动更新:
- Scene.update()
- SceneManager.update()
- WorldManager.updateAll()

## @esengine/network

- 新增 ComponentSyncSystem 基于 @sync 自动同步组件状态
- 将 ecs-framework 从 devDependencies 移到 peerDependencies

## @esengine/server

新增 ECSRoom,带有 ECS World 支持的房间基类:

- 每个 ECSRoom 在 Core.worldManager 中创建独立的 World
- Core.update() 统一更新 Time 和所有 World
- onTick() 只处理状态同步逻辑
- 自动创建/销毁玩家实体
- 增量状态广播
This commit is contained in:
YHH
2025-12-29 21:08:34 +08:00
committed by GitHub
parent 4cf868a769
commit 1f297ac769
32 changed files with 4620 additions and 23 deletions

View File

@@ -0,0 +1,348 @@
/**
* @zh ECSRoom 集成测试
* @en ECSRoom integration tests
*/
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'
import {
Core,
Component,
ECSComponent,
sync,
initChangeTracker,
getSyncMetadata,
registerSyncComponent,
} from '@esengine/ecs-framework'
import { createTestEnv, type TestEnvironment, wait } from '../testing/TestServer.js'
import { ECSRoom } from './ECSRoom.js'
import type { Player } from '../room/Player.js'
import { onMessage } from '../room/decorators.js'
// ============================================================================
// Test Components | 测试组件
// ============================================================================
@ECSComponent('ECSRoomTest_PlayerComponent')
class PlayerComponent extends Component {
@sync('string') name: string = ''
@sync('uint16') score: number = 0
@sync('float32') x: number = 0
@sync('float32') y: number = 0
}
@ECSComponent('ECSRoomTest_HealthComponent')
class HealthComponent extends Component {
@sync('int32') current: number = 100
@sync('int32') max: number = 100
}
// ============================================================================
// Test Room | 测试房间
// ============================================================================
interface TestRoomState {
gameStarted: boolean
}
interface TestPlayerData {
nickname: string
}
class TestECSRoom extends ECSRoom<TestRoomState, TestPlayerData> {
state: TestRoomState = {
gameStarted: false,
}
onCreate(): void {
// 可以在这里添加系统
}
onJoin(player: Player<TestPlayerData>): void {
const entity = this.createPlayerEntity(player.id)
const comp = entity.addComponent(new PlayerComponent())
comp.name = player.data.nickname || `Player_${player.id.slice(-4)}`
comp.x = Math.random() * 100
comp.y = Math.random() * 100
this.broadcast('PlayerJoined', {
playerId: player.id,
name: comp.name,
})
}
async onLeave(player: Player<TestPlayerData>, reason?: string): Promise<void> {
await super.onLeave(player, reason)
this.broadcast('PlayerLeft', { playerId: player.id })
}
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: Player<TestPlayerData>): void {
const entity = this.getPlayerEntity(player.id)
if (entity) {
const comp = entity.getComponent(PlayerComponent)
if (comp) {
comp.x = data.x
comp.y = data.y
}
}
}
@onMessage('AddScore')
handleAddScore(data: { amount: number }, player: Player<TestPlayerData>): void {
const entity = this.getPlayerEntity(player.id)
if (entity) {
const comp = entity.getComponent(PlayerComponent)
if (comp) {
comp.score += data.amount
}
}
}
@onMessage('Ping')
handlePing(_data: unknown, player: Player<TestPlayerData>): void {
player.send('Pong', { timestamp: Date.now() })
}
getWorld() {
return this.world
}
getScene() {
return this.scene
}
getPlayerEntityCount(): number {
return this.scene.entities.buffer.length
}
}
// ============================================================================
// Test Suites | 测试套件
// ============================================================================
describe('ECSRoom Integration Tests', () => {
let env: TestEnvironment
beforeAll(() => {
Core.create()
registerSyncComponent('ECSRoomTest_PlayerComponent', PlayerComponent)
registerSyncComponent('ECSRoomTest_HealthComponent', HealthComponent)
})
afterAll(() => {
Core.destroy()
})
beforeEach(async () => {
env = await createTestEnv({ tickRate: 20 })
})
afterEach(async () => {
await env.cleanup()
})
// ========================================================================
// Room Creation | 房间创建
// ========================================================================
describe('Room Creation', () => {
it('should create ECSRoom with World and Scene', async () => {
env.server.define('ecs-test', TestECSRoom)
const client = await env.createClient()
await client.joinRoom('ecs-test')
expect(client.roomId).toBeDefined()
})
it('should have World managed by Core.worldManager', async () => {
env.server.define('ecs-test', TestECSRoom)
const client = await env.createClient()
await client.joinRoom('ecs-test')
// 验证 World 正常创建(通过消息通信验证)
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
client.sendToRoom('Ping', {})
const pong = await pongPromise
expect(pong.timestamp).toBeGreaterThan(0)
})
})
// ========================================================================
// Player Entity Management | 玩家实体管理
// ========================================================================
describe('Player Entity Management', () => {
it('should create player entity on join', async () => {
env.server.define('ecs-test', TestECSRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('ecs-test')
// 等待第二个玩家加入时收到广播
const joinPromise = client1.waitForRoomMessage<{ playerId: string; name: string }>(
'PlayerJoined'
)
const client2 = await env.createClient()
await client2.joinRoomById(roomId)
const joinMsg = await joinPromise
expect(joinMsg.playerId).toBe(client2.playerId)
expect(joinMsg.name).toContain('Player_')
})
it('should destroy player entity on leave', async () => {
env.server.define('ecs-test', TestECSRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('ecs-test')
const client2 = await env.createClient()
await client2.joinRoomById(roomId)
const leavePromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerLeft')
await client2.leaveRoom()
const leaveMsg = await leavePromise
expect(leaveMsg.playerId).toBeDefined()
})
})
// ========================================================================
// Component Sync | 组件同步
// ========================================================================
describe('Component State Updates', () => {
it('should update component via message handler', async () => {
env.server.define('ecs-test', TestECSRoom)
const client = await env.createClient()
await client.joinRoom('ecs-test')
client.sendToRoom('Move', { x: 100, y: 200 })
// 等待处理
await wait(50)
// 验证 Ping/Pong 仍能工作(房间仍活跃)
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
client.sendToRoom('Ping', {})
const pong = await pongPromise
expect(pong.timestamp).toBeGreaterThan(0)
})
it('should handle AddScore message', async () => {
env.server.define('ecs-test', TestECSRoom)
const client = await env.createClient()
await client.joinRoom('ecs-test')
client.sendToRoom('AddScore', { amount: 50 })
client.sendToRoom('AddScore', { amount: 25 })
await wait(50)
// 确认房间仍然活跃
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
client.sendToRoom('Ping', {})
await pongPromise
})
})
// ========================================================================
// Sync Broadcast | 同步广播
// ========================================================================
describe('State Sync Broadcast', () => {
it('should receive $sync messages when enabled', async () => {
env.server.define('ecs-test', TestECSRoom)
const client = await env.createClient()
await client.joinRoom('ecs-test')
// 触发状态变更
client.sendToRoom('Move', { x: 50, y: 75 })
// 等待 tick 处理
await wait(200)
// 检查是否收到 $sync 消息
const hasSync = client.hasReceivedMessage('RoomMessage')
expect(hasSync).toBe(true)
})
})
// ========================================================================
// Multi-player Sync | 多玩家同步
// ========================================================================
describe('Multi-player Scenarios', () => {
it('should handle multiple players in same room', async () => {
env.server.define('ecs-test', TestECSRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('ecs-test')
const client2 = await env.createClient()
const joinPromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerJoined')
await client2.joinRoomById(roomId)
const joinMsg = await joinPromise
expect(joinMsg.playerId).toBe(client2.playerId)
})
it('should broadcast to all players on state change', async () => {
env.server.define('ecs-test', TestECSRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('ecs-test')
const client2 = await env.createClient()
// client1 等待收到 client2 加入的广播
const joinPromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerJoined')
await client2.joinRoomById(roomId)
const joinMsg = await joinPromise
expect(joinMsg.playerId).toBe(client2.playerId)
// 验证每个客户端都能独立通信
const pong1Promise = client1.waitForRoomMessage<{ timestamp: number }>('Pong')
client1.sendToRoom('Ping', {})
const pong1 = await pong1Promise
expect(pong1.timestamp).toBeGreaterThan(0)
const pong2Promise = client2.waitForRoomMessage<{ timestamp: number }>('Pong')
client2.sendToRoom('Ping', {})
const pong2 = await pong2Promise
expect(pong2.timestamp).toBeGreaterThan(0)
})
})
// ========================================================================
// Cleanup | 清理
// ========================================================================
describe('Room Cleanup', () => {
it('should cleanup World on dispose', async () => {
env.server.define('ecs-test', TestECSRoom)
const client = await env.createClient()
await client.joinRoom('ecs-test')
await client.leaveRoom()
// 等待自动销毁
await wait(100)
// 房间应该已销毁
expect(client.roomId).toBeNull()
})
})
})

View File

@@ -0,0 +1,345 @@
/**
* @zh ECS 房间基类
* @en ECS Room base class
*/
import {
Core,
Scene,
World,
Entity,
EntitySystem,
type Component,
// Sync
SyncOperation,
SYNC_METADATA,
CHANGE_TRACKER,
type SyncMetadata,
type ChangeTracker,
encodeSnapshot,
encodeSpawn,
encodeDespawn,
initChangeTracker,
} from '@esengine/ecs-framework';
import { Room, type RoomOptions } from '../room/Room.js';
import type { Player } from '../room/Player.js';
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh ECS 房间配置
* @en ECS room configuration
*/
export interface ECSRoomConfig {
/**
* @zh 状态同步间隔(毫秒)
* @en State sync interval in milliseconds
*/
syncInterval: number;
/**
* @zh 是否启用增量同步
* @en Whether to enable delta sync
*/
enableDeltaSync: boolean;
}
const DEFAULT_ECS_CONFIG: ECSRoomConfig = {
syncInterval: 50, // 20 Hz
enableDeltaSync: true,
};
/**
* @zh 网络实体标识组件
* @en Network entity identity component
*/
const NETWORK_ENTITY_OWNER = Symbol('NetworkEntityOwner');
// =============================================================================
// ECSRoom | ECS 房间
// =============================================================================
/**
* @zh ECS 房间基类,带有 ECS World 支持和自动状态同步
* @en ECS Room base class with ECS World support and automatic state synchronization
*
* @example
* ```typescript
* // 服务端启动
* Core.create();
* setInterval(() => Core.update(1/60), 16);
*
* // 定义房间
* class GameRoom extends ECSRoom {
* onCreate() {
* this.addSystem(new PhysicsSystem());
* }
*
* onJoin(player: Player) {
* const entity = this.createPlayerEntity(player.id);
* entity.addComponent(new PlayerComponent());
* }
* }
* ```
*/
export abstract class ECSRoom<TState = any, TPlayerData = Record<string, unknown>> extends Room<TState, TPlayerData> {
/**
* @zh ECS World由 Core.worldManager 管理)
* @en ECS World (managed by Core.worldManager)
*/
protected readonly world: World;
/**
* @zh World 在 WorldManager 中的 ID
* @en World ID in WorldManager
*/
protected readonly worldId: string;
/**
* @zh 房间的主场景
* @en Room's main scene
*/
protected readonly scene: Scene;
/**
* @zh ECS 配置
* @en ECS configuration
*/
protected readonly ecsConfig: ECSRoomConfig;
/**
* @zh 玩家 ID 到实体的映射
* @en Player ID to Entity mapping
*/
private readonly _playerEntities: Map<string, Entity> = new Map();
/**
* @zh 上次同步时间
* @en Last sync time
*/
private _lastSyncTime: number = 0;
constructor(ecsConfig?: Partial<ECSRoomConfig>) {
super();
this.ecsConfig = { ...DEFAULT_ECS_CONFIG, ...ecsConfig };
this.worldId = `room_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
this.world = Core.worldManager.createWorld(this.worldId);
this.scene = this.world.createScene('game');
this.world.setSceneActive('game', true);
this.world.start();
}
// =========================================================================
// Scene Management | 场景管理
// =========================================================================
/**
* @zh 添加系统到场景
* @en Add system to scene
*/
protected addSystem(system: EntitySystem): void {
this.scene.addSystem(system);
}
/**
* @zh 创建实体
* @en Create entity
*/
protected createEntity(name?: string): Entity {
return this.scene.createEntity(name ?? `entity_${Date.now()}`);
}
/**
* @zh 为玩家创建实体
* @en Create entity for player
*
* @param playerId - @zh 玩家 ID @en Player ID
* @param name - @zh 实体名称 @en Entity name
* @returns @zh 创建的实体 @en Created entity
*/
protected createPlayerEntity(playerId: string, name?: string): Entity {
const entityName = name ?? `player_${playerId}`;
const entity = this.scene.createEntity(entityName);
(entity as any)[NETWORK_ENTITY_OWNER] = playerId;
this._playerEntities.set(playerId, entity);
return entity;
}
/**
* @zh 获取玩家的实体
* @en Get player's entity
*/
protected getPlayerEntity(playerId: string): Entity | undefined {
return this._playerEntities.get(playerId);
}
/**
* @zh 销毁玩家的实体
* @en Destroy player's entity
*/
protected destroyPlayerEntity(playerId: string): void {
const entity = this._playerEntities.get(playerId);
if (entity) {
const despawnData = encodeDespawn(entity.id);
this.broadcastBinary(despawnData);
entity.destroy();
this._playerEntities.delete(playerId);
}
}
// =========================================================================
// State Sync | 状态同步
// =========================================================================
/**
* @zh 广播二进制数据
* @en Broadcast binary data
*/
protected broadcastBinary(data: Uint8Array): void {
for (const player of this.players) {
this.sendBinary(player, data);
}
}
/**
* @zh 发送二进制数据给指定玩家
* @en Send binary data to specific player
*/
protected sendBinary(player: Player<TPlayerData>, data: Uint8Array): void {
player.send('$sync', { data: Array.from(data) });
}
/**
* @zh 发送完整状态给玩家(用于玩家刚加入时)
* @en Send full state to player (for when player just joined)
*/
protected sendFullState(player: Player<TPlayerData>): void {
const entities = this._getSyncEntities();
if (entities.length === 0) return;
for (const entity of entities) {
this._initComponentTrackers(entity);
}
const data = encodeSnapshot(entities, SyncOperation.FULL);
this.sendBinary(player, data);
}
/**
* @zh 广播实体生成
* @en Broadcast entity spawn
*/
protected broadcastSpawn(entity: Entity, prefabType?: string): void {
this._initComponentTrackers(entity);
const data = encodeSpawn(entity, prefabType);
this.broadcastBinary(data);
}
/**
* @zh 广播增量状态更新
* @en Broadcast delta state update
*/
protected broadcastDelta(): void {
const entities = this._getSyncEntities();
const changedEntities = entities.filter(entity => this._hasChanges(entity));
if (changedEntities.length === 0) return;
const data = encodeSnapshot(changedEntities, SyncOperation.DELTA);
this.broadcastBinary(data);
this._clearChangeTrackers(changedEntities);
}
// =========================================================================
// Lifecycle Overrides | 生命周期重载
// =========================================================================
/**
* @zh 游戏循环,处理状态同步
* @en Game tick, handles state sync
*/
override onTick(_dt: number): void {
if (this.ecsConfig.enableDeltaSync) {
const now = Date.now();
if (now - this._lastSyncTime >= this.ecsConfig.syncInterval) {
this._lastSyncTime = now;
this.broadcastDelta();
}
}
}
/**
* @zh 玩家离开时自动销毁其实体
* @en Auto destroy player entity when leaving
*/
override async onLeave(player: Player<TPlayerData>, reason?: string): Promise<void> {
this.destroyPlayerEntity(player.id);
}
/**
* @zh 房间销毁时从 WorldManager 移除 World
* @en Remove World from WorldManager when room is disposed
*/
override onDispose(): void {
this._playerEntities.clear();
Core.worldManager.removeWorld(this.worldId);
}
// =========================================================================
// Internal | 内部方法
// =========================================================================
private _getSyncEntities(): Entity[] {
const entities: Entity[] = [];
for (const entity of this.scene.entities.buffer) {
if (this._hasSyncComponents(entity)) {
entities.push(entity);
}
}
return entities;
}
private _hasSyncComponents(entity: Entity): boolean {
for (const component of entity.components) {
const metadata: SyncMetadata | undefined = (component.constructor as any)[SYNC_METADATA];
if (metadata && metadata.fields.length > 0) {
return true;
}
}
return false;
}
private _hasChanges(entity: Entity): boolean {
for (const component of entity.components) {
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
if (tracker?.hasChanges()) {
return true;
}
}
return false;
}
private _initComponentTrackers(entity: Entity): void {
for (const component of entity.components) {
const metadata: SyncMetadata | undefined = (component.constructor as any)[SYNC_METADATA];
if (metadata && metadata.fields.length > 0) {
initChangeTracker(component);
}
}
}
private _clearChangeTrackers(entities: Entity[]): void {
for (const entity of entities) {
for (const component of entity.components) {
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
if (tracker) {
tracker.clear();
}
}
}
}
}

View File

@@ -0,0 +1,59 @@
/**
* @zh @esengine/server ECS 集成模块
* @en @esengine/server ECS integration module
*
* @zh 提供带 ECS World 的房间类,支持基于 @sync 装饰器的自动状态同步
* @en Provides Room class with ECS World, supports automatic state sync based on @sync decorator
*
* @example
* ```typescript
* import { ECSRoom } from '@esengine/server/ecs';
* import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
*
* @ECSComponent('Player')
* class PlayerComponent extends Component {
* @sync("string") name: string = "";
* @sync("uint16") score: number = 0;
* }
*
* class GameRoom extends ECSRoom {
* onCreate() {
* this.addSystem(new MovementSystem());
* }
*
* onJoin(player: Player) {
* const entity = this.createPlayerEntity(player.id);
* entity.addComponent(new PlayerComponent());
* }
* }
* ```
*/
export { ECSRoom } from './ECSRoom.js';
export type { ECSRoomConfig } from './ECSRoom.js';
// Re-export commonly used ECS types for convenience
export type {
Entity,
Component,
EntitySystem,
Scene,
World,
} from '@esengine/ecs-framework';
// Re-export sync types
export {
sync,
getSyncMetadata,
hasSyncFields,
initChangeTracker,
clearChanges,
hasChanges,
SyncOperation,
type SyncType,
type SyncFieldMetadata,
type SyncMetadata,
} from '@esengine/ecs-framework';
// Re-export room decorators
export { onMessage } from '../room/decorators.js';