Skip to content

Commit cd8a6e2

Browse files
committed
feat(world): add component lifecycle hooks support
- Introduced lifecycle hooks for components, allowing callbacks when components are added or removed from entities. - Added `registerComponentLifecycleHook` and `unregisterComponentLifecycleHook` methods to manage hooks. - Updated the `addComponent` and `removeComponent` methods to trigger the respective hooks. - Enhanced documentation and examples to demonstrate the new functionality.
1 parent 0000381 commit cd8a6e2

File tree

4 files changed

+274
-0
lines changed

4 files changed

+274
-0
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,33 @@ query.forEach([PositionId, VelocityId], (entity, position, velocity) => {
5050
});
5151
```
5252

53+
### 组件生命周期钩子
54+
55+
ECS 支持在组件添加或移除时执行回调函数:
56+
57+
```typescript
58+
// 注册组件生命周期钩子
59+
world.registerComponentLifecycleHook(PositionId, {
60+
onAdded: (entityId, componentType, component) => {
61+
console.log(`组件 ${componentType} 被添加到实体 ${entityId}`);
62+
},
63+
onRemoved: (entityId, componentType) => {
64+
console.log(`组件 ${componentType} 被从实体 ${entityId} 移除`);
65+
}
66+
});
67+
68+
// 你也可以只注册其中一个钩子
69+
world.registerComponentLifecycleHook(VelocityId, {
70+
onRemoved: (entityId, componentType) => {
71+
console.log(`组件 ${componentType} 被从实体 ${entityId} 移除`);
72+
}
73+
});
74+
75+
// 添加组件时会触发钩子
76+
world.addComponent(entity, PositionId, { x: 0, y: 0 });
77+
world.flushCommands(); // 钩子在这里被调用
78+
```
79+
5380
### 运行示例
5481

5582
```bash
@@ -65,6 +92,8 @@ bun run examples/simple/demo.ts
6592
- `removeComponent(entity, componentId)`: 从实体移除组件
6693
- `createQuery(componentIds)`: 创建查询
6794
- `registerSystem(system)`: 注册系统
95+
- `registerComponentLifecycleHook(componentId, hook)`: 注册组件生命周期钩子
96+
- `unregisterComponentLifecycleHook(componentId, hook)`: 注销组件生命周期钩子
6897
- `update(deltaTime)`: 更新世界
6998
- `flushCommands()`: 应用命令缓冲区
7099

examples/simple/demo.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ function main() {
5151
world.addComponent(entity2, PositionId, { x: 10, y: 10 });
5252
world.addComponent(entity2, VelocityId, { x: -0.5, y: 1 });
5353

54+
// 注册组件钩子
55+
world.registerComponentLifecycleHook(PositionId, {
56+
onAdded: (entityId, componentType, component) => {
57+
console.log(`组件添加钩子触发: 实体 ${entityId} 添加了 ${componentType} 组件,值为 (${component.x}, ${component.y})`);
58+
}
59+
});
60+
61+
world.registerComponentLifecycleHook(VelocityId, {
62+
onRemoved: (entityId, componentType) => {
63+
console.log(`组件移除钩子触发: 实体 ${entityId} 移除了 ${componentType} 组件`);
64+
}
65+
});
66+
5467
// 执行命令以应用组件添加
5568
world.flushCommands();
5669

@@ -61,6 +74,11 @@ function main() {
6174
world.update(deltaTime);
6275
}
6376

77+
// 演示组件移除钩子
78+
console.log("\n移除组件演示:");
79+
world.removeComponent(entity1, VelocityId);
80+
world.flushCommands();
81+
6482
console.log("\nDemo completed!");
6583
}
6684

