fix(sync): use GlobalComponentRegistry for network sync decoding (#392)

- Decoder.ts now uses GlobalComponentRegistry.getComponentType() instead of local registry
- @sync decorator uses getComponentTypeName() to get @ECSComponent decorator name
- @ECSComponent decorator updates SYNC_METADATA.typeId when defined
- Removed deprecated registerSyncComponent/autoRegisterSyncComponent functions
- Updated ComponentSync.ts in network package to use GlobalComponentRegistry
- Updated tests to use correct @ECSComponent type names

This ensures that components decorated with @ECSComponent are automatically
available for network sync decoding without any manual registration.
This commit is contained in:
YHH
2025-12-30 09:39:17 +08:00
committed by GitHub
parent 449bd420a6
commit a08a84b7db
10 changed files with 63 additions and 59 deletions

View File

@@ -0,0 +1,29 @@
---
"@esengine/ecs-framework": patch
---
fix(sync): Decoder 现在使用 GlobalComponentRegistry 查找组件 | Decoder now uses GlobalComponentRegistry for component lookup
**问题 | Problem:**
1. `Decoder.ts` 有自己独立的 `componentRegistry` Map`GlobalComponentRegistry` 完全分离。这导致通过 `@ECSComponent` 装饰器注册的组件在网络反序列化时找不到,产生 "Unknown component type" 错误。
2. `@sync` 装饰器使用 `constructor.name` 作为 `typeId`,而不是 `@ECSComponent` 装饰器指定的名称,导致编码和解码使用不同的类型 ID。
1. `Decoder.ts` had its own local `componentRegistry` Map that was completely separate from `GlobalComponentRegistry`. This caused components registered via `@ECSComponent` decorator to not be found during network deserialization, resulting in "Unknown component type" errors.
2. `@sync` decorator used `constructor.name` as `typeId` instead of the name specified by `@ECSComponent` decorator, causing encoding and decoding to use different type IDs.
**修改 | Changes:**
- 从 Decoder.ts 中移除本地 `componentRegistry`
- 更新 `decodeEntity``decodeSpawn` 使用 `GlobalComponentRegistry.getComponentType()`
- 移除已废弃的 `registerSyncComponent``autoRegisterSyncComponent` 函数
- 更新 `@sync` 装饰器使用 `getComponentTypeName()` 获取组件类型名称
- 更新 `@ECSComponent` 装饰器同步更新 `SYNC_METADATA.typeId`
- Removed local `componentRegistry` from Decoder.ts
- Updated `decodeEntity` and `decodeSpawn` to use `GlobalComponentRegistry.getComponentType()`
- Removed deprecated `registerSyncComponent` and `autoRegisterSyncComponent` functions
- Updated `@sync` decorator to use `getComponentTypeName()` for component type name
- Updated `@ECSComponent` decorator to sync update `SYNC_METADATA.typeId`
现在使用 `@ECSComponent` 装饰器的组件会自动可用于网络同步解码,无需手动注册。
Now `@ECSComponent` decorated components are automatically available for network sync decoding without any manual registration.

View File

@@ -10,10 +10,16 @@ import { Int32 } from './Core/SoAStorage';
* @en Components in ECS architecture should be pure data containers. * @en Components in ECS architecture should be pure data containers.
* All game logic should be implemented in EntitySystem, not inside components. * All game logic should be implemented in EntitySystem, not inside components.
* *
* @zh **重要:所有 Component 子类都必须使用 @ECSComponent 装饰器!**
* @zh 该装饰器用于注册组件类型名称,是序列化、网络同步等功能正常工作的前提。
* @en **IMPORTANT: All Component subclasses MUST use the @ECSComponent decorator!**
* @en This decorator registers the component type name, which is required for serialization, network sync, etc.
*
* @example * @example
* @zh 推荐做法:纯数据组件 * @zh 正确做法:使用 @ECSComponent 装饰器
* @en Recommended: Pure data component * @en Correct: Use @ECSComponent decorator
* ```typescript * ```typescript
* @ECSComponent('HealthComponent')
* class HealthComponent extends Component { * class HealthComponent extends Component {
* public health: number = 100; * public health: number = 100;
* public maxHealth: number = 100; * public maxHealth: number = 100;

View File

@@ -19,6 +19,7 @@ import {
type ComponentEditorOptions, type ComponentEditorOptions,
type ComponentType type ComponentType
} from '../Core/ComponentStorage/ComponentTypeUtils'; } from '../Core/ComponentStorage/ComponentTypeUtils';
import { SYNC_METADATA, type SyncMetadata } from '../Sync/types';
/** /**
* 存储系统类型名称的Symbol键 * 存储系统类型名称的Symbol键
@@ -138,6 +139,14 @@ export function ECSComponent(typeName: string, options?: ComponentOptions) {
metadata[COMPONENT_EDITOR_OPTIONS] = options.editor; metadata[COMPONENT_EDITOR_OPTIONS] = options.editor;
} }
// 更新 @sync 装饰器创建的 SYNC_METADATA.typeId如果存在
// Update SYNC_METADATA.typeId created by @sync decorator (if exists)
// Property decorators execute before class decorators, so @sync may have used constructor.name
const syncMeta = (target as any)[SYNC_METADATA] as SyncMetadata | undefined;
if (syncMeta) {
syncMeta.typeId = typeName;
}
// 自动注册到全局 ComponentRegistry使组件可以通过名称查找 // 自动注册到全局 ComponentRegistry使组件可以通过名称查找
// Auto-register to GlobalComponentRegistry, enabling lookup by name // Auto-register to GlobalComponentRegistry, enabling lookup by name
GlobalComponentRegistry.register(target); GlobalComponentRegistry.register(target);

View File

@@ -9,6 +9,7 @@
import type { SyncType, SyncFieldMetadata, SyncMetadata } from './types'; import type { SyncType, SyncFieldMetadata, SyncMetadata } from './types';
import { SYNC_METADATA, CHANGE_TRACKER } from './types'; import { SYNC_METADATA, CHANGE_TRACKER } from './types';
import { ChangeTracker } from './ChangeTracker'; import { ChangeTracker } from './ChangeTracker';
import { getComponentTypeName } from '../Core/ComponentStorage/ComponentTypeUtils';
/** /**
* @zh 获取或创建组件的同步元数据 * @zh 获取或创建组件的同步元数据
@@ -31,8 +32,9 @@ function getOrCreateSyncMetadata(target: any): SyncMetadata {
const inheritedMetadata: SyncMetadata | undefined = constructor[SYNC_METADATA]; const inheritedMetadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
// Create new metadata (copy from inherited if exists) // Create new metadata (copy from inherited if exists)
// Use getComponentTypeName to get @ECSComponent decorator name, or fall back to constructor.name
const metadata: SyncMetadata = { const metadata: SyncMetadata = {
typeId: constructor.name, typeId: getComponentTypeName(constructor),
fields: inheritedMetadata ? [...inheritedMetadata.fields] : [], fields: inheritedMetadata ? [...inheritedMetadata.fields] : [],
fieldIndexMap: inheritedMetadata ? new Map(inheritedMetadata.fieldIndexMap) : new Map() fieldIndexMap: inheritedMetadata ? new Map(inheritedMetadata.fieldIndexMap) : new Map()
}; };

View File

@@ -12,39 +12,7 @@ import type { Scene } from '../../Scene';
import type { SyncType, SyncMetadata } from '../types'; import type { SyncType, SyncMetadata } from '../types';
import { SyncOperation, SYNC_METADATA } from '../types'; import { SyncOperation, SYNC_METADATA } from '../types';
import { BinaryReader } from './BinaryReader'; import { BinaryReader } from './BinaryReader';
import { GlobalComponentRegistry } from '../../Core/ComponentStorage/ComponentRegistry';
/**
* @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 解码字段值 * @zh 解码字段值
@@ -166,8 +134,8 @@ export function decodeEntity(
const typeId = reader.readString(); const typeId = reader.readString();
componentTypes.push(typeId); componentTypes.push(typeId);
// Find component class from registry // Find component class from GlobalComponentRegistry
const componentClass = componentRegistry.get(typeId); const componentClass = GlobalComponentRegistry.getComponentType(typeId) as (new () => Component) | null;
if (!componentClass) { if (!componentClass) {
console.warn(`Unknown component type: ${typeId}`); console.warn(`Unknown component type: ${typeId}`);
// Skip component data - we need to read it to advance the reader // Skip component data - we need to read it to advance the reader
@@ -306,7 +274,7 @@ export function decodeSpawn(
const typeId = reader.readString(); const typeId = reader.readString();
componentTypes.push(typeId); componentTypes.push(typeId);
const componentClass = componentRegistry.get(typeId); const componentClass = GlobalComponentRegistry.getComponentType(typeId) as (new () => Component) | null;
if (!componentClass) { if (!componentClass) {
console.warn(`Unknown component type: ${typeId}`); console.warn(`Unknown component type: ${typeId}`);
// Try to skip // Try to skip
@@ -322,7 +290,7 @@ export function decodeSpawn(
continue; continue;
} }
const component = entity.addComponent(new componentClass()); const component = entity.addComponent(new (componentClass as new () => Component)());
decodeComponent(component, metadata, reader); decodeComponent(component, metadata, reader);
} }

View File

@@ -34,8 +34,6 @@ export {
// Decoder // Decoder
export { export {
registerSyncComponent,
autoRegisterSyncComponent,
decodeComponent, decodeComponent,
decodeEntity, decodeEntity,
decodeSnapshot, decodeSnapshot,

View File

@@ -53,7 +53,7 @@ describe('@sync 装饰器测试', () => {
const metadata = getSyncMetadata(PlayerComponent); const metadata = getSyncMetadata(PlayerComponent);
expect(metadata).not.toBeNull(); expect(metadata).not.toBeNull();
expect(metadata!.typeId).toBe('PlayerComponent'); expect(metadata!.typeId).toBe('SyncTest_PlayerComponent');
expect(metadata!.fields.length).toBe(4); expect(metadata!.fields.length).toBe(4);
}); });

View File

@@ -13,8 +13,7 @@ import {
import { import {
decodeSnapshot, decodeSnapshot,
decodeSpawn, decodeSpawn,
processDespawn, processDespawn
registerSyncComponent
} from '../../../src/ECS/Sync/encoding/Decoder'; } from '../../../src/ECS/Sync/encoding/Decoder';
import { SyncOperation } from '../../../src/ECS/Sync/types'; import { SyncOperation } from '../../../src/ECS/Sync/types';
@@ -320,10 +319,7 @@ describe('BinaryWriter/BinaryReader - 二进制读写器测试', () => {
describe('Encoder/Decoder - 实体编解码测试', () => { describe('Encoder/Decoder - 实体编解码测试', () => {
let scene: Scene; let scene: Scene;
beforeAll(() => { // Components are auto-registered via @ECSComponent decorator
registerSyncComponent('PlayerComponent', PlayerComponent);
registerSyncComponent('AllTypesComponent', AllTypesComponent);
});
beforeEach(() => { beforeEach(() => {
scene = new Scene(); scene = new Scene();
@@ -414,7 +410,7 @@ describe('Encoder/Decoder - 实体编解码测试', () => {
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.prefabType).toBe('Player'); expect(result!.prefabType).toBe('Player');
expect(result!.componentTypes).toContain('PlayerComponent'); expect(result!.componentTypes).toContain('EncodingTest_PlayerComponent');
const decodedComp = result!.entity.getComponent(PlayerComponent); const decodedComp = result!.entity.getComponent(PlayerComponent);
expect(decodedComp!.name).toBe("SpawnedPlayer"); expect(decodedComp!.name).toBe("SpawnedPlayer");
@@ -470,10 +466,6 @@ describe('Encoder/Decoder - 实体编解码测试', () => {
}); });
describe('所有同步类型编解码', () => { describe('所有同步类型编解码', () => {
beforeAll(() => {
registerSyncComponent('AllTypesComponent', AllTypesComponent);
});
test('应该正确编解码所有类型', () => { test('应该正确编解码所有类型', () => {
const entity = scene.createEntity('AllTypes'); const entity = scene.createEntity('AllTypes');
const comp = entity.addComponent(new AllTypesComponent()); const comp = entity.addComponent(new AllTypesComponent());

View File

@@ -23,7 +23,7 @@ import {
decodeSnapshot, decodeSnapshot,
decodeSpawn, decodeSpawn,
processDespawn, processDespawn,
registerSyncComponent, GlobalComponentRegistry,
type DecodeSnapshotResult, type DecodeSnapshotResult,
type DecodeSpawnResult, type DecodeSpawnResult,
} from '@esengine/ecs-framework'; } from '@esengine/ecs-framework';
@@ -166,10 +166,7 @@ export class ComponentSyncSystem extends EntitySystem {
* @en Client needs to call this to register all component types to be synced * @en Client needs to call this to register all component types to be synced
*/ */
public registerComponent<T extends new () => any>(componentClass: T): void { public registerComponent<T extends new () => any>(componentClass: T): void {
const metadata: SyncMetadata | undefined = (componentClass as any)[SYNC_METADATA]; GlobalComponentRegistry.register(componentClass as any);
if (metadata) {
registerSyncComponent(metadata.typeId, componentClass as any);
}
} }
// ========================================================================= // =========================================================================

View File

@@ -32,6 +32,9 @@
export { ECSRoom } from './ECSRoom.js'; export { ECSRoom } from './ECSRoom.js';
export type { ECSRoomConfig } from './ECSRoom.js'; export type { ECSRoomConfig } from './ECSRoom.js';
// Re-export Player for convenience
export { Player, type IPlayer } from '../room/Player.js';
// Re-export commonly used ECS types for convenience // Re-export commonly used ECS types for convenience
export type { export type {
Entity, Entity,