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

## @esengine/ecs-framework

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

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

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

## @esengine/network

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

## @esengine/server

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

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

View File

@@ -0,0 +1,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);
});
});
});