src/world.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,165 @@ describe("World", () => {
333333
expect(world.hasEntity(entity2)).toBe(true);
334334
});
335335
});
336+
337+
describe("Component Hooks", () => {
338+
type Position = { x: number; y: number };
339+
type Velocity = { x: number; y: number };
340+
341+
const positionComponent = createComponentId<Position>(1);
342+
const velocityComponent = createComponentId<Velocity>(2);
343+
344+
it("should trigger component added hooks", () => {
345+
const world = new World();
346+
const entity = world.createEntity();
347+
const position: Position = { x: 10, y: 20 };
348+
349+
let hookCalled = false;
350+
let hookEntityId: EntityId | undefined;
351+
let hookComponentType: EntityId<Position> | undefined;
352+
let hookComponent: Position | undefined;
353+
354+
world.registerComponentLifecycleHook(positionComponent, {
355+
onAdded: (entityId, componentType, component) => {
356+
hookCalled = true;
357+
hookEntityId = entityId;
358+
hookComponentType = componentType;
359+
hookComponent = component;
360+
}
361+
});
362+
363+
world.addComponent(entity, positionComponent, position);
364+
world.flushCommands();
365+
366+
expect(hookCalled).toBe(true);
367+
expect(hookEntityId).toBe(entity);
368+
expect(hookComponentType).toBe(positionComponent);
369+
expect(hookComponent).toEqual(position);
370+
});
371+
372+
it("should trigger component removed hooks", () => {
373+
const world = new World();
374+
const entity = world.createEntity();
375+
const position: Position = { x: 10, y: 20 };
376+
377+
world.addComponent(entity, positionComponent, position);
378+
world.flushCommands();
379+
380+
let hookCalled = false;
381+
let hookEntityId: EntityId | undefined;
382+
let hookComponentType: EntityId<Position> | undefined;
383+
384+
world.registerComponentLifecycleHook(positionComponent, {
385+
onRemoved: (entityId, componentType) => {
386+
hookCalled = true;
387+
hookEntityId = entityId;
388+
hookComponentType = componentType;
389+
}
390+
});
391+
392+
world.removeComponent(entity, positionComponent);
393+
world.flushCommands();
394+
395+
expect(hookCalled).toBe(true);
396+
expect(hookEntityId).toBe(entity);
397+
expect(hookComponentType).toBe(positionComponent);
398+
});
399+
400+
it("should handle multiple hooks for the same component type", () => {
401+
const world = new World();
402+
const entity = world.createEntity();
403+
const position: Position = { x: 10, y: 20 };
404+
405+
let hook1Called = false;
406+
let hook2Called = false;
407+
408+
world.registerComponentLifecycleHook(positionComponent, {
409+
onAdded: () => {
410+
hook1Called = true;
411+
}
412+
});
413+
414+
world.registerComponentLifecycleHook(positionComponent, {
415+
onAdded: () => {
416+
hook2Called = true;
417+
}
418+
});
419+
420+
world.addComponent(entity, positionComponent, position);
421+
world.flushCommands();
422+
423+
expect(hook1Called).toBe(true);
424+
expect(hook2Called).toBe(true);
425+
});
426+
427+
it("should support hooks with both onAdded and onRemoved", () => {
428+
const world = new World();
429+
const entity = world.createEntity();
430+
const position: Position = { x: 10, y: 20 };
431+
432+
let addedCalled = false;
433+
let removedCalled = false;
434+
435+
world.registerComponentLifecycleHook(positionComponent, {
436+
onAdded: () => {
437+
addedCalled = true;
438+
},
439+
onRemoved: () => {
440+
removedCalled = true;
441+
}
442+
});
443+
444+
world.addComponent(entity, positionComponent, position);
445+
world.flushCommands();
446+
447+
expect(addedCalled).toBe(true);
448+
expect(removedCalled).toBe(false);
449+
450+
world.removeComponent(entity, positionComponent);
451+
world.flushCommands();
452+
453+
expect(removedCalled).toBe(true);
454+
});
455+
456+
it("should support hooks with only onAdded", () => {
457+
const world = new World();
458+
const entity = world.createEntity();
459+
const position: Position = { x: 10, y: 20 };
460+
461+
let addedCalled = false;
462+
463+
world.registerComponentLifecycleHook(positionComponent, {
464+
onAdded: () => {
465+
addedCalled = true;
466+
}
467+
});
468+
469+
world.addComponent(entity, positionComponent, position);
470+
world.flushCommands();
471+
472+
expect(addedCalled).toBe(true);
473+
});
474+
475+
it("should support hooks with only onRemoved", () => {
476+
const world = new World();
477+
const entity = world.createEntity();
478+
const position: Position = { x: 10, y: 20 };
479+
480+
world.addComponent(entity, positionComponent, position);
481+
world.flushCommands();
482+
483+
let removedCalled = false;
484+
485+
world.registerComponentLifecycleHook(positionComponent, {
486+
onRemoved: () => {
487+
removedCalled = true;
488+
}
489+
});
490+
491+
world.removeComponent(entity, positionComponent);
492+
world.flushCommands();
493+
494+
expect(removedCalled).toBe(true);
495+
});
496+
});
336497
});

