Compare commits

...

2 Commits

Author SHA1 Message Date
github-actions[bot]
449bd420a6 chore: release packages (#391)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 21:10:36 +08:00
YHH
1f297ac769 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() 只处理状态同步逻辑
- 自动创建/销毁玩家实体
- 增量状态广播
2025-12-29 21:08:34 +08:00
53 changed files with 4881 additions and 35 deletions

View File

@@ -311,6 +311,93 @@ client.send('RoomMessage', {
})
```
## ECSRoom
`ECSRoom` is a room base class with ECS World support, suitable for games that need ECS architecture.
### Server Startup
```typescript
import { Core } from '@esengine/ecs-framework';
import { createServer } from '@esengine/server';
import { GameRoom } from './rooms/GameRoom.js';
// Initialize Core
Core.create();
// Global game loop
setInterval(() => Core.update(1/60), 16);
// Create server
const server = await createServer({ port: 3000 });
server.define('game', GameRoom);
await server.start();
```
### Define ECSRoom
```typescript
import { ECSRoom, Player } from '@esengine/server/ecs';
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
// Define sync component
@ECSComponent('Player')
class PlayerComponent extends Component {
@sync("string") name: string = "";
@sync("uint16") score: number = 0;
@sync("float32") x: number = 0;
@sync("float32") y: number = 0;
}
// Define room
class GameRoom extends ECSRoom {
onCreate() {
this.addSystem(new MovementSystem());
}
onJoin(player: Player) {
const entity = this.createPlayerEntity(player.id);
const comp = entity.addComponent(new PlayerComponent());
comp.name = player.id;
}
}
```
### ECSRoom API
```typescript
abstract class ECSRoom<TState, TPlayerData> extends Room<TState, TPlayerData> {
protected readonly world: World; // ECS World
protected readonly scene: Scene; // Main scene
// Scene management
protected addSystem(system: EntitySystem): void;
protected createEntity(name?: string): Entity;
protected createPlayerEntity(playerId: string, name?: string): Entity;
protected getPlayerEntity(playerId: string): Entity | undefined;
protected destroyPlayerEntity(playerId: string): void;
// State sync
protected sendFullState(player: Player): void;
protected broadcastSpawn(entity: Entity, prefabType?: string): void;
protected broadcastDelta(): void;
}
```
### @sync Decorator
Mark component fields that need network synchronization:
| Type | Description | Bytes |
|------|-------------|-------|
| `"boolean"` | Boolean | 1 |
| `"int8"` / `"uint8"` | 8-bit integer | 1 |
| `"int16"` / `"uint16"` | 16-bit integer | 2 |
| `"int32"` / `"uint32"` | 32-bit integer | 4 |
| `"float32"` | 32-bit float | 4 |
| `"float64"` | 64-bit float | 8 |
| `"string"` | String | Variable |
## Best Practices
1. **Set Appropriate Tick Rate**

View File

@@ -1,8 +1,80 @@
---
title: "State Sync"
description: "Interpolation, prediction and snapshot buffers"
description: "Component sync, interpolation, prediction and snapshot buffers"
---
## Component Sync System
ECS component state synchronization based on `@sync` decorator.
### Define Sync Component
```typescript
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
@ECSComponent('Player')
class PlayerComponent extends Component {
@sync("string") name: string = "";
@sync("uint16") score: number = 0;
@sync("float32") x: number = 0;
@sync("float32") y: number = 0;
// Fields without @sync won't be synced
localData: any;
}
```
### Server-side Encoding
```typescript
import { ComponentSyncSystem } from '@esengine/network';
const syncSystem = new ComponentSyncSystem({}, true);
scene.addSystem(syncSystem);
// Encode all entities (initial connection)
const fullData = syncSystem.encodeAllEntities(true);
sendToClient(fullData);
// Encode delta (only send changes)
const deltaData = syncSystem.encodeDelta();
if (deltaData) {
broadcast(deltaData);
}
```
### Client-side Decoding
```typescript
const syncSystem = new ComponentSyncSystem();
scene.addSystem(syncSystem);
// Register component types
syncSystem.registerComponent(PlayerComponent);
// Listen for sync events
syncSystem.addSyncListener((event) => {
if (event.type === 'entitySpawned') {
console.log('New entity:', event.entityId);
}
});
// Apply state
syncSystem.applySnapshot(data);
```
### Sync Types
| Type | Description | Bytes |
|------|-------------|-------|
| `"boolean"` | Boolean | 1 |
| `"int8"` / `"uint8"` | 8-bit integer | 1 |
| `"int16"` / `"uint16"` | 16-bit integer | 2 |
| `"int32"` / `"uint32"` | 32-bit integer | 4 |
| `"float32"` | 32-bit float | 4 |
| `"float64"` | 64-bit float | 8 |
| `"string"` | String | Variable |
## Snapshot Buffer
Stores server state snapshots for interpolation:

View File

@@ -311,6 +311,93 @@ client.send('RoomMessage', {
})
```
## ECSRoom
`ECSRoom` 是带有 ECS World 支持的房间基类,适用于需要 ECS 架构的游戏。
### 服务端启动
```typescript
import { Core } from '@esengine/ecs-framework';
import { createServer } from '@esengine/server';
import { GameRoom } from './rooms/GameRoom.js';
// 初始化 Core
Core.create();
// 全局游戏循环
setInterval(() => Core.update(1/60), 16);
// 创建服务器
const server = await createServer({ port: 3000 });
server.define('game', GameRoom);
await server.start();
```
### 定义 ECSRoom
```typescript
import { ECSRoom, Player } 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;
@sync("float32") x: number = 0;
@sync("float32") y: number = 0;
}
// 定义房间
class GameRoom extends ECSRoom {
onCreate() {
this.addSystem(new MovementSystem());
}
onJoin(player: Player) {
const entity = this.createPlayerEntity(player.id);
const comp = entity.addComponent(new PlayerComponent());
comp.name = player.id;
}
}
```
### ECSRoom API
```typescript
abstract class ECSRoom<TState, TPlayerData> extends Room<TState, TPlayerData> {
protected readonly world: World; // ECS World
protected readonly scene: Scene; // 主场景
// 场景管理
protected addSystem(system: EntitySystem): void;
protected createEntity(name?: string): Entity;
protected createPlayerEntity(playerId: string, name?: string): Entity;
protected getPlayerEntity(playerId: string): Entity | undefined;
protected destroyPlayerEntity(playerId: string): void;
// 状态同步
protected sendFullState(player: Player): void;
protected broadcastSpawn(entity: Entity, prefabType?: string): void;
protected broadcastDelta(): void;
}
```
### @sync 装饰器
标记需要网络同步的组件字段:
| 类型 | 描述 | 字节数 |
|------|------|--------|
| `"boolean"` | 布尔值 | 1 |
| `"int8"` / `"uint8"` | 8位整数 | 1 |
| `"int16"` / `"uint16"` | 16位整数 | 2 |
| `"int32"` / `"uint32"` | 32位整数 | 4 |
| `"float32"` | 32位浮点 | 4 |
| `"float64"` | 64位浮点 | 8 |
| `"string"` | 字符串 | 变长 |
## 最佳实践
1. **合理设置 Tick 频率**

View File

@@ -1,8 +1,80 @@
---
title: "状态同步"
description: "插值、预测和快照缓冲区"
description: "组件同步、插值、预测和快照缓冲区"
---
## 组件同步系统
基于 `@sync` 装饰器的 ECS 组件状态同步。
### 定义同步组件
```typescript
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
@ECSComponent('Player')
class PlayerComponent extends Component {
@sync("string") name: string = "";
@sync("uint16") score: number = 0;
@sync("float32") x: number = 0;
@sync("float32") y: number = 0;
// 不带 @sync 的字段不会同步
localData: any;
}
```
### 服务端编码
```typescript
import { ComponentSyncSystem } from '@esengine/network';
const syncSystem = new ComponentSyncSystem({}, true);
scene.addSystem(syncSystem);
// 编码所有实体(首次连接)
const fullData = syncSystem.encodeAllEntities(true);
sendToClient(fullData);
// 编码增量(只发送变更)
const deltaData = syncSystem.encodeDelta();
if (deltaData) {
broadcast(deltaData);
}
```
### 客户端解码
```typescript
const syncSystem = new ComponentSyncSystem();
scene.addSystem(syncSystem);
// 注册组件类型
syncSystem.registerComponent(PlayerComponent);
// 监听同步事件
syncSystem.addSyncListener((event) => {
if (event.type === 'entitySpawned') {
console.log('New entity:', event.entityId);
}
});
// 应用状态
syncSystem.applySnapshot(data);
```
### 同步类型
| 类型 | 描述 | 字节数 |
|------|------|--------|
| `"boolean"` | 布尔值 | 1 |
| `"int8"` / `"uint8"` | 8位整数 | 1 |
| `"int16"` / `"uint16"` | 16位整数 | 2 |
| `"int32"` / `"uint32"` | 32位整数 | 4 |
| `"float32"` | 32位浮点 | 4 |
| `"float64"` | 64位浮点 | 8 |
| `"string"` | 字符串 | 变长 |
## 快照缓冲区
用于存储服务器状态快照并进行插值:

View File

@@ -1,5 +1,12 @@
# @esengine/behavior-tree
## 2.0.0
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
## 1.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/behavior-tree",
"version": "1.0.3",
"version": "2.0.0",
"description": "ECS-based AI behavior tree system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
"main": "dist/index.js",
"module": "dist/index.js",

View File

@@ -1,5 +1,12 @@
# @esengine/blueprint
## 2.0.0
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
## 1.0.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/blueprint",
"version": "1.0.2",
"version": "2.0.0",
"description": "Visual scripting system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
"main": "dist/index.js",
"module": "dist/index.js",

View File

@@ -1,5 +1,90 @@
# @esengine/ecs-framework
## 2.5.0
### Minor Changes
- [#390](https://github.com/esengine/esengine/pull/390) [`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256) Thanks [@esengine](https://github.com/esengine)! - feat: ECS 网络状态同步系统
## @esengine/ecs-framework
新增 `@sync` 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:
```typescript
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
@ECSComponent('Player')
class PlayerComponent extends Component {
@sync('string') name: string = '';
@sync('uint16') score: number = 0;
@sync('float32') x: number = 0;
@sync('float32') y: number = 0;
}
```
### 新增导出
- `sync` - 标记需要同步的字段装饰器
- `SyncType` - 支持的同步类型
- `SyncOperation` - 同步操作类型FULL/DELTA/SPAWN/DESPAWN
- `encodeSnapshot` / `decodeSnapshot` - 批量编解码
- `encodeSpawn` / `decodeSpawn` - 实体生成编解码
- `encodeDespawn` / `processDespawn` - 实体销毁编解码
- `ChangeTracker` - 字段级变更追踪
- `initChangeTracker` / `clearChanges` / `hasChanges` - 变更追踪工具函数
### 内部方法标记
将以下方法标记为 `@internal`,用户应通过 `Core.update()` 驱动更新:
- `Scene.update()`
- `SceneManager.update()`
- `WorldManager.updateAll()`
## @esengine/network
新增 `ComponentSyncSystem`,基于 `@sync` 装饰器自动同步组件状态:
```typescript
import { ComponentSyncSystem } from '@esengine/network';
// 服务端:编码状态
const data = syncSystem.encodeAllEntities(false);
// 客户端:解码状态
syncSystem.applySnapshot(data);
```
### 修复
- 将 `@esengine/ecs-framework` 从 devDependencies 移到 peerDependencies
## @esengine/server
新增 `ECSRoom`,带有 ECS World 支持的房间基类:
```typescript
import { ECSRoom } from '@esengine/server/ecs';
// 服务端启动
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());
}
}
```
### 设计
- 每个 `ECSRoom` 在 `Core.worldManager` 中创建独立的 World
- `Core.update()` 统一更新 Time 和所有 World
- `onTick()` 只处理状态同步逻辑
## 2.4.4
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/ecs-framework",
"version": "2.4.4",
"version": "2.5.0",
"description": "用于Laya、Cocos Creator等JavaScript游戏引擎的高性能ECS框架",
"main": "dist/index.cjs",
"module": "dist/index.mjs",

View File

@@ -508,7 +508,9 @@ export class Scene implements IScene {
}
/**
* 更新场景
* @zh 更新场景
* @en Update scene
* @internal 由 SceneManager 或 World 调用,用户不应直接调用
*/
public update() {
this.epochManager.increment();

View File

@@ -240,18 +240,9 @@ export class SceneManager implements IService {
}
/**
* 更新场景
*
* 应该在每帧的游戏循环中调用
* 会自动处理延迟场景切换。
*
* @example
* ```typescript
* function gameLoop(deltaTime: number) {
* Core.update(deltaTime);
* sceneManager.update(); // 每帧调用
* }
* ```
* @zh 更新场景
* @en Update scene
* @internal 由 Core.update() 调用,用户不应直接调用
*/
public update(): void {
// 处理延迟场景切换

View File

@@ -0,0 +1,125 @@
/**
* @zh 组件变更追踪器
* @en Component change tracker
*
* @zh 用于追踪 @sync 标记字段的变更,支持增量同步
* @en Tracks changes to @sync marked fields for delta synchronization
*/
export class ChangeTracker {
/**
* @zh 脏字段索引集合
* @en Set of dirty field indices
*/
private _dirtyFields: Set<number> = new Set();
/**
* @zh 是否有任何变更
* @en Whether there are any changes
*/
private _hasChanges: boolean = false;
/**
* @zh 上次同步的时间戳
* @en Last sync timestamp
*/
private _lastSyncTime: number = 0;
/**
* @zh 标记字段为脏
* @en Mark field as dirty
*
* @param fieldIndex - @zh 字段索引 @en Field index
*/
public setDirty(fieldIndex: number): void {
this._dirtyFields.add(fieldIndex);
this._hasChanges = true;
}
/**
* @zh 检查是否有变更
* @en Check if there are any changes
*/
public hasChanges(): boolean {
return this._hasChanges;
}
/**
* @zh 检查特定字段是否脏
* @en Check if a specific field is dirty
*
* @param fieldIndex - @zh 字段索引 @en Field index
*/
public isDirty(fieldIndex: number): boolean {
return this._dirtyFields.has(fieldIndex);
}
/**
* @zh 获取所有脏字段索引
* @en Get all dirty field indices
*/
public getDirtyFields(): number[] {
return Array.from(this._dirtyFields);
}
/**
* @zh 获取脏字段数量
* @en Get number of dirty fields
*/
public getDirtyCount(): number {
return this._dirtyFields.size;
}
/**
* @zh 清除所有变更标记
* @en Clear all change marks
*/
public clear(): void {
this._dirtyFields.clear();
this._hasChanges = false;
this._lastSyncTime = Date.now();
}
/**
* @zh 清除特定字段的变更标记
* @en Clear change mark for a specific field
*
* @param fieldIndex - @zh 字段索引 @en Field index
*/
public clearField(fieldIndex: number): void {
this._dirtyFields.delete(fieldIndex);
if (this._dirtyFields.size === 0) {
this._hasChanges = false;
}
}
/**
* @zh 获取上次同步时间
* @en Get last sync time
*/
public get lastSyncTime(): number {
return this._lastSyncTime;
}
/**
* @zh 标记所有字段为脏(用于首次同步)
* @en Mark all fields as dirty (for initial sync)
*
* @param fieldCount - @zh 字段数量 @en Field count
*/
public markAllDirty(fieldCount: number): void {
for (let i = 0; i < fieldCount; i++) {
this._dirtyFields.add(i);
}
this._hasChanges = fieldCount > 0;
}
/**
* @zh 重置追踪器
* @en Reset tracker
*/
public reset(): void {
this._dirtyFields.clear();
this._hasChanges = false;
this._lastSyncTime = 0;
}
}

View File

@@ -0,0 +1,217 @@
/**
* @zh 网络同步装饰器
* @en Network synchronization decorators
*
* @zh 提供 @sync 装饰器,用于标记需要网络同步的 Component 字段
* @en Provides @sync decorator to mark Component fields for network synchronization
*/
import type { SyncType, SyncFieldMetadata, SyncMetadata } from './types';
import { SYNC_METADATA, CHANGE_TRACKER } from './types';
import { ChangeTracker } from './ChangeTracker';
/**
* @zh 获取或创建组件的同步元数据
* @en Get or create sync metadata for a component class
*
* @param target - @zh 组件类的原型 @en Component class prototype
* @returns @zh 同步元数据 @en Sync metadata
*/
function getOrCreateSyncMetadata(target: any): SyncMetadata {
const constructor = target.constructor;
// Check if has own metadata (not inherited)
const hasOwnMetadata = Object.prototype.hasOwnProperty.call(constructor, SYNC_METADATA);
if (hasOwnMetadata) {
return constructor[SYNC_METADATA];
}
// Check for inherited metadata
const inheritedMetadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
// Create new metadata (copy from inherited if exists)
const metadata: SyncMetadata = {
typeId: constructor.name,
fields: inheritedMetadata ? [...inheritedMetadata.fields] : [],
fieldIndexMap: inheritedMetadata ? new Map(inheritedMetadata.fieldIndexMap) : new Map()
};
constructor[SYNC_METADATA] = metadata;
return metadata;
}
/**
* @zh 同步字段装饰器
* @en Sync field decorator
*
* @zh 标记 Component 字段为可网络同步。被标记的字段会自动追踪变更,
* 并在值修改时触发变更追踪器。
* @en Marks a Component field for network synchronization. Marked fields
* automatically track changes and trigger the change tracker on modification.
*
* @param type - @zh 字段的同步类型 @en Sync type of the field
*
* @example
* ```typescript
* import { Component, ECSComponent } from '@esengine/ecs-framework';
* import { sync } from '@esengine/ecs-framework';
*
* @ECSComponent('Player')
* class PlayerComponent extends Component {
* @sync("string") name: string = "";
* @sync("uint16") score: number = 0;
* @sync("float32") x: number = 0;
* @sync("float32") y: number = 0;
*
* // 不带 @sync 的字段不会同步
* // Fields without @sync will not be synchronized
* localData: any;
* }
* ```
*/
export function sync(type: SyncType) {
return function (target: any, propertyKey: string) {
const metadata = getOrCreateSyncMetadata(target);
// Assign field index (auto-increment based on field count)
const fieldIndex = metadata.fields.length;
// Create field metadata
const fieldMeta: SyncFieldMetadata = {
index: fieldIndex,
name: propertyKey,
type: type
};
// Register field
metadata.fields.push(fieldMeta);
metadata.fieldIndexMap.set(propertyKey, fieldIndex);
// Store original property key for getter/setter
const privateKey = `_sync_${propertyKey}`;
// Define getter/setter to intercept value changes
Object.defineProperty(target, propertyKey, {
get() {
return this[privateKey];
},
set(value: any) {
const oldValue = this[privateKey];
if (oldValue !== value) {
this[privateKey] = value;
// Trigger change tracker if exists
const tracker = this[CHANGE_TRACKER] as ChangeTracker | undefined;
if (tracker) {
tracker.setDirty(fieldIndex);
}
}
},
enumerable: true,
configurable: true
});
};
}
/**
* @zh 获取组件类的同步元数据
* @en Get sync metadata for a component class
*
* @param componentClass - @zh 组件类或组件实例 @en Component class or instance
* @returns @zh 同步元数据,如果不存在则返回 null @en Sync metadata, or null if not exists
*/
export function getSyncMetadata(componentClass: any): SyncMetadata | null {
if (!componentClass) {
return null;
}
const constructor = typeof componentClass === 'function'
? componentClass
: componentClass.constructor;
return constructor[SYNC_METADATA] || null;
}
/**
* @zh 检查组件是否有同步字段
* @en Check if a component has sync fields
*
* @param component - @zh 组件类或组件实例 @en Component class or instance
* @returns @zh 如果有同步字段返回 true @en Returns true if has sync fields
*/
export function hasSyncFields(component: any): boolean {
const metadata = getSyncMetadata(component);
return metadata !== null && metadata.fields.length > 0;
}
/**
* @zh 获取组件实例的变更追踪器
* @en Get change tracker of a component instance
*
* @param component - @zh 组件实例 @en Component instance
* @returns @zh 变更追踪器,如果不存在则返回 null @en Change tracker, or null if not exists
*/
export function getChangeTracker(component: any): ChangeTracker | null {
if (!component) {
return null;
}
return component[CHANGE_TRACKER] || null;
}
/**
* @zh 为组件实例初始化变更追踪器
* @en Initialize change tracker for a component instance
*
* @zh 这个函数应该在组件首次添加到实体时调用。
* 它会创建变更追踪器并标记所有字段为脏(用于首次同步)。
* @en This function should be called when a component is first added to an entity.
* It creates the change tracker and marks all fields as dirty (for initial sync).
*
* @param component - @zh 组件实例 @en Component instance
* @returns @zh 变更追踪器 @en Change tracker
*/
export function initChangeTracker(component: any): ChangeTracker {
const metadata = getSyncMetadata(component);
if (!metadata) {
throw new Error('Component does not have sync metadata. Use @sync decorator on fields.');
}
let tracker = component[CHANGE_TRACKER] as ChangeTracker | undefined;
if (!tracker) {
tracker = new ChangeTracker();
component[CHANGE_TRACKER] = tracker;
}
// Mark all fields as dirty for initial sync
tracker.markAllDirty(metadata.fields.length);
return tracker;
}
/**
* @zh 清除组件实例的变更标记
* @en Clear change marks for a component instance
*
* @zh 通常在同步完成后调用,清除所有脏标记
* @en Usually called after sync is complete, clears all dirty marks
*
* @param component - @zh 组件实例 @en Component instance
*/
export function clearChanges(component: any): void {
const tracker = getChangeTracker(component);
if (tracker) {
tracker.clear();
}
}
/**
* @zh 检查组件是否有变更
* @en Check if a component has changes
*
* @param component - @zh 组件实例 @en Component instance
* @returns @zh 如果有变更返回 true @en Returns true if has changes
*/
export function hasChanges(component: any): boolean {
const tracker = getChangeTracker(component);
return tracker ? tracker.hasChanges() : false;
}

View File

@@ -0,0 +1,285 @@
/**
* @zh 二进制读取器
* @en Binary Reader
*
* @zh 提供高效的二进制数据读取功能
* @en Provides efficient binary data reading
*/
import { decodeVarint } from './varint';
/**
* @zh 文本解码器(使用浏览器原生 API
* @en Text decoder (using browser native API)
*/
const textDecoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
/**
* @zh 二进制读取器
* @en Binary reader for decoding data
*/
export class BinaryReader {
/**
* @zh 数据缓冲区
* @en Data buffer
*/
private _buffer: Uint8Array;
/**
* @zh DataView 用于读取数值
* @en DataView for reading numbers
*/
private _view: DataView;
/**
* @zh 当前读取位置
* @en Current read position
*/
private _offset: number = 0;
/**
* @zh 创建二进制读取器
* @en Create binary reader
*
* @param buffer - @zh 要读取的数据 @en Data to read
*/
constructor(buffer: Uint8Array) {
this._buffer = buffer;
this._view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
}
/**
* @zh 获取当前读取位置
* @en Get current read position
*/
public get offset(): number {
return this._offset;
}
/**
* @zh 设置读取位置
* @en Set read position
*/
public set offset(value: number) {
this._offset = value;
}
/**
* @zh 获取剩余可读字节数
* @en Get remaining readable bytes
*/
public get remaining(): number {
return this._buffer.length - this._offset;
}
/**
* @zh 检查是否有更多数据可读
* @en Check if there's more data to read
*/
public hasMore(): boolean {
return this._offset < this._buffer.length;
}
/**
* @zh 读取单个字节
* @en Read single byte
*/
public readUint8(): number {
this.checkBounds(1);
return this._buffer[this._offset++]!;
}
/**
* @zh 读取有符号字节
* @en Read signed byte
*/
public readInt8(): number {
this.checkBounds(1);
return this._view.getInt8(this._offset++);
}
/**
* @zh 读取布尔值
* @en Read boolean
*/
public readBoolean(): boolean {
return this.readUint8() !== 0;
}
/**
* @zh 读取 16 位无符号整数(小端序)
* @en Read 16-bit unsigned integer (little-endian)
*/
public readUint16(): number {
this.checkBounds(2);
const value = this._view.getUint16(this._offset, true);
this._offset += 2;
return value;
}
/**
* @zh 读取 16 位有符号整数(小端序)
* @en Read 16-bit signed integer (little-endian)
*/
public readInt16(): number {
this.checkBounds(2);
const value = this._view.getInt16(this._offset, true);
this._offset += 2;
return value;
}
/**
* @zh 读取 32 位无符号整数(小端序)
* @en Read 32-bit unsigned integer (little-endian)
*/
public readUint32(): number {
this.checkBounds(4);
const value = this._view.getUint32(this._offset, true);
this._offset += 4;
return value;
}
/**
* @zh 读取 32 位有符号整数(小端序)
* @en Read 32-bit signed integer (little-endian)
*/
public readInt32(): number {
this.checkBounds(4);
const value = this._view.getInt32(this._offset, true);
this._offset += 4;
return value;
}
/**
* @zh 读取 32 位浮点数(小端序)
* @en Read 32-bit float (little-endian)
*/
public readFloat32(): number {
this.checkBounds(4);
const value = this._view.getFloat32(this._offset, true);
this._offset += 4;
return value;
}
/**
* @zh 读取 64 位浮点数(小端序)
* @en Read 64-bit float (little-endian)
*/
public readFloat64(): number {
this.checkBounds(8);
const value = this._view.getFloat64(this._offset, true);
this._offset += 8;
return value;
}
/**
* @zh 读取变长整数
* @en Read variable-length integer
*/
public readVarint(): number {
const [value, newOffset] = decodeVarint(this._buffer, this._offset);
this._offset = newOffset;
return value;
}
/**
* @zh 读取字符串UTF-8 编码,带长度前缀)
* @en Read string (UTF-8 encoded with length prefix)
*/
public readString(): string {
const length = this.readVarint();
this.checkBounds(length);
const bytes = this._buffer.subarray(this._offset, this._offset + length);
this._offset += length;
if (textDecoder) {
return textDecoder.decode(bytes);
} else {
return this.utf8BytesToString(bytes);
}
}
/**
* @zh 读取原始字节
* @en Read raw bytes
*
* @param length - @zh 要读取的字节数 @en Number of bytes to read
*/
public readBytes(length: number): Uint8Array {
this.checkBounds(length);
const bytes = this._buffer.slice(this._offset, this._offset + length);
this._offset += length;
return bytes;
}
/**
* @zh 查看下一个字节但不移动读取位置
* @en Peek next byte without advancing read position
*/
public peekUint8(): number {
this.checkBounds(1);
return this._buffer[this._offset]!;
}
/**
* @zh 跳过指定字节数
* @en Skip specified number of bytes
*/
public skip(count: number): void {
this.checkBounds(count);
this._offset += count;
}
/**
* @zh 检查边界
* @en Check bounds
*/
private checkBounds(size: number): void {
if (this._offset + size > this._buffer.length) {
throw new Error(`BinaryReader: buffer overflow (offset=${this._offset}, size=${size}, bufferLength=${this._buffer.length})`);
}
}
/**
* @zh UTF-8 字节转字符串(后备方案)
* @en UTF-8 bytes to string (fallback)
*/
private utf8BytesToString(bytes: Uint8Array): string {
let result = '';
let i = 0;
while (i < bytes.length) {
let charCode: number;
const byte1 = bytes[i++]!;
if (byte1 < 0x80) {
charCode = byte1;
} else if (byte1 < 0xE0) {
const byte2 = bytes[i++]!;
charCode = ((byte1 & 0x1F) << 6) | (byte2 & 0x3F);
} else if (byte1 < 0xF0) {
const byte2 = bytes[i++]!;
const byte3 = bytes[i++]!;
charCode = ((byte1 & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F);
} else {
const byte2 = bytes[i++]!;
const byte3 = bytes[i++]!;
const byte4 = bytes[i++]!;
charCode = ((byte1 & 0x07) << 18) | ((byte2 & 0x3F) << 12) |
((byte3 & 0x3F) << 6) | (byte4 & 0x3F);
// Convert to surrogate pair
if (charCode > 0xFFFF) {
charCode -= 0x10000;
result += String.fromCharCode(0xD800 + (charCode >> 10));
charCode = 0xDC00 + (charCode & 0x3FF);
}
}
result += String.fromCharCode(charCode);
}
return result;
}
}

View File

@@ -0,0 +1,257 @@
/**
* @zh 二进制写入器
* @en Binary Writer
*
* @zh 提供高效的二进制数据写入功能,支持自动扩容
* @en Provides efficient binary data writing with auto-expansion
*/
import { encodeVarint, varintSize } from './varint';
/**
* @zh 文本编码器(使用浏览器原生 API
* @en Text encoder (using browser native API)
*/
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
/**
* @zh 二进制写入器
* @en Binary writer for encoding data
*/
export class BinaryWriter {
/**
* @zh 内部缓冲区
* @en Internal buffer
*/
private _buffer: Uint8Array;
/**
* @zh DataView 用于写入数值
* @en DataView for writing numbers
*/
private _view: DataView;
/**
* @zh 当前写入位置
* @en Current write position
*/
private _offset: number = 0;
/**
* @zh 创建二进制写入器
* @en Create binary writer
*
* @param initialCapacity - @zh 初始容量 @en Initial capacity
*/
constructor(initialCapacity: number = 256) {
this._buffer = new Uint8Array(initialCapacity);
this._view = new DataView(this._buffer.buffer);
}
/**
* @zh 获取当前写入位置
* @en Get current write position
*/
public get offset(): number {
return this._offset;
}
/**
* @zh 获取写入的数据
* @en Get written data
*
* @returns @zh 包含写入数据的 Uint8Array @en Uint8Array containing written data
*/
public toUint8Array(): Uint8Array {
return this._buffer.slice(0, this._offset);
}
/**
* @zh 重置写入器(清空数据但保留缓冲区)
* @en Reset writer (clear data but keep buffer)
*/
public reset(): void {
this._offset = 0;
}
/**
* @zh 确保有足够空间
* @en Ensure enough space
*
* @param size - @zh 需要的额外字节数 @en Extra bytes needed
*/
private ensureCapacity(size: number): void {
const required = this._offset + size;
if (required > this._buffer.length) {
// Double the buffer size or use required size, whichever is larger
const newSize = Math.max(this._buffer.length * 2, required);
const newBuffer = new Uint8Array(newSize);
newBuffer.set(this._buffer);
this._buffer = newBuffer;
this._view = new DataView(this._buffer.buffer);
}
}
/**
* @zh 写入单个字节
* @en Write single byte
*/
public writeUint8(value: number): void {
this.ensureCapacity(1);
this._buffer[this._offset++] = value;
}
/**
* @zh 写入有符号字节
* @en Write signed byte
*/
public writeInt8(value: number): void {
this.ensureCapacity(1);
this._view.setInt8(this._offset++, value);
}
/**
* @zh 写入布尔值
* @en Write boolean
*/
public writeBoolean(value: boolean): void {
this.writeUint8(value ? 1 : 0);
}
/**
* @zh 写入 16 位无符号整数(小端序)
* @en Write 16-bit unsigned integer (little-endian)
*/
public writeUint16(value: number): void {
this.ensureCapacity(2);
this._view.setUint16(this._offset, value, true);
this._offset += 2;
}
/**
* @zh 写入 16 位有符号整数(小端序)
* @en Write 16-bit signed integer (little-endian)
*/
public writeInt16(value: number): void {
this.ensureCapacity(2);
this._view.setInt16(this._offset, value, true);
this._offset += 2;
}
/**
* @zh 写入 32 位无符号整数(小端序)
* @en Write 32-bit unsigned integer (little-endian)
*/
public writeUint32(value: number): void {
this.ensureCapacity(4);
this._view.setUint32(this._offset, value, true);
this._offset += 4;
}
/**
* @zh 写入 32 位有符号整数(小端序)
* @en Write 32-bit signed integer (little-endian)
*/
public writeInt32(value: number): void {
this.ensureCapacity(4);
this._view.setInt32(this._offset, value, true);
this._offset += 4;
}
/**
* @zh 写入 32 位浮点数(小端序)
* @en Write 32-bit float (little-endian)
*/
public writeFloat32(value: number): void {
this.ensureCapacity(4);
this._view.setFloat32(this._offset, value, true);
this._offset += 4;
}
/**
* @zh 写入 64 位浮点数(小端序)
* @en Write 64-bit float (little-endian)
*/
public writeFloat64(value: number): void {
this.ensureCapacity(8);
this._view.setFloat64(this._offset, value, true);
this._offset += 8;
}
/**
* @zh 写入变长整数
* @en Write variable-length integer
*/
public writeVarint(value: number): void {
this.ensureCapacity(varintSize(value));
this._offset = encodeVarint(value, this._buffer, this._offset);
}
/**
* @zh 写入字符串UTF-8 编码,带长度前缀)
* @en Write string (UTF-8 encoded with length prefix)
*/
public writeString(value: string): void {
if (textEncoder) {
const encoded = textEncoder.encode(value);
this.writeVarint(encoded.length);
this.ensureCapacity(encoded.length);
this._buffer.set(encoded, this._offset);
this._offset += encoded.length;
} else {
// Fallback for environments without TextEncoder
const bytes = this.stringToUtf8Bytes(value);
this.writeVarint(bytes.length);
this.ensureCapacity(bytes.length);
this._buffer.set(bytes, this._offset);
this._offset += bytes.length;
}
}
/**
* @zh 写入原始字节
* @en Write raw bytes
*/
public writeBytes(data: Uint8Array): void {
this.ensureCapacity(data.length);
this._buffer.set(data, this._offset);
this._offset += data.length;
}
/**
* @zh 字符串转 UTF-8 字节(后备方案)
* @en String to UTF-8 bytes (fallback)
*/
private stringToUtf8Bytes(str: string): Uint8Array {
const bytes: number[] = [];
for (let i = 0; i < str.length; i++) {
let charCode = str.charCodeAt(i);
// Handle surrogate pairs
if (charCode >= 0xD800 && charCode <= 0xDBFF && i + 1 < str.length) {
const next = str.charCodeAt(i + 1);
if (next >= 0xDC00 && next <= 0xDFFF) {
charCode = 0x10000 + ((charCode - 0xD800) << 10) + (next - 0xDC00);
i++;
}
}
if (charCode < 0x80) {
bytes.push(charCode);
} else if (charCode < 0x800) {
bytes.push(0xC0 | (charCode >> 6));
bytes.push(0x80 | (charCode & 0x3F));
} else if (charCode < 0x10000) {
bytes.push(0xE0 | (charCode >> 12));
bytes.push(0x80 | ((charCode >> 6) & 0x3F));
bytes.push(0x80 | (charCode & 0x3F));
} else {
bytes.push(0xF0 | (charCode >> 18));
bytes.push(0x80 | ((charCode >> 12) & 0x3F));
bytes.push(0x80 | ((charCode >> 6) & 0x3F));
bytes.push(0x80 | (charCode & 0x3F));
}
}
return new Uint8Array(bytes);
}
}

View File

@@ -0,0 +1,404 @@
/**
* @zh 组件状态解码器
* @en Component state decoder
*
* @zh 从二进制格式解码并应用到 ECS Component
* @en Decodes binary format and applies to ECS Components
*/
import type { Entity } from '../../Entity';
import type { Component } from '../../Component';
import type { Scene } from '../../Scene';
import type { SyncType, SyncMetadata } from '../types';
import { SyncOperation, SYNC_METADATA } from '../types';
import { BinaryReader } from './BinaryReader';
/**
* @zh 组件类型注册表
* @en Component type registry
*/
const componentRegistry = new Map<string, new () => Component>();
/**
* @zh 注册组件类型
* @en Register component type
*
* @param typeId - @zh 组件类型 ID @en Component type ID
* @param componentClass - @zh 组件类 @en Component class
*/
export function registerSyncComponent<T extends Component>(
typeId: string,
componentClass: new () => T
): void {
componentRegistry.set(typeId, componentClass);
}
/**
* @zh 从 @ECSComponent 装饰器自动注册
* @en Auto-register from @ECSComponent decorator
*
* @param componentClass - @zh 组件类 @en Component class
*/
export function autoRegisterSyncComponent(componentClass: new () => Component): void {
const metadata: SyncMetadata | undefined = (componentClass as any)[SYNC_METADATA];
if (metadata) {
componentRegistry.set(metadata.typeId, componentClass);
}
}
/**
* @zh 解码字段值
* @en Decode field value
*/
function decodeFieldValue(reader: BinaryReader, type: SyncType): any {
switch (type) {
case 'boolean':
return reader.readBoolean();
case 'int8':
return reader.readInt8();
case 'uint8':
return reader.readUint8();
case 'int16':
return reader.readInt16();
case 'uint16':
return reader.readUint16();
case 'int32':
return reader.readInt32();
case 'uint32':
return reader.readUint32();
case 'float32':
return reader.readFloat32();
case 'float64':
return reader.readFloat64();
case 'string':
return reader.readString();
}
}
/**
* @zh 解码并应用组件数据
* @en Decode and apply component data
*
* @param component - @zh 组件实例 @en Component instance
* @param metadata - @zh 组件同步元数据 @en Component sync metadata
* @param reader - @zh 二进制读取器 @en Binary reader
*/
export function decodeComponent(
component: Component,
metadata: SyncMetadata,
reader: BinaryReader
): void {
const fieldCount = reader.readVarint();
for (let i = 0; i < fieldCount; i++) {
const fieldIndex = reader.readUint8();
const field = metadata.fields[fieldIndex];
if (field) {
const value = decodeFieldValue(reader, field.type);
// Directly set the private backing field to avoid triggering change tracking
(component as any)[`_sync_${field.name}`] = value;
} else {
// Unknown field, skip based on type info in metadata
console.warn(`Unknown sync field index: ${fieldIndex}`);
}
}
}
/**
* @zh 解码实体快照结果
* @en Decode entity snapshot result
*/
export interface DecodeEntityResult {
/**
* @zh 实体 ID
* @en Entity ID
*/
entityId: number;
/**
* @zh 是否为新实体
* @en Whether it's a new entity
*/
isNew: boolean;
/**
* @zh 解码的组件类型列表
* @en List of decoded component types
*/
componentTypes: string[];
}
/**
* @zh 解码并应用实体数据
* @en Decode and apply entity data
*
* @param scene - @zh 场景 @en Scene
* @param reader - @zh 二进制读取器 @en Binary reader
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
* @returns @zh 解码结果 @en Decode result
*/
export function decodeEntity(
scene: Scene,
reader: BinaryReader,
entityMap?: Map<number, Entity>
): DecodeEntityResult {
const entityId = reader.readUint32();
const componentCount = reader.readVarint();
const componentTypes: string[] = [];
// Find or create entity
let entity: Entity | null | undefined = entityMap?.get(entityId);
let isNew = false;
if (!entity) {
entity = scene.findEntityById(entityId);
}
if (!entity) {
// Entity doesn't exist, create it
entity = scene.createEntity(`entity_${entityId}`);
isNew = true;
entityMap?.set(entityId, entity);
}
for (let i = 0; i < componentCount; i++) {
const typeId = reader.readString();
componentTypes.push(typeId);
// Find component class from registry
const componentClass = componentRegistry.get(typeId);
if (!componentClass) {
console.warn(`Unknown component type: ${typeId}`);
// Skip component data - we need to read it to advance the reader
const fieldCount = reader.readVarint();
for (let j = 0; j < fieldCount; j++) {
reader.readUint8(); // fieldIndex
// We can't skip properly without knowing the type, so this is a problem
// For now, log error and break
console.error(`Cannot skip unknown component type: ${typeId}`);
break;
}
continue;
}
const metadata: SyncMetadata | undefined = (componentClass as any)[SYNC_METADATA];
if (!metadata) {
console.warn(`Component ${typeId} has no sync metadata`);
continue;
}
// Find or add component
let component = entity.getComponent(componentClass);
if (!component) {
component = entity.addComponent(new componentClass());
}
// Decode component data
decodeComponent(component, metadata, reader);
}
return { entityId, isNew, componentTypes };
}
/**
* @zh 解码快照结果
* @en Decode snapshot result
*/
export interface DecodeSnapshotResult {
/**
* @zh 操作类型
* @en Operation type
*/
operation: SyncOperation;
/**
* @zh 解码的实体列表
* @en List of decoded entities
*/
entities: DecodeEntityResult[];
}
/**
* @zh 解码状态快照
* @en Decode state snapshot
*
* @param scene - @zh 场景 @en Scene
* @param data - @zh 二进制数据 @en Binary data
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
* @returns @zh 解码结果 @en Decode result
*/
export function decodeSnapshot(
scene: Scene,
data: Uint8Array,
entityMap?: Map<number, Entity>
): DecodeSnapshotResult {
const reader = new BinaryReader(data);
const operation = reader.readUint8() as SyncOperation;
const entityCount = reader.readVarint();
const entities: DecodeEntityResult[] = [];
const map = entityMap || new Map<number, Entity>();
for (let i = 0; i < entityCount; i++) {
const result = decodeEntity(scene, reader, map);
entities.push(result);
}
return { operation, entities };
}
/**
* @zh 解码生成消息结果
* @en Decode spawn message result
*/
export interface DecodeSpawnResult {
/**
* @zh 实体
* @en Entity
*/
entity: Entity;
/**
* @zh 预制体类型
* @en Prefab type
*/
prefabType: string;
/**
* @zh 解码的组件类型列表
* @en List of decoded component types
*/
componentTypes: string[];
}
/**
* @zh 解码实体生成消息
* @en Decode entity spawn message
*
* @param scene - @zh 场景 @en Scene
* @param data - @zh 二进制数据 @en Binary data
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
* @returns @zh 解码结果,如果不是 SPAWN 消息则返回 null @en Decode result, or null if not a SPAWN message
*/
export function decodeSpawn(
scene: Scene,
data: Uint8Array,
entityMap?: Map<number, Entity>
): DecodeSpawnResult | null {
const reader = new BinaryReader(data);
const operation = reader.readUint8();
if (operation !== SyncOperation.SPAWN) {
return null;
}
const entityId = reader.readUint32();
const prefabType = reader.readString();
const componentCount = reader.readVarint();
const componentTypes: string[] = [];
// Create entity
const entity = scene.createEntity(`entity_${entityId}`);
entityMap?.set(entityId, entity);
for (let i = 0; i < componentCount; i++) {
const typeId = reader.readString();
componentTypes.push(typeId);
const componentClass = componentRegistry.get(typeId);
if (!componentClass) {
console.warn(`Unknown component type: ${typeId}`);
// Try to skip
const fieldCount = reader.readVarint();
for (let j = 0; j < fieldCount; j++) {
reader.readUint8();
}
continue;
}
const metadata: SyncMetadata | undefined = (componentClass as any)[SYNC_METADATA];
if (!metadata) {
continue;
}
const component = entity.addComponent(new componentClass());
decodeComponent(component, metadata, reader);
}
return { entity, prefabType, componentTypes };
}
/**
* @zh 解码销毁消息结果
* @en Decode despawn message result
*/
export interface DecodeDespawnResult {
/**
* @zh 销毁的实体 ID 列表
* @en List of despawned entity IDs
*/
entityIds: number[];
}
/**
* @zh 解码实体销毁消息
* @en Decode entity despawn message
*
* @param data - @zh 二进制数据 @en Binary data
* @returns @zh 解码结果,如果不是 DESPAWN 消息则返回 null @en Decode result, or null if not a DESPAWN message
*/
export function decodeDespawn(data: Uint8Array): DecodeDespawnResult | null {
const reader = new BinaryReader(data);
const operation = reader.readUint8();
if (operation !== SyncOperation.DESPAWN) {
return null;
}
const entityIds: number[] = [];
// Check if it's a single entity or batch
if (reader.remaining === 4) {
// Single entity
entityIds.push(reader.readUint32());
} else {
// Batch
const count = reader.readVarint();
for (let i = 0; i < count; i++) {
entityIds.push(reader.readUint32());
}
}
return { entityIds };
}
/**
* @zh 处理销毁消息(从场景中移除实体)
* @en Process despawn message (remove entities from scene)
*
* @param scene - @zh 场景 @en Scene
* @param data - @zh 二进制数据 @en Binary data
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
* @returns @zh 移除的实体 ID 列表 @en List of removed entity IDs
*/
export function processDespawn(
scene: Scene,
data: Uint8Array,
entityMap?: Map<number, Entity>
): number[] {
const result = decodeDespawn(data);
if (!result) {
return [];
}
for (const entityId of result.entityIds) {
const entity = entityMap?.get(entityId) || scene.findEntityById(entityId);
if (entity) {
entity.destroy();
entityMap?.delete(entityId);
}
}
return result.entityIds;
}

View File

@@ -0,0 +1,291 @@
/**
* @zh 组件状态编码器
* @en Component state encoder
*
* @zh 将 ECS Component 的 @sync 字段编码为二进制格式
* @en Encodes @sync fields of ECS Components to binary format
*/
import type { Entity } from '../../Entity';
import type { Component } from '../../Component';
import type { SyncType, SyncMetadata } from '../types';
import { SyncOperation, SYNC_METADATA, CHANGE_TRACKER } from '../types';
import type { ChangeTracker } from '../ChangeTracker';
import { BinaryWriter } from './BinaryWriter';
/**
* @zh 编码单个字段值
* @en Encode a single field value
*/
function encodeFieldValue(writer: BinaryWriter, value: any, type: SyncType): void {
switch (type) {
case 'boolean':
writer.writeBoolean(value);
break;
case 'int8':
writer.writeInt8(value);
break;
case 'uint8':
writer.writeUint8(value);
break;
case 'int16':
writer.writeInt16(value);
break;
case 'uint16':
writer.writeUint16(value);
break;
case 'int32':
writer.writeInt32(value);
break;
case 'uint32':
writer.writeUint32(value);
break;
case 'float32':
writer.writeFloat32(value);
break;
case 'float64':
writer.writeFloat64(value);
break;
case 'string':
writer.writeString(value ?? '');
break;
}
}
/**
* @zh 编码组件的完整状态
* @en Encode full state of a component
*
* @zh 格式: [fieldCount: varint] ([fieldIndex: uint8] [value])...
* @en Format: [fieldCount: varint] ([fieldIndex: uint8] [value])...
*
* @param component - @zh 组件实例 @en Component instance
* @param metadata - @zh 组件同步元数据 @en Component sync metadata
* @param writer - @zh 二进制写入器 @en Binary writer
*/
export function encodeComponentFull(
component: Component,
metadata: SyncMetadata,
writer: BinaryWriter
): void {
const fields = metadata.fields;
writer.writeVarint(fields.length);
for (const field of fields) {
writer.writeUint8(field.index);
const value = (component as any)[field.name];
encodeFieldValue(writer, value, field.type);
}
}
/**
* @zh 编码组件的增量状态(只编码脏字段)
* @en Encode delta state of a component (only dirty fields)
*
* @zh 格式: [dirtyCount: varint] ([fieldIndex: uint8] [value])...
* @en Format: [dirtyCount: varint] ([fieldIndex: uint8] [value])...
*
* @param component - @zh 组件实例 @en Component instance
* @param metadata - @zh 组件同步元数据 @en Component sync metadata
* @param tracker - @zh 变更追踪器 @en Change tracker
* @param writer - @zh 二进制写入器 @en Binary writer
* @returns @zh 是否有数据编码 @en Whether any data was encoded
*/
export function encodeComponentDelta(
component: Component,
metadata: SyncMetadata,
tracker: ChangeTracker,
writer: BinaryWriter
): boolean {
if (!tracker.hasChanges()) {
return false;
}
const dirtyFields = tracker.getDirtyFields();
writer.writeVarint(dirtyFields.length);
for (const fieldIndex of dirtyFields) {
const field = metadata.fields[fieldIndex];
if (field) {
writer.writeUint8(field.index);
const value = (component as any)[field.name];
encodeFieldValue(writer, value, field.type);
}
}
return dirtyFields.length > 0;
}
/**
* @zh 编码实体的所有同步组件
* @en Encode all sync components of an entity
*
* @zh 格式:
* [entityId: uint32]
* [componentCount: varint]
* ([typeIdLength: varint] [typeId: string] [componentData])...
*
* @en Format:
* [entityId: uint32]
* [componentCount: varint]
* ([typeIdLength: varint] [typeId: string] [componentData])...
*
* @param entity - @zh 实体 @en Entity
* @param writer - @zh 二进制写入器 @en Binary writer
* @param deltaOnly - @zh 只编码增量 @en Only encode delta
* @returns @zh 编码的组件数量 @en Number of components encoded
*/
export function encodeEntity(
entity: Entity,
writer: BinaryWriter,
deltaOnly: boolean = false
): number {
writer.writeUint32(entity.id);
const components = entity.components;
const syncComponents: Array<{
component: Component;
metadata: SyncMetadata;
tracker: ChangeTracker | undefined;
}> = [];
// Collect components with sync metadata
for (const component of components) {
const constructor = component.constructor as any;
const metadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
if (metadata && metadata.fields.length > 0) {
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
// For delta encoding, only include components with changes
if (deltaOnly && tracker && !tracker.hasChanges()) {
continue;
}
syncComponents.push({ component, metadata, tracker });
}
}
writer.writeVarint(syncComponents.length);
for (const { component, metadata, tracker } of syncComponents) {
// Write component type ID
writer.writeString(metadata.typeId);
if (deltaOnly && tracker) {
encodeComponentDelta(component, metadata, tracker, writer);
} else {
encodeComponentFull(component, metadata, writer);
}
}
return syncComponents.length;
}
/**
* @zh 编码状态快照(多个实体)
* @en Encode state snapshot (multiple entities)
*
* @zh 格式:
* [operation: uint8] (FULL=0, DELTA=1, SPAWN=2, DESPAWN=3)
* [entityCount: varint]
* (entityData)...
*
* @en Format:
* [operation: uint8] (FULL=0, DELTA=1, SPAWN=2, DESPAWN=3)
* [entityCount: varint]
* (entityData)...
*
* @param entities - @zh 要编码的实体数组 @en Entities to encode
* @param operation - @zh 同步操作类型 @en Sync operation type
* @returns @zh 编码后的二进制数据 @en Encoded binary data
*/
export function encodeSnapshot(
entities: Entity[],
operation: SyncOperation = SyncOperation.FULL
): Uint8Array {
const writer = new BinaryWriter(1024);
writer.writeUint8(operation);
writer.writeVarint(entities.length);
const deltaOnly = operation === SyncOperation.DELTA;
for (const entity of entities) {
encodeEntity(entity, writer, deltaOnly);
}
return writer.toUint8Array();
}
/**
* @zh 编码实体生成消息
* @en Encode entity spawn message
*
* @param entity - @zh 生成的实体 @en Spawned entity
* @param prefabType - @zh 预制体类型(可选)@en Prefab type (optional)
* @returns @zh 编码后的二进制数据 @en Encoded binary data
*/
export function encodeSpawn(entity: Entity, prefabType?: string): Uint8Array {
const writer = new BinaryWriter(256);
writer.writeUint8(SyncOperation.SPAWN);
writer.writeUint32(entity.id);
writer.writeString(prefabType || '');
// Encode all sync components for initial state
const components = entity.components;
const syncComponents: Array<{ component: Component; metadata: SyncMetadata }> = [];
for (const component of components) {
const constructor = component.constructor as any;
const metadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
if (metadata && metadata.fields.length > 0) {
syncComponents.push({ component, metadata });
}
}
writer.writeVarint(syncComponents.length);
for (const { component, metadata } of syncComponents) {
writer.writeString(metadata.typeId);
encodeComponentFull(component, metadata, writer);
}
return writer.toUint8Array();
}
/**
* @zh 编码实体销毁消息
* @en Encode entity despawn message
*
* @param entityId - @zh 销毁的实体 ID @en Despawned entity ID
* @returns @zh 编码后的二进制数据 @en Encoded binary data
*/
export function encodeDespawn(entityId: number): Uint8Array {
const writer = new BinaryWriter(8);
writer.writeUint8(SyncOperation.DESPAWN);
writer.writeUint32(entityId);
return writer.toUint8Array();
}
/**
* @zh 编码批量实体销毁消息
* @en Encode batch entity despawn message
*
* @param entityIds - @zh 销毁的实体 ID 数组 @en Despawned entity IDs
* @returns @zh 编码后的二进制数据 @en Encoded binary data
*/
export function encodeDespawnBatch(entityIds: number[]): Uint8Array {
const writer = new BinaryWriter(8 + entityIds.length * 4);
writer.writeUint8(SyncOperation.DESPAWN);
writer.writeVarint(entityIds.length);
for (const id of entityIds) {
writer.writeUint32(id);
}
return writer.toUint8Array();
}

View File

@@ -0,0 +1,52 @@
/**
* @zh 二进制编解码模块
* @en Binary encoding/decoding module
*
* @zh 提供 ECS Component 状态的二进制序列化和反序列化功能
* @en Provides binary serialization and deserialization for ECS Component state
*/
// Variable-length integer encoding
export {
varintSize,
encodeVarint,
decodeVarint,
zigzagEncode,
zigzagDecode,
encodeSignedVarint,
decodeSignedVarint
} from './varint';
// Binary writer/reader
export { BinaryWriter } from './BinaryWriter';
export { BinaryReader } from './BinaryReader';
// Encoder
export {
encodeComponentFull,
encodeComponentDelta,
encodeEntity,
encodeSnapshot,
encodeSpawn,
encodeDespawn,
encodeDespawnBatch
} from './Encoder';
// Decoder
export {
registerSyncComponent,
autoRegisterSyncComponent,
decodeComponent,
decodeEntity,
decodeSnapshot,
decodeSpawn,
decodeDespawn,
processDespawn
} from './Decoder';
export type {
DecodeEntityResult,
DecodeSnapshotResult,
DecodeSpawnResult,
DecodeDespawnResult
} from './Decoder';

View File

@@ -0,0 +1,137 @@
/**
* @zh 变长整数编解码
* @en Variable-length integer encoding/decoding
*
* @zh 使用 LEB128 编码方式,可变长度编码正整数。
* 小数值使用更少字节,大数值使用更多字节。
* @en Uses LEB128 encoding for variable-length integer encoding.
* Small values use fewer bytes, large values use more bytes.
*
* | 值范围 | 字节数 |
* |--------|--------|
* | 0-127 | 1 |
* | 128-16383 | 2 |
* | 16384-2097151 | 3 |
* | 2097152-268435455 | 4 |
* | 268435456+ | 5 |
*/
/**
* @zh 计算变长整数所需的字节数
* @en Calculate bytes needed for a varint
*
* @param value - @zh 整数值 @en Integer value
* @returns @zh 所需字节数 @en Bytes needed
*/
export function varintSize(value: number): number {
if (value < 0) {
throw new Error('Varint only supports non-negative integers');
}
if (value < 128) return 1;
if (value < 16384) return 2;
if (value < 2097152) return 3;
if (value < 268435456) return 4;
return 5;
}
/**
* @zh 编码变长整数到字节数组
* @en Encode varint to byte array
*
* @param value - @zh 要编码的整数 @en Integer to encode
* @param buffer - @zh 目标缓冲区 @en Target buffer
* @param offset - @zh 写入偏移 @en Write offset
* @returns @zh 写入后的新偏移 @en New offset after writing
*/
export function encodeVarint(value: number, buffer: Uint8Array, offset: number): number {
if (value < 0) {
throw new Error('Varint only supports non-negative integers');
}
while (value >= 0x80) {
buffer[offset++] = (value & 0x7F) | 0x80;
value >>>= 7;
}
buffer[offset++] = value;
return offset;
}
/**
* @zh 从字节数组解码变长整数
* @en Decode varint from byte array
*
* @param buffer - @zh 源缓冲区 @en Source buffer
* @param offset - @zh 读取偏移 @en Read offset
* @returns @zh [解码值, 新偏移] @en [decoded value, new offset]
*/
export function decodeVarint(buffer: Uint8Array, offset: number): [number, number] {
let result = 0;
let shift = 0;
let byte: number;
do {
if (offset >= buffer.length) {
throw new Error('Varint decode: buffer overflow');
}
byte = buffer[offset++]!;
result |= (byte & 0x7F) << shift;
shift += 7;
} while (byte >= 0x80);
return [result, offset];
}
/**
* @zh 编码有符号整数ZigZag 编码)
* @en Encode signed integer (ZigZag encoding)
*
* @zh ZigZag 编码将有符号整数映射到无符号整数:
* 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, ...
* 这样小的负数也能用较少字节表示。
* @en ZigZag encoding maps signed integers to unsigned:
* 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, ...
* This allows small negative numbers to use fewer bytes.
*
* @param value - @zh 有符号整数 @en Signed integer
* @returns @zh ZigZag 编码后的值 @en ZigZag encoded value
*/
export function zigzagEncode(value: number): number {
return (value << 1) ^ (value >> 31);
}
/**
* @zh 解码有符号整数ZigZag 解码)
* @en Decode signed integer (ZigZag decoding)
*
* @param value - @zh ZigZag 编码的值 @en ZigZag encoded value
* @returns @zh 原始有符号整数 @en Original signed integer
*/
export function zigzagDecode(value: number): number {
return (value >>> 1) ^ -(value & 1);
}
/**
* @zh 编码有符号变长整数
* @en Encode signed varint
*
* @param value - @zh 有符号整数 @en Signed integer
* @param buffer - @zh 目标缓冲区 @en Target buffer
* @param offset - @zh 写入偏移 @en Write offset
* @returns @zh 写入后的新偏移 @en New offset after writing
*/
export function encodeSignedVarint(value: number, buffer: Uint8Array, offset: number): number {
return encodeVarint(zigzagEncode(value), buffer, offset);
}
/**
* @zh 解码有符号变长整数
* @en Decode signed varint
*
* @param buffer - @zh 源缓冲区 @en Source buffer
* @param offset - @zh 读取偏移 @en Read offset
* @returns @zh [解码值, 新偏移] @en [decoded value, new offset]
*/
export function decodeSignedVarint(buffer: Uint8Array, offset: number): [number, number] {
const [encoded, newOffset] = decodeVarint(buffer, offset);
return [zigzagDecode(encoded), newOffset];
}

View File

@@ -0,0 +1,55 @@
/**
* @zh ECS 网络同步模块
* @en ECS Network Synchronization Module
*
* @zh 提供基于 ECS Component 的网络状态同步功能:
* - @sync 装饰器:标记需要同步的字段
* - ChangeTracker追踪字段级变更
* - 二进制编解码器:高效的网络序列化
*
* @en Provides network state synchronization based on ECS Components:
* - @sync decorator: Mark fields for synchronization
* - ChangeTracker: Track field-level changes
* - Binary encoder/decoder: Efficient network serialization
*
* @example
* ```typescript
* import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
*
* @ECSComponent('Player')
* class PlayerComponent extends Component {
* @sync("string") name: string = "";
* @sync("uint16") score: number = 0;
* @sync("float32") x: number = 0;
* @sync("float32") y: number = 0;
* }
* ```
*/
// Types
export {
SyncType,
SyncFieldMetadata,
SyncMetadata,
SyncOperation,
TYPE_SIZES,
SYNC_METADATA,
CHANGE_TRACKER
} from './types';
// Change Tracker
export { ChangeTracker } from './ChangeTracker';
// Decorators
export {
sync,
getSyncMetadata,
hasSyncFields,
getChangeTracker,
initChangeTracker,
clearChanges,
hasChanges
} from './decorators';
// Encoding
export * from './encoding';

View File

@@ -0,0 +1,127 @@
/**
* @zh 网络同步类型定义
* @en Network synchronization type definitions
*/
/**
* @zh 支持的同步数据类型
* @en Supported sync data types
*/
export type SyncType =
| 'boolean'
| 'int8'
| 'uint8'
| 'int16'
| 'uint16'
| 'int32'
| 'uint32'
| 'float32'
| 'float64'
| 'string';
/**
* @zh 同步字段元数据
* @en Sync field metadata
*/
export interface SyncFieldMetadata {
/**
* @zh 字段索引(用于二进制编码)
* @en Field index (for binary encoding)
*/
index: number;
/**
* @zh 字段名称
* @en Field name
*/
name: string;
/**
* @zh 字段类型
* @en Field type
*/
type: SyncType;
}
/**
* @zh 组件同步元数据
* @en Component sync metadata
*/
export interface SyncMetadata {
/**
* @zh 组件类型 ID
* @en Component type ID
*/
typeId: string;
/**
* @zh 同步字段列表(按索引排序)
* @en Sync fields list (sorted by index)
*/
fields: SyncFieldMetadata[];
/**
* @zh 字段名到索引的映射
* @en Field name to index mapping
*/
fieldIndexMap: Map<string, number>;
}
/**
* @zh 同步操作类型
* @en Sync operation type
*/
export enum SyncOperation {
/**
* @zh 完整快照
* @en Full snapshot
*/
FULL = 0,
/**
* @zh 增量更新
* @en Delta update
*/
DELTA = 1,
/**
* @zh 实体生成
* @en Entity spawn
*/
SPAWN = 2,
/**
* @zh 实体销毁
* @en Entity despawn
*/
DESPAWN = 3,
}
/**
* @zh 各类型的字节大小
* @en Byte size for each type
*/
export const TYPE_SIZES: Record<SyncType, number> = {
boolean: 1,
int8: 1,
uint8: 1,
int16: 2,
uint16: 2,
int32: 4,
uint32: 4,
float32: 4,
float64: 8,
string: -1, // 动态长度 | dynamic length
};
/**
* @zh 同步元数据的 Symbol 键
* @en Symbol key for sync metadata
*/
export const SYNC_METADATA = Symbol('SyncMetadata');
/**
* @zh 变更追踪器的 Symbol 键
* @en Symbol key for change tracker
*/
export const CHANGE_TRACKER = Symbol('ChangeTracker');

View File

@@ -317,9 +317,7 @@ export class WorldManager implements IService {
/**
* @zh 更新所有活跃的World
* @en Update all active Worlds
*
* @zh 应该在每帧的游戏循环中调用
* @en Should be called in each frame of game loop
* @internal 由 Core.update() 调用,用户不应直接调用
*/
public updateAll(): void {
if (!this._isRunning) return;

View File

@@ -57,3 +57,6 @@ export { EpochManager } from './Core/EpochManager';
// Compiled Query
export { CompiledQuery } from './Core/Query/CompiledQuery';
export type { InstanceTypes } from './Core/Query/CompiledQuery';
// Network Synchronization
export * from './Sync';

View File

@@ -0,0 +1,172 @@
import { ChangeTracker } from '../../../src/ECS/Sync/ChangeTracker';
describe('ChangeTracker - 变更追踪器测试', () => {
let tracker: ChangeTracker;
beforeEach(() => {
tracker = new ChangeTracker();
});
describe('基本功能', () => {
test('初始状态应该没有变更', () => {
expect(tracker.hasChanges()).toBe(false);
expect(tracker.getDirtyCount()).toBe(0);
expect(tracker.getDirtyFields()).toEqual([]);
});
test('setDirty 应该标记字段为脏', () => {
tracker.setDirty(0);
expect(tracker.hasChanges()).toBe(true);
expect(tracker.isDirty(0)).toBe(true);
expect(tracker.getDirtyCount()).toBe(1);
expect(tracker.getDirtyFields()).toEqual([0]);
});
test('多次 setDirty 同一字段应该只记录一次', () => {
tracker.setDirty(0);
tracker.setDirty(0);
tracker.setDirty(0);
expect(tracker.getDirtyCount()).toBe(1);
expect(tracker.getDirtyFields()).toEqual([0]);
});
test('setDirty 不同字段应该都被记录', () => {
tracker.setDirty(0);
tracker.setDirty(1);
tracker.setDirty(2);
expect(tracker.getDirtyCount()).toBe(3);
expect(tracker.getDirtyFields().sort()).toEqual([0, 1, 2]);
});
});
describe('isDirty 方法', () => {
test('未标记的字段应该返回 false', () => {
expect(tracker.isDirty(0)).toBe(false);
expect(tracker.isDirty(5)).toBe(false);
});
test('已标记的字段应该返回 true', () => {
tracker.setDirty(3);
expect(tracker.isDirty(3)).toBe(true);
expect(tracker.isDirty(0)).toBe(false);
});
});
describe('clear 方法', () => {
test('clear 应该清除所有变更', () => {
tracker.setDirty(0);
tracker.setDirty(1);
tracker.setDirty(2);
expect(tracker.hasChanges()).toBe(true);
tracker.clear();
expect(tracker.hasChanges()).toBe(false);
expect(tracker.getDirtyCount()).toBe(0);
expect(tracker.getDirtyFields()).toEqual([]);
});
test('clear 应该更新 lastSyncTime', () => {
const before = tracker.lastSyncTime;
tracker.setDirty(0);
tracker.clear();
expect(tracker.lastSyncTime).toBeGreaterThan(0);
});
});
describe('clearField 方法', () => {
test('clearField 应该只清除指定字段', () => {
tracker.setDirty(0);
tracker.setDirty(1);
tracker.setDirty(2);
tracker.clearField(1);
expect(tracker.isDirty(0)).toBe(true);
expect(tracker.isDirty(1)).toBe(false);
expect(tracker.isDirty(2)).toBe(true);
expect(tracker.getDirtyCount()).toBe(2);
});
test('清除最后一个字段应该使 hasChanges 返回 false', () => {
tracker.setDirty(0);
expect(tracker.hasChanges()).toBe(true);
tracker.clearField(0);
expect(tracker.hasChanges()).toBe(false);
});
});
describe('markAllDirty 方法', () => {
test('markAllDirty 应该标记所有字段', () => {
tracker.markAllDirty(5);
expect(tracker.hasChanges()).toBe(true);
expect(tracker.getDirtyCount()).toBe(5);
expect(tracker.getDirtyFields().sort()).toEqual([0, 1, 2, 3, 4]);
});
test('markAllDirty(0) 应该没有变更', () => {
tracker.markAllDirty(0);
expect(tracker.hasChanges()).toBe(false);
expect(tracker.getDirtyCount()).toBe(0);
});
test('markAllDirty 用于首次同步', () => {
tracker.markAllDirty(3);
expect(tracker.isDirty(0)).toBe(true);
expect(tracker.isDirty(1)).toBe(true);
expect(tracker.isDirty(2)).toBe(true);
expect(tracker.isDirty(3)).toBe(false);
});
});
describe('reset 方法', () => {
test('reset 应该重置所有状态', () => {
tracker.setDirty(0);
tracker.setDirty(1);
tracker.clear();
tracker.reset();
expect(tracker.hasChanges()).toBe(false);
expect(tracker.getDirtyCount()).toBe(0);
expect(tracker.lastSyncTime).toBe(0);
});
});
describe('边界情况', () => {
test('大量字段标记应该正常工作', () => {
const fieldCount = 1000;
for (let i = 0; i < fieldCount; i++) {
tracker.setDirty(i);
}
expect(tracker.getDirtyCount()).toBe(fieldCount);
expect(tracker.hasChanges()).toBe(true);
});
test('交替设置和清除应该正常工作', () => {
tracker.setDirty(0);
tracker.setDirty(1);
tracker.clearField(0);
tracker.setDirty(2);
tracker.clearField(1);
expect(tracker.isDirty(0)).toBe(false);
expect(tracker.isDirty(1)).toBe(false);
expect(tracker.isDirty(2)).toBe(true);
expect(tracker.getDirtyCount()).toBe(1);
});
});
});

View File

@@ -0,0 +1,327 @@
import { Component } from '../../../src/ECS/Component';
import { ECSComponent } from '../../../src/ECS/Decorators';
import { Scene } from '../../../src/ECS/Scene';
import {
sync,
getSyncMetadata,
hasSyncFields,
getChangeTracker,
initChangeTracker,
clearChanges,
hasChanges
} from '../../../src/ECS/Sync/decorators';
import { SYNC_METADATA, CHANGE_TRACKER } from '../../../src/ECS/Sync/types';
@ECSComponent('SyncTest_PlayerComponent')
class PlayerComponent extends Component {
@sync("string") name: string = "";
@sync("uint16") score: number = 0;
@sync("float32") x: number = 0;
@sync("float32") y: number = 0;
localData: string = "not synced";
}
@ECSComponent('SyncTest_SimpleComponent')
class SimpleComponent extends Component {
@sync("boolean") active: boolean = true;
@sync("int32") value: number = 100;
}
@ECSComponent('SyncTest_NoSyncComponent')
class NoSyncComponent extends Component {
localValue: number = 0;
}
@ECSComponent('SyncTest_AllTypesComponent')
class AllTypesComponent extends Component {
@sync("boolean") boolField: boolean = false;
@sync("int8") int8Field: number = 0;
@sync("uint8") uint8Field: number = 0;
@sync("int16") int16Field: number = 0;
@sync("uint16") uint16Field: number = 0;
@sync("int32") int32Field: number = 0;
@sync("uint32") uint32Field: number = 0;
@sync("float32") float32Field: number = 0;
@sync("float64") float64Field: number = 0;
@sync("string") stringField: string = "";
}
describe('@sync 装饰器测试', () => {
describe('getSyncMetadata', () => {
test('应该返回带 @sync 字段的组件元数据', () => {
const metadata = getSyncMetadata(PlayerComponent);
expect(metadata).not.toBeNull();
expect(metadata!.typeId).toBe('PlayerComponent');
expect(metadata!.fields.length).toBe(4);
});
test('应该正确记录字段信息', () => {
const metadata = getSyncMetadata(PlayerComponent);
const nameField = metadata!.fields.find(f => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.type).toBe('string');
expect(nameField!.index).toBe(0);
const scoreField = metadata!.fields.find(f => f.name === 'score');
expect(scoreField).toBeDefined();
expect(scoreField!.type).toBe('uint16');
const xField = metadata!.fields.find(f => f.name === 'x');
expect(xField).toBeDefined();
expect(xField!.type).toBe('float32');
});
test('没有 @sync 字段的组件应该返回 null', () => {
const metadata = getSyncMetadata(NoSyncComponent);
expect(metadata).toBeNull();
});
test('可以从实例获取元数据', () => {
const component = new PlayerComponent();
const metadata = getSyncMetadata(component);
expect(metadata).not.toBeNull();
expect(metadata!.fields.length).toBe(4);
});
test('fieldIndexMap 应该正确映射字段名到索引', () => {
const metadata = getSyncMetadata(PlayerComponent);
expect(metadata!.fieldIndexMap.get('name')).toBe(0);
expect(metadata!.fieldIndexMap.get('score')).toBe(1);
expect(metadata!.fieldIndexMap.get('x')).toBe(2);
expect(metadata!.fieldIndexMap.get('y')).toBe(3);
});
});
describe('hasSyncFields', () => {
test('有 @sync 字段应该返回 true', () => {
expect(hasSyncFields(PlayerComponent)).toBe(true);
expect(hasSyncFields(new PlayerComponent())).toBe(true);
});
test('没有 @sync 字段应该返回 false', () => {
expect(hasSyncFields(NoSyncComponent)).toBe(false);
expect(hasSyncFields(new NoSyncComponent())).toBe(false);
});
});
describe('支持所有同步类型', () => {
test('AllTypesComponent 应该有所有类型的字段', () => {
const metadata = getSyncMetadata(AllTypesComponent);
expect(metadata).not.toBeNull();
expect(metadata!.fields.length).toBe(10);
const types = metadata!.fields.map(f => f.type);
expect(types).toContain('boolean');
expect(types).toContain('int8');
expect(types).toContain('uint8');
expect(types).toContain('int16');
expect(types).toContain('uint16');
expect(types).toContain('int32');
expect(types).toContain('uint32');
expect(types).toContain('float32');
expect(types).toContain('float64');
expect(types).toContain('string');
});
});
describe('字段值拦截', () => {
test('修改 @sync 字段应该触发变更追踪', () => {
const component = new PlayerComponent();
initChangeTracker(component);
const tracker = getChangeTracker(component);
expect(tracker).not.toBeNull();
tracker!.clear();
component.name = "TestPlayer";
expect(tracker!.hasChanges()).toBe(true);
expect(tracker!.isDirty(0)).toBe(true);
});
test('设置相同值不应该触发变更', () => {
const component = new PlayerComponent();
component.name = "Test";
initChangeTracker(component);
const tracker = getChangeTracker(component);
tracker!.clear();
component.name = "Test";
expect(tracker!.hasChanges()).toBe(false);
});
test('修改非 @sync 字段不应该触发变更', () => {
const component = new PlayerComponent();
initChangeTracker(component);
const tracker = getChangeTracker(component);
tracker!.clear();
component.localData = "new value";
expect(tracker!.hasChanges()).toBe(false);
});
test('多个字段变更应该都被追踪', () => {
const component = new PlayerComponent();
initChangeTracker(component);
const tracker = getChangeTracker(component);
tracker!.clear();
component.name = "NewName";
component.score = 100;
component.x = 1.5;
expect(tracker!.getDirtyCount()).toBe(3);
expect(tracker!.isDirty(0)).toBe(true);
expect(tracker!.isDirty(1)).toBe(true);
expect(tracker!.isDirty(2)).toBe(true);
expect(tracker!.isDirty(3)).toBe(false);
});
});
describe('initChangeTracker', () => {
test('应该创建变更追踪器', () => {
const component = new PlayerComponent();
expect(getChangeTracker(component)).toBeNull();
initChangeTracker(component);
expect(getChangeTracker(component)).not.toBeNull();
});
test('应该标记所有字段为脏(用于首次同步)', () => {
const component = new PlayerComponent();
initChangeTracker(component);
const tracker = getChangeTracker(component);
expect(tracker!.hasChanges()).toBe(true);
expect(tracker!.getDirtyCount()).toBe(4);
});
test('对没有 @sync 字段的组件应该抛出错误', () => {
const component = new NoSyncComponent();
expect(() => {
initChangeTracker(component);
}).toThrow();
});
test('重复初始化应该重新标记所有字段', () => {
const component = new PlayerComponent();
initChangeTracker(component);
const tracker = getChangeTracker(component);
tracker!.clear();
expect(tracker!.hasChanges()).toBe(false);
initChangeTracker(component);
expect(tracker!.hasChanges()).toBe(true);
expect(tracker!.getDirtyCount()).toBe(4);
});
});
describe('clearChanges', () => {
test('应该清除所有变更标记', () => {
const component = new PlayerComponent();
initChangeTracker(component);
expect(hasChanges(component)).toBe(true);
clearChanges(component);
expect(hasChanges(component)).toBe(false);
});
test('对没有追踪器的组件应该安全执行', () => {
const component = new PlayerComponent();
expect(() => {
clearChanges(component);
}).not.toThrow();
});
});
describe('hasChanges', () => {
test('初始化后应该有变更', () => {
const component = new PlayerComponent();
initChangeTracker(component);
expect(hasChanges(component)).toBe(true);
});
test('清除后应该没有变更', () => {
const component = new PlayerComponent();
initChangeTracker(component);
clearChanges(component);
expect(hasChanges(component)).toBe(false);
});
test('修改字段后应该有变更', () => {
const component = new PlayerComponent();
initChangeTracker(component);
clearChanges(component);
component.score = 999;
expect(hasChanges(component)).toBe(true);
});
test('没有追踪器应该返回 false', () => {
const component = new PlayerComponent();
expect(hasChanges(component)).toBe(false);
});
});
describe('与实体集成', () => {
let scene: Scene;
beforeEach(() => {
scene = new Scene();
});
test('添加到实体的组件应该能正常工作', () => {
const entity = scene.createEntity('TestEntity');
const component = new PlayerComponent();
entity.addComponent(component);
initChangeTracker(component);
component.name = "EntityPlayer";
component.x = 100;
const tracker = getChangeTracker(component);
expect(tracker!.hasChanges()).toBe(true);
});
test('从实体获取的组件应该保持追踪状态', () => {
const entity = scene.createEntity('TestEntity');
const component = new PlayerComponent();
entity.addComponent(component);
initChangeTracker(component);
clearChanges(component);
const retrieved = entity.getComponent(PlayerComponent);
retrieved!.score = 50;
expect(hasChanges(component)).toBe(true);
expect(hasChanges(retrieved!)).toBe(true);
});
});
});

View File

@@ -0,0 +1,538 @@
import { BinaryWriter } from '../../../src/ECS/Sync/encoding/BinaryWriter';
import { BinaryReader } from '../../../src/ECS/Sync/encoding/BinaryReader';
import { Component } from '../../../src/ECS/Component';
import { ECSComponent } from '../../../src/ECS/Decorators';
import { Scene } from '../../../src/ECS/Scene';
import { sync, initChangeTracker, clearChanges } from '../../../src/ECS/Sync/decorators';
import {
encodeSnapshot,
encodeSpawn,
encodeDespawn,
encodeDespawnBatch
} from '../../../src/ECS/Sync/encoding/Encoder';
import {
decodeSnapshot,
decodeSpawn,
processDespawn,
registerSyncComponent
} from '../../../src/ECS/Sync/encoding/Decoder';
import { SyncOperation } from '../../../src/ECS/Sync/types';
@ECSComponent('EncodingTest_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('EncodingTest_AllTypesComponent')
class AllTypesComponent extends Component {
@sync("boolean") boolField: boolean = false;
@sync("int8") int8Field: number = 0;
@sync("uint8") uint8Field: number = 0;
@sync("int16") int16Field: number = 0;
@sync("uint16") uint16Field: number = 0;
@sync("int32") int32Field: number = 0;
@sync("uint32") uint32Field: number = 0;
@sync("float32") float32Field: number = 0;
@sync("float64") float64Field: number = 0;
@sync("string") stringField: string = "";
}
describe('BinaryWriter/BinaryReader - 二进制读写器测试', () => {
describe('基本数值类型', () => {
test('writeUint8/readUint8', () => {
const writer = new BinaryWriter();
writer.writeUint8(0);
writer.writeUint8(127);
writer.writeUint8(255);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readUint8()).toBe(0);
expect(reader.readUint8()).toBe(127);
expect(reader.readUint8()).toBe(255);
});
test('writeInt8/readInt8', () => {
const writer = new BinaryWriter();
writer.writeInt8(-128);
writer.writeInt8(0);
writer.writeInt8(127);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readInt8()).toBe(-128);
expect(reader.readInt8()).toBe(0);
expect(reader.readInt8()).toBe(127);
});
test('writeBoolean/readBoolean', () => {
const writer = new BinaryWriter();
writer.writeBoolean(true);
writer.writeBoolean(false);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readBoolean()).toBe(true);
expect(reader.readBoolean()).toBe(false);
});
test('writeUint16/readUint16', () => {
const writer = new BinaryWriter();
writer.writeUint16(0);
writer.writeUint16(32767);
writer.writeUint16(65535);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readUint16()).toBe(0);
expect(reader.readUint16()).toBe(32767);
expect(reader.readUint16()).toBe(65535);
});
test('writeInt16/readInt16', () => {
const writer = new BinaryWriter();
writer.writeInt16(-32768);
writer.writeInt16(0);
writer.writeInt16(32767);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readInt16()).toBe(-32768);
expect(reader.readInt16()).toBe(0);
expect(reader.readInt16()).toBe(32767);
});
test('writeUint32/readUint32', () => {
const writer = new BinaryWriter();
writer.writeUint32(0);
writer.writeUint32(2147483647);
writer.writeUint32(4294967295);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readUint32()).toBe(0);
expect(reader.readUint32()).toBe(2147483647);
expect(reader.readUint32()).toBe(4294967295);
});
test('writeInt32/readInt32', () => {
const writer = new BinaryWriter();
writer.writeInt32(-2147483648);
writer.writeInt32(0);
writer.writeInt32(2147483647);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readInt32()).toBe(-2147483648);
expect(reader.readInt32()).toBe(0);
expect(reader.readInt32()).toBe(2147483647);
});
test('writeFloat32/readFloat32', () => {
const writer = new BinaryWriter();
writer.writeFloat32(0);
writer.writeFloat32(3.14);
writer.writeFloat32(-100.5);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readFloat32()).toBe(0);
expect(reader.readFloat32()).toBeCloseTo(3.14, 5);
expect(reader.readFloat32()).toBeCloseTo(-100.5, 5);
});
test('writeFloat64/readFloat64', () => {
const writer = new BinaryWriter();
writer.writeFloat64(0);
writer.writeFloat64(Math.PI);
writer.writeFloat64(-1e100);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readFloat64()).toBe(0);
expect(reader.readFloat64()).toBe(Math.PI);
expect(reader.readFloat64()).toBe(-1e100);
});
});
describe('变长整数 (Varint)', () => {
test('小值 (1字节)', () => {
const writer = new BinaryWriter();
writer.writeVarint(0);
writer.writeVarint(127);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readVarint()).toBe(0);
expect(reader.readVarint()).toBe(127);
});
test('中等值 (2字节)', () => {
const writer = new BinaryWriter();
writer.writeVarint(128);
writer.writeVarint(16383);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readVarint()).toBe(128);
expect(reader.readVarint()).toBe(16383);
});
test('大值 (多字节)', () => {
const writer = new BinaryWriter();
writer.writeVarint(16384);
writer.writeVarint(1000000);
writer.writeVarint(2147483647);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readVarint()).toBe(16384);
expect(reader.readVarint()).toBe(1000000);
expect(reader.readVarint()).toBe(2147483647);
});
});
describe('字符串', () => {
test('空字符串', () => {
const writer = new BinaryWriter();
writer.writeString("");
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readString()).toBe("");
});
test('ASCII 字符串', () => {
const writer = new BinaryWriter();
writer.writeString("Hello, World!");
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readString()).toBe("Hello, World!");
});
test('Unicode 字符串', () => {
const writer = new BinaryWriter();
writer.writeString("你好世界");
writer.writeString("日本語テスト");
writer.writeString("emoji: 🎮🎯");
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readString()).toBe("你好世界");
expect(reader.readString()).toBe("日本語テスト");
expect(reader.readString()).toBe("emoji: 🎮🎯");
});
test('混合字符串', () => {
const writer = new BinaryWriter();
writer.writeString("Player_玩家_プレイヤー");
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.readString()).toBe("Player_玩家_プレイヤー");
});
});
describe('字节数组', () => {
test('writeBytes/readBytes', () => {
const writer = new BinaryWriter();
const data = new Uint8Array([1, 2, 3, 4, 5]);
writer.writeBytes(data);
const reader = new BinaryReader(writer.toUint8Array());
const result = reader.readBytes(5);
expect(Array.from(result)).toEqual([1, 2, 3, 4, 5]);
});
});
describe('BinaryReader 辅助方法', () => {
test('remaining 应该返回剩余字节数', () => {
const writer = new BinaryWriter();
writer.writeUint32(100);
writer.writeUint32(200);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.remaining).toBe(8);
reader.readUint32();
expect(reader.remaining).toBe(4);
});
test('hasMore 应该正确判断', () => {
const writer = new BinaryWriter();
writer.writeUint8(1);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.hasMore()).toBe(true);
reader.readUint8();
expect(reader.hasMore()).toBe(false);
});
test('peekUint8 不应该移动读取位置', () => {
const writer = new BinaryWriter();
writer.writeUint8(42);
writer.writeUint8(99);
const reader = new BinaryReader(writer.toUint8Array());
expect(reader.peekUint8()).toBe(42);
expect(reader.peekUint8()).toBe(42);
expect(reader.offset).toBe(0);
});
test('skip 应该跳过指定字节', () => {
const writer = new BinaryWriter();
writer.writeUint8(1);
writer.writeUint8(2);
writer.writeUint8(3);
const reader = new BinaryReader(writer.toUint8Array());
reader.skip(2);
expect(reader.readUint8()).toBe(3);
});
test('读取超出范围应该抛出错误', () => {
const reader = new BinaryReader(new Uint8Array([1, 2]));
expect(() => reader.readUint32()).toThrow();
});
});
describe('BinaryWriter 自动扩容', () => {
test('应该自动扩容', () => {
const writer = new BinaryWriter(4);
for (let i = 0; i < 100; i++) {
writer.writeUint32(i);
}
expect(writer.offset).toBe(400);
const reader = new BinaryReader(writer.toUint8Array());
for (let i = 0; i < 100; i++) {
expect(reader.readUint32()).toBe(i);
}
});
test('reset 应该清空数据但保留缓冲区', () => {
const writer = new BinaryWriter();
writer.writeUint32(100);
writer.writeUint32(200);
expect(writer.offset).toBe(8);
writer.reset();
expect(writer.offset).toBe(0);
expect(writer.toUint8Array().length).toBe(0);
});
});
});
describe('Encoder/Decoder - 实体编解码测试', () => {
let scene: Scene;
beforeAll(() => {
registerSyncComponent('PlayerComponent', PlayerComponent);
registerSyncComponent('AllTypesComponent', AllTypesComponent);
});
beforeEach(() => {
scene = new Scene();
});
describe('encodeSnapshot/decodeSnapshot', () => {
test('应该编码和解码单个实体', () => {
const entity = scene.createEntity('Player1');
const comp = entity.addComponent(new PlayerComponent());
comp.name = "TestPlayer";
comp.score = 100;
comp.x = 10.5;
comp.y = 20.5;
initChangeTracker(comp);
const data = encodeSnapshot([entity], SyncOperation.FULL);
const targetScene = new Scene();
const result = decodeSnapshot(targetScene, data);
expect(result.operation).toBe(SyncOperation.FULL);
expect(result.entities.length).toBe(1);
expect(result.entities[0].isNew).toBe(true);
const decodedEntity = targetScene.entities.buffer[0];
expect(decodedEntity).toBeDefined();
const decodedComp = decodedEntity!.getComponent(PlayerComponent);
expect(decodedComp).not.toBeNull();
expect(decodedComp!.name).toBe("TestPlayer");
expect(decodedComp!.score).toBe(100);
expect(decodedComp!.x).toBeCloseTo(10.5, 5);
expect(decodedComp!.y).toBeCloseTo(20.5, 5);
});
test('应该编码和解码多个实体', () => {
const entity1 = scene.createEntity('Player1');
const comp1 = entity1.addComponent(new PlayerComponent());
comp1.name = "Player1";
comp1.score = 50;
initChangeTracker(comp1);
const entity2 = scene.createEntity('Player2');
const comp2 = entity2.addComponent(new PlayerComponent());
comp2.name = "Player2";
comp2.score = 100;
initChangeTracker(comp2);
const data = encodeSnapshot([entity1, entity2], SyncOperation.FULL);
const targetScene = new Scene();
const result = decodeSnapshot(targetScene, data);
expect(result.entities.length).toBe(2);
});
test('DELTA 操作应该只编码变更的字段', () => {
const entity = scene.createEntity('Player1');
const comp = entity.addComponent(new PlayerComponent());
comp.name = "TestPlayer";
comp.score = 0;
initChangeTracker(comp);
clearChanges(comp);
comp.score = 200;
const deltaData = encodeSnapshot([entity], SyncOperation.DELTA);
expect(deltaData[0]).toBe(SyncOperation.DELTA);
expect(deltaData.length).toBeLessThan(50);
});
});
describe('encodeSpawn/decodeSpawn', () => {
test('应该编码和解码实体生成', () => {
const entity = scene.createEntity('SpawnedEntity');
const comp = entity.addComponent(new PlayerComponent());
comp.name = "SpawnedPlayer";
comp.score = 50;
comp.x = 100;
comp.y = 200;
initChangeTracker(comp);
const data = encodeSpawn(entity, 'Player');
const targetScene = new Scene();
const result = decodeSpawn(targetScene, data);
expect(result).not.toBeNull();
expect(result!.prefabType).toBe('Player');
expect(result!.componentTypes).toContain('PlayerComponent');
const decodedComp = result!.entity.getComponent(PlayerComponent);
expect(decodedComp!.name).toBe("SpawnedPlayer");
expect(decodedComp!.score).toBe(50);
});
test('没有 prefabType 应该也能工作', () => {
const entity = scene.createEntity('Entity');
const comp = entity.addComponent(new PlayerComponent());
initChangeTracker(comp);
const data = encodeSpawn(entity);
const targetScene = new Scene();
const result = decodeSpawn(targetScene, data);
expect(result!.prefabType).toBe('');
});
});
describe('encodeDespawn/processDespawn', () => {
test('应该编码和处理单个实体销毁', () => {
const targetScene = new Scene();
const entity = targetScene.createEntity('ToBeDestroyed');
const entityId = entity.id;
const data = encodeDespawn(entityId);
expect(data[0]).toBe(SyncOperation.DESPAWN);
const removedIds = processDespawn(targetScene, data);
expect(removedIds).toContain(entityId);
});
test('应该编码和处理批量实体销毁', () => {
const targetScene = new Scene();
const entity1 = targetScene.createEntity('Entity1');
const entity2 = targetScene.createEntity('Entity2');
const entity3 = targetScene.createEntity('Entity3');
const data = encodeDespawnBatch([entity1.id, entity2.id, entity3.id]);
expect(data[0]).toBe(SyncOperation.DESPAWN);
const removedIds = processDespawn(targetScene, data);
expect(removedIds.length).toBe(3);
expect(removedIds).toContain(entity1.id);
expect(removedIds).toContain(entity2.id);
expect(removedIds).toContain(entity3.id);
});
});
describe('所有同步类型编解码', () => {
beforeAll(() => {
registerSyncComponent('AllTypesComponent', AllTypesComponent);
});
test('应该正确编解码所有类型', () => {
const entity = scene.createEntity('AllTypes');
const comp = entity.addComponent(new AllTypesComponent());
comp.boolField = true;
comp.int8Field = -100;
comp.uint8Field = 200;
comp.int16Field = -30000;
comp.uint16Field = 60000;
comp.int32Field = -2000000000;
comp.uint32Field = 4000000000;
comp.float32Field = 3.14159;
comp.float64Field = Math.PI;
comp.stringField = "测试字符串";
initChangeTracker(comp);
const data = encodeSnapshot([entity], SyncOperation.FULL);
const targetScene = new Scene();
decodeSnapshot(targetScene, data);
const decodedEntity = targetScene.entities.buffer[0];
const decodedComp = decodedEntity!.getComponent(AllTypesComponent);
expect(decodedComp!.boolField).toBe(true);
expect(decodedComp!.int8Field).toBe(-100);
expect(decodedComp!.uint8Field).toBe(200);
expect(decodedComp!.int16Field).toBe(-30000);
expect(decodedComp!.uint16Field).toBe(60000);
expect(decodedComp!.int32Field).toBe(-2000000000);
expect(decodedComp!.uint32Field).toBe(4000000000);
expect(decodedComp!.float32Field).toBeCloseTo(3.14159, 4);
expect(decodedComp!.float64Field).toBe(Math.PI);
expect(decodedComp!.stringField).toBe("测试字符串");
});
});
describe('边界情况', () => {
test('空实体列表应该能编码', () => {
const data = encodeSnapshot([], SyncOperation.FULL);
const targetScene = new Scene();
const result = decodeSnapshot(targetScene, data);
expect(result.entities.length).toBe(0);
});
test('entityMap 应该正确跟踪实体', () => {
const entity = scene.createEntity('Tracked');
const comp = entity.addComponent(new PlayerComponent());
comp.name = "TrackedPlayer";
initChangeTracker(comp);
const data = encodeSnapshot([entity], SyncOperation.FULL);
const targetScene = new Scene();
const entityMap = new Map();
decodeSnapshot(targetScene, data, entityMap);
expect(entityMap.size).toBe(1);
});
});
});

View File

@@ -1,5 +1,13 @@
# @esengine/fsm
## 2.0.0
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
- @esengine/blueprint@2.0.0
## 1.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/fsm",
"version": "1.0.3",
"version": "2.0.0",
"description": "Finite State Machine for ECS Framework / ECS 框架的有限状态机",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,96 @@
# @esengine/network
## 3.0.0
### Minor Changes
- [#390](https://github.com/esengine/esengine/pull/390) [`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256) Thanks [@esengine](https://github.com/esengine)! - feat: ECS 网络状态同步系统
## @esengine/ecs-framework
新增 `@sync` 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:
```typescript
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
@ECSComponent('Player')
class PlayerComponent extends Component {
@sync('string') name: string = '';
@sync('uint16') score: number = 0;
@sync('float32') x: number = 0;
@sync('float32') y: number = 0;
}
```
### 新增导出
- `sync` - 标记需要同步的字段装饰器
- `SyncType` - 支持的同步类型
- `SyncOperation` - 同步操作类型FULL/DELTA/SPAWN/DESPAWN
- `encodeSnapshot` / `decodeSnapshot` - 批量编解码
- `encodeSpawn` / `decodeSpawn` - 实体生成编解码
- `encodeDespawn` / `processDespawn` - 实体销毁编解码
- `ChangeTracker` - 字段级变更追踪
- `initChangeTracker` / `clearChanges` / `hasChanges` - 变更追踪工具函数
### 内部方法标记
将以下方法标记为 `@internal`,用户应通过 `Core.update()` 驱动更新:
- `Scene.update()`
- `SceneManager.update()`
- `WorldManager.updateAll()`
## @esengine/network
新增 `ComponentSyncSystem`,基于 `@sync` 装饰器自动同步组件状态:
```typescript
import { ComponentSyncSystem } from '@esengine/network';
// 服务端:编码状态
const data = syncSystem.encodeAllEntities(false);
// 客户端:解码状态
syncSystem.applySnapshot(data);
```
### 修复
- 将 `@esengine/ecs-framework` 从 devDependencies 移到 peerDependencies
## @esengine/server
新增 `ECSRoom`,带有 ECS World 支持的房间基类:
```typescript
import { ECSRoom } from '@esengine/server/ecs';
// 服务端启动
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());
}
}
```
### 设计
- 每个 `ECSRoom` 在 `Core.worldManager` 中创建独立的 World
- `Core.update()` 统一更新 Time 和所有 World
- `onTick()` 只处理状态同步逻辑
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
- @esengine/blueprint@2.0.0
## 2.2.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/network",
"version": "2.2.0",
"version": "3.0.0",
"description": "Network synchronization for multiplayer games",
"esengine": {
"plugin": true,
@@ -30,6 +30,15 @@
"dependencies": {
"@esengine/rpc": "workspace:*"
},
"peerDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/blueprint": "workspace:*"
},
"peerDependenciesMeta": {
"@esengine/blueprint": {
"optional": true
}
},
"devDependencies": {
"@esengine/blueprint": "workspace:*",
"@esengine/ecs-framework": "workspace:*",

View File

@@ -133,6 +133,11 @@ export type {
EntityDeltaState,
DeltaSyncData,
DeltaCompressionConfig,
// Component sync types
ComponentSyncEventType,
ComponentSyncEvent,
ComponentSyncEventListener,
ComponentSyncConfig,
} from './sync'
export {
@@ -150,6 +155,9 @@ export {
DeltaFlags,
StateDeltaCompressor,
createStateDeltaCompressor,
// Component sync
ComponentSyncSystem,
createComponentSyncSystem,
} from './sync'
// ============================================================================

View File

@@ -0,0 +1,411 @@
/**
* @zh 组件同步系统
* @en Component Sync System
*
* @zh 基于 @sync 装饰器的组件状态同步,与 ecs-framework 的 Sync 模块集成
* @en Component state synchronization based on @sync decorator, integrated with ecs-framework Sync module
*/
import {
EntitySystem,
Matcher,
type Entity,
// Sync types
SyncOperation,
SYNC_METADATA,
CHANGE_TRACKER,
type SyncMetadata,
type ChangeTracker,
// Encoding
encodeSnapshot,
encodeSpawn,
encodeDespawn,
decodeSnapshot,
decodeSpawn,
processDespawn,
registerSyncComponent,
type DecodeSnapshotResult,
type DecodeSpawnResult,
} from '@esengine/ecs-framework';
import { NetworkIdentity } from '../components/NetworkIdentity';
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 组件同步事件类型
* @en Component sync event type
*/
export type ComponentSyncEventType =
| 'entitySpawned'
| 'entityDespawned'
| 'stateUpdated';
/**
* @zh 组件同步事件
* @en Component sync event
*/
export interface ComponentSyncEvent {
type: ComponentSyncEventType;
entityId: number;
prefabType?: string;
}
/**
* @zh 组件同步事件监听器
* @en Component sync event listener
*/
export type ComponentSyncEventListener = (event: ComponentSyncEvent) => void;
/**
* @zh 组件同步配置
* @en Component sync configuration
*/
export interface ComponentSyncConfig {
/**
* @zh 是否启用增量同步
* @en Whether to enable delta sync
*/
enableDeltaSync: boolean;
/**
* @zh 同步间隔(毫秒)
* @en Sync interval in milliseconds
*/
syncInterval: number;
}
const DEFAULT_CONFIG: ComponentSyncConfig = {
enableDeltaSync: true,
syncInterval: 50, // 20 Hz
};
// =============================================================================
// ComponentSyncSystem | 组件同步系统
// =============================================================================
/**
* @zh 组件同步系统
* @en Component sync system
*
* @zh 基于 @sync 装饰器自动同步组件状态
* @en Automatically syncs component state based on @sync decorator
*
* @example
* ```typescript
* // Server-side: broadcast state
* const syncSystem = scene.getSystem(ComponentSyncSystem);
* const data = syncSystem.encodeAllEntities(false); // delta
* broadcast(data);
*
* // Client-side: receive state
* const syncSystem = scene.getSystem(ComponentSyncSystem);
* syncSystem.applySnapshot(data);
* ```
*/
export class ComponentSyncSystem extends EntitySystem {
private readonly _config: ComponentSyncConfig;
private readonly _syncEntityMap: Map<number, Entity> = new Map();
private readonly _syncListeners: Set<ComponentSyncEventListener> = new Set();
private _lastSyncTime: number = 0;
private _isServer: boolean = false;
constructor(config?: Partial<ComponentSyncConfig>, isServer: boolean = false) {
super(Matcher.all(NetworkIdentity));
this._config = { ...DEFAULT_CONFIG, ...config };
this._isServer = isServer;
}
/**
* @zh 设置是否为服务端模式
* @en Set whether in server mode
*/
public set isServer(value: boolean) {
this._isServer = value;
}
/**
* @zh 获取是否为服务端模式
* @en Get whether in server mode
*/
public get isServer(): boolean {
return this._isServer;
}
/**
* @zh 获取配置
* @en Get configuration
*/
public get config(): Readonly<ComponentSyncConfig> {
return this._config;
}
/**
* @zh 添加同步事件监听器
* @en Add sync event listener
*/
public addSyncListener(listener: ComponentSyncEventListener): void {
this._syncListeners.add(listener);
}
/**
* @zh 移除同步事件监听器
* @en Remove sync event listener
*/
public removeSyncListener(listener: ComponentSyncEventListener): void {
this._syncListeners.delete(listener);
}
/**
* @zh 注册同步组件类型
* @en Register sync component type
*
* @zh 客户端需要调用此方法注册所有需要同步的组件类型
* @en Client needs to call this to register all component types to be synced
*/
public registerComponent<T extends new () => any>(componentClass: T): void {
const metadata: SyncMetadata | undefined = (componentClass as any)[SYNC_METADATA];
if (metadata) {
registerSyncComponent(metadata.typeId, componentClass as any);
}
}
// =========================================================================
// Server-side: Encoding | 服务端:编码
// =========================================================================
/**
* @zh 编码所有实体状态
* @en Encode all entities state
*
* @param fullSync - @zh 是否完整同步(首次连接时使用)@en Whether to do full sync (for initial connection)
* @returns @zh 编码后的二进制数据 @en Encoded binary data
*/
public encodeAllEntities(fullSync: boolean = false): Uint8Array {
const entities = this.getMatchingEntities();
const operation = fullSync ? SyncOperation.FULL : SyncOperation.DELTA;
const data = encodeSnapshot(entities, operation);
// Clear change trackers after encoding delta
if (!fullSync) {
this._clearChangeTrackers(entities);
}
return data;
}
/**
* @zh 编码有变更的实体
* @en Encode entities with changes
*
* @returns @zh 编码后的二进制数据,如果没有变更返回 null @en Encoded binary data, or null if no changes
*/
public encodeDelta(): Uint8Array | null {
const entities = this.getMatchingEntities();
const changedEntities = entities.filter(entity => this._hasChanges(entity));
if (changedEntities.length === 0) {
return null;
}
const data = encodeSnapshot(changedEntities, SyncOperation.DELTA);
this._clearChangeTrackers(changedEntities);
return data;
}
/**
* @zh 编码实体生成消息
* @en Encode entity spawn message
*/
public encodeSpawn(entity: Entity, prefabType?: string): Uint8Array {
return encodeSpawn(entity, prefabType);
}
/**
* @zh 编码实体销毁消息
* @en Encode entity despawn message
*/
public encodeDespawn(entityId: number): Uint8Array {
return encodeDespawn(entityId);
}
// =========================================================================
// Client-side: Decoding | 客户端:解码
// =========================================================================
/**
* @zh 应用状态快照
* @en Apply state snapshot
*
* @param data - @zh 二进制数据 @en Binary data
* @returns @zh 解码结果 @en Decode result
*/
public applySnapshot(data: Uint8Array): DecodeSnapshotResult {
if (!this.scene) {
throw new Error('ComponentSyncSystem not attached to a scene');
}
const result = decodeSnapshot(this.scene, data, this._syncEntityMap);
// Emit events
for (const entityResult of result.entities) {
if (entityResult.isNew) {
this._emitEvent({
type: 'entitySpawned',
entityId: entityResult.entityId,
});
} else {
this._emitEvent({
type: 'stateUpdated',
entityId: entityResult.entityId,
});
}
}
return result;
}
/**
* @zh 应用实体生成消息
* @en Apply entity spawn message
*
* @param data - @zh 二进制数据 @en Binary data
* @returns @zh 解码结果,如果不是 SPAWN 消息返回 null @en Decode result, or null if not a SPAWN message
*/
public applySpawn(data: Uint8Array): DecodeSpawnResult | null {
if (!this.scene) {
throw new Error('ComponentSyncSystem not attached to a scene');
}
const result = decodeSpawn(this.scene, data, this._syncEntityMap);
if (result) {
this._emitEvent({
type: 'entitySpawned',
entityId: result.entity.id,
prefabType: result.prefabType,
});
}
return result;
}
/**
* @zh 应用实体销毁消息
* @en Apply entity despawn message
*
* @param data - @zh 二进制数据 @en Binary data
* @returns @zh 销毁的实体 ID 列表 @en List of despawned entity IDs
*/
public applyDespawn(data: Uint8Array): number[] {
if (!this.scene) {
throw new Error('ComponentSyncSystem not attached to a scene');
}
const entityIds = processDespawn(this.scene, data, this._syncEntityMap);
for (const entityId of entityIds) {
this._emitEvent({
type: 'entityDespawned',
entityId,
});
}
return entityIds;
}
// =========================================================================
// Entity Management | 实体管理
// =========================================================================
/**
* @zh 通过网络 ID 获取实体
* @en Get entity by network ID
*/
public getEntityById(entityId: number): Entity | undefined {
return this._syncEntityMap.get(entityId);
}
/**
* @zh 获取所有匹配的实体
* @en Get all matching entities
*/
public getMatchingEntities(): Entity[] {
return this.entities.slice();
}
// =========================================================================
// Internal | 内部方法
// =========================================================================
protected override process(entities: readonly Entity[]): void {
// Server mode: auto-sync at interval
if (this._isServer && this._config.enableDeltaSync) {
const now = Date.now();
if (now - this._lastSyncTime >= this._config.syncInterval) {
// Note: actual broadcast should be done by the user
// This just updates the sync time
this._lastSyncTime = now;
}
}
// Update entity ID map
for (const entity of entities) {
const identity = entity.getComponent(NetworkIdentity);
if (identity) {
this._syncEntityMap.set(entity.id, entity);
}
}
}
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 _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();
}
}
}
}
private _emitEvent(event: ComponentSyncEvent): void {
for (const listener of this._syncListeners) {
try {
listener(event);
} catch (error) {
console.error('ComponentSyncSystem: event listener error:', error);
}
}
}
protected override onDestroy(): void {
this._syncEntityMap.clear();
this._syncListeners.clear();
}
}
/**
* @zh 创建组件同步系统
* @en Create component sync system
*/
export function createComponentSyncSystem(
config?: Partial<ComponentSyncConfig>,
isServer: boolean = false
): ComponentSyncSystem {
return new ComponentSyncSystem(config, isServer);
}