src/world.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ import type { ComponentTuple } from "./types";
88
import type { System } from "./system";
99
import { getOrCreateWithSideEffect } from "./utils";
1010

11+
/**
12+
* Hook types for component lifecycle events
13+
*/
14+
export interface ComponentLifecycleHook<T> {
15+
/**
16+
* Called when a component is added to an entity
17+
*/
18+
onAdded?: (entityId: EntityId, componentType: EntityId<T>, component: T) => void;
19+
/**
20+
* Called when a component is removed from an entity
21+
*/
22+
onRemoved?: (entityId: EntityId, componentType: EntityId<T>) => void;
23+
}
24+
1125
/**
1226
* World class for ECS architecture
1327
* Manages entities, components, and systems
@@ -22,6 +36,11 @@ export class World<ExtraParams extends any[] = [deltaTime: number]> {
2236
private commandBuffer: CommandBuffer;
2337
private componentToArchetypes = new Map<EntityId<any>, Archetype[]>();
2438

39+
/**
40+
* Hook storage for component lifecycle events
41+
*/
42+
private componentLifecycleHooks = new Map<EntityId<any>, Set<ComponentLifecycleHook<any>>>();
43+
2544
/**
2645
* Reverse index tracking which entities use each entity as a component type
2746
* Maps entity ID to set of {sourceEntityId, componentType} pairs where componentType uses this entity
@@ -168,6 +187,29 @@ export class World<ExtraParams extends any[] = [deltaTime: number]> {
168187
}
169188
}
170189

190+
/**
191+
* Register a lifecycle hook for component events
192+
*/
193+
registerComponentLifecycleHook<T>(componentType: EntityId<T>, hook: ComponentLifecycleHook<T>): void {
194+
if (!this.componentLifecycleHooks.has(componentType)) {
195+
this.componentLifecycleHooks.set(componentType, new Set());
196+
}
197+
this.componentLifecycleHooks.get(componentType)!.add(hook);
198+
}
199+
200+
/**
201+
* Unregister a lifecycle hook for component events
202+
*/
203+
unregisterComponentLifecycleHook<T>(componentType: EntityId<T>, hook: ComponentLifecycleHook<T>): void {
204+
const hooks = this.componentLifecycleHooks.get(componentType);
205+
if (hooks) {
206+
hooks.delete(hook);
207+
if (hooks.size === 0) {
208+
this.componentLifecycleHooks.delete(componentType);
209+
}
210+
}
211+
}
212+
171213
/**
172214
* Update the world (run all systems)
173215
*/
@@ -435,6 +477,30 @@ export class World<ExtraParams extends any[] = [deltaTime: number]> {
435477
this.addComponentReference(entityId, componentType, componentType);
436478
}
437479
}
480+
481+
// Trigger component added hooks
482+
for (const [componentType, component] of adds) {
483+
const hooks = this.componentLifecycleHooks.get(componentType);
484+
if (hooks) {
485+
for (const hook of hooks) {
486+
if (hook.onAdded) {
487+
hook.onAdded(entityId, componentType, component);
488+
}
489+
}
490+
}
491+
}
492+
493+
// Trigger component removed hooks
494+
for (const componentType of removes) {
495+
const hooks = this.componentLifecycleHooks.get(componentType);
496+
if (hooks) {
497+
for (const hook of hooks) {
498+
if (hook.onRemoved) {
499+
hook.onRemoved(entityId, componentType);
500+
}
501+
}
502+
}
503+
}
438504
}
439505

440506
/**

0 commit comments

Comments
 (0)