View File

@@ -62,3 +62,19 @@ export {
StateDeltaCompressor,
createStateDeltaCompressor
} from './StateDelta';
// =============================================================================
// 组件同步 | Component Sync (@sync decorator based)
// =============================================================================
export type {
ComponentSyncEventType,
ComponentSyncEvent,
ComponentSyncEventListener,
ComponentSyncConfig
} from './ComponentSync';
export {
ComponentSyncSystem,
createComponentSyncSystem
} from './ComponentSync';

View File

@@ -1,5 +1,13 @@
# @esengine/pathfinding
## 2.0.0
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
- @esengine/blueprint@2.0.0
## 1.0.4
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/pathfinding",
"version": "1.1.0",
"version": "2.0.0",
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,13 @@
# @esengine/procgen
## 2.0.0
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
- @esengine/blueprint@2.0.0
## 1.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/procgen",
"version": "1.0.3",
"version": "2.0.0",
"description": "Procedural generation tools for ECS Framework / ECS 框架的程序化生成工具",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,95 @@
# @esengine/server
## 2.0.0
### Minor Changes
- [#390](https://github.com/esengine/esengine/pull/390) [`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256) Thanks [@esengine](https://github.com/esengine)! - feat: ECS 网络状态同步系统
## @esengine/ecs-framework
新增 `@sync` 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:
```typescript
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
@ECSComponent('Player')
class PlayerComponent extends Component {
@sync('string') name: string = '';
@sync('uint16') score: number = 0;
@sync('float32') x: number = 0;
@sync('float32') y: number = 0;
}
```
### 新增导出
- `sync` - 标记需要同步的字段装饰器
- `SyncType` - 支持的同步类型
- `SyncOperation` - 同步操作类型FULL/DELTA/SPAWN/DESPAWN
- `encodeSnapshot` / `decodeSnapshot` - 批量编解码
- `encodeSpawn` / `decodeSpawn` - 实体生成编解码
- `encodeDespawn` / `processDespawn` - 实体销毁编解码
- `ChangeTracker` - 字段级变更追踪
- `initChangeTracker` / `clearChanges` / `hasChanges` - 变更追踪工具函数
### 内部方法标记
将以下方法标记为 `@internal`,用户应通过 `Core.update()` 驱动更新:
- `Scene.update()`
- `SceneManager.update()`
- `WorldManager.updateAll()`
## @esengine/network
新增 `ComponentSyncSystem`,基于 `@sync` 装饰器自动同步组件状态:
```typescript
import { ComponentSyncSystem } from '@esengine/network';
// 服务端:编码状态
const data = syncSystem.encodeAllEntities(false);
// 客户端:解码状态
syncSystem.applySnapshot(data);
```
### 修复
- 将 `@esengine/ecs-framework` 从 devDependencies 移到 peerDependencies
## @esengine/server
新增 `ECSRoom`,带有 ECS World 支持的房间基类:
```typescript
import { ECSRoom } from '@esengine/server/ecs';
// 服务端启动
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());
}
}
```
### 设计
- 每个 `ECSRoom` 在 `Core.worldManager` 中创建独立的 World
- `Core.update()` 统一更新 Time 和所有 World
- `onTick()` 只处理状态同步逻辑
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
## 1.3.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/server",
"version": "1.3.0",
"version": "2.0.0",
"description": "Game server framework for ESEngine with file-based routing",
"type": "module",
"main": "./dist/index.js",
@@ -26,6 +26,10 @@
"./testing": {
"import": "./dist/testing/index.js",
"types": "./dist/testing/index.d.ts"
},
"./ecs": {
"import": "./dist/ecs/index.js",
"types": "./dist/ecs/index.d.ts"
}
},
"files": [
@@ -46,14 +50,19 @@
},
"peerDependencies": {
"ws": ">=8.0.0",
"jsonwebtoken": ">=9.0.0"
"jsonwebtoken": ">=9.0.0",
"@esengine/ecs-framework": ">=2.5.0"
},
"peerDependenciesMeta": {
"jsonwebtoken": {
"optional": true
},
"@esengine/ecs-framework": {
"optional": true
}
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.0.0",
"@types/ws": "^8.5.13",

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';

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsup'
import { defineConfig } from 'tsup';
export default defineConfig({
entry: [
@@ -6,12 +6,13 @@ export default defineConfig({
'src/auth/index.ts',
'src/auth/testing/index.ts',
'src/ratelimit/index.ts',
'src/testing/index.ts'
'src/testing/index.ts',
'src/ecs/index.ts',
],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
external: ['ws', 'jsonwebtoken', '@esengine/rpc', '@esengine/rpc/codec'],
external: ['ws', 'jsonwebtoken', '@esengine/rpc', '@esengine/rpc/codec', '@esengine/ecs-framework'],
treeshake: true,
})
});

View File

@@ -1,5 +1,13 @@
# @esengine/spatial
## 2.0.0
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
- @esengine/blueprint@2.0.0
## 1.0.4
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/spatial",
"version": "1.0.4",
"version": "2.0.0",
"description": "Spatial query and indexing system for ECS Framework / ECS 框架的空间查询和索引系统",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,13 @@
# @esengine/timer
## 2.0.0
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/ecs-framework@2.5.0
- @esengine/blueprint@2.0.0
## 1.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/timer",
"version": "1.0.3",
"version": "2.0.0",
"description": "Timer and cooldown system for ECS Framework / ECS 框架的定时器和冷却系统",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,12 @@
# @esengine/transaction
## 2.0.3
### Patch Changes
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
- @esengine/server@2.0.0
## 2.0.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/transaction",
"version": "2.0.2",
"version": "2.0.3",
"description": "Game transaction system with distributed support | 游戏事务系统,支持分布式事务",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,16 @@
# @esengine/demos
## 1.0.5
### Patch Changes
- Updated dependencies []:
- @esengine/fsm@2.0.0
- @esengine/pathfinding@2.0.0
- @esengine/procgen@2.0.0
- @esengine/spatial@2.0.0
- @esengine/timer@2.0.0
## 1.0.4
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/demos",
"version": "1.0.4",
"version": "1.0.5",
"private": true,
"description": "Demo tests for ESEngine modules documentation",
"type": "module",

3
pnpm-lock.yaml generated
View File

@@ -1688,6 +1688,9 @@ importers:
specifier: workspace:*
version: link:../rpc
devDependencies:
'@esengine/ecs-framework':
specifier: workspace:*
version: link:../core/dist
'@types/jsonwebtoken':
specifier: ^9.0.0
version: 9.0.10