Skip to content

Commit c7b334f

Browse files
committed
feat(core): add filter support to multi-component hooks
- Added optional filter parameter to hook API - Implemented negative component filtering - Updated hook triggering logic for set/remove - Enhanced archetype matching with filter support - Added comprehensive tests for filter behavior
1 parent 8d1b333 commit c7b334f

5 files changed

Lines changed: 200 additions & 40 deletions

File tree

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,29 @@ const unhook = world.hook([PositionId, { optional: VelocityId }], {
124124
});
125125
```
126126

127+
多组件 `hook()` 还支持第三个可选参数 `filter`(与 `createQuery()` 的过滤语义一致),可用于排除带有某些负面组件的实体:
128+
129+
```typescript
130+
const DisabledId = component<void>();
131+
132+
const unhook = world.hook(
133+
[PositionId, VelocityId],
134+
{
135+
on_set: (entityId, position, velocity) => {
136+
// 实体进入匹配集合时触发(包括移除 Disabled 后重新进入)
137+
console.log("active", entityId, position, velocity);
138+
},
139+
on_remove: (entityId, position, velocity) => {
140+
// 实体退出匹配集合时触发(包括新增 Disabled 后退出)
141+
console.log("inactive", entityId, position, velocity);
142+
},
143+
},
144+
{
145+
negativeComponentTypes: [DisabledId],
146+
},
147+
);
148+
```
149+
127150
### 通配符关系钩子
128151

129152
ECS 支持通配符关系钩子,可以监听特定组件的所有关系变化:
@@ -226,7 +249,7 @@ bun run examples/simple/demo.ts
226249
- `delete(entity)`: 销毁实体及其所有组件
227250
- `query(componentIds)`: 快速查询具有指定组件的实体
228251
- `createQuery(componentIds)`: 创建可重用的查询对象
229-
- `hook(componentIds, hook)`: 注册生命周期钩子,返回卸载函数
252+
- `hook(componentIds, hook, filter?)`: 注册生命周期钩子,返回卸载函数(数组形式支持可选 filter)
230253
- `serialize()`: 序列化世界状态为快照对象
231254
- `sync()`: 执行所有延迟命令
232255

src/__tests__/world-multi-component-hooks.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,124 @@ describe("World - Multi-Component Hooks", () => {
353353
expect(initCalls[0]!.components).toEqual([42, "hello"]);
354354
});
355355

356+
it("should apply negative filter for on_init replay", () => {
357+
const world = new World();
358+
const A = component<number>();
359+
const Disabled = component<void>();
360+
361+
const activeEntity = world.spawn().with(A, 1).build();
362+
const filteredEntity = world.spawn().with(A, 2).with(Disabled).build();
363+
world.sync();
364+
365+
const initCalls: EntityId[] = [];
366+
world.hook(
367+
[A],
368+
{
369+
on_init: (entityId) => {
370+
initCalls.push(entityId);
371+
},
372+
},
373+
{ negativeComponentTypes: [Disabled] },
374+
);
375+
376+
expect(initCalls).toContain(activeEntity);
377+
expect(initCalls).not.toContain(filteredEntity);
378+
expect(initCalls.length).toBe(1);
379+
});
380+
381+
it("should trigger on_remove when entering negative filter state", () => {
382+
const world = new World();
383+
const A = component<number>();
384+
const Disabled = component<void>();
385+
const removeCalls: { entityId: EntityId; value: number }[] = [];
386+
387+
world.hook(
388+
[A],
389+
{
390+
on_remove: (entityId, value) => {
391+
removeCalls.push({ entityId, value });
392+
},
393+
},
394+
{ negativeComponentTypes: [Disabled] },
395+
);
396+
397+
const entity = world.spawn().with(A, 42).build();
398+
world.sync();
399+
expect(removeCalls.length).toBe(0);
400+
401+
world.set(entity, Disabled);
402+
world.sync();
403+
404+
expect(removeCalls.length).toBe(1);
405+
expect(removeCalls[0]!.entityId).toBe(entity);
406+
expect(removeCalls[0]!.value).toBe(42);
407+
});
408+
409+
it("should trigger on_set when leaving negative filter state", () => {
410+
const world = new World();
411+
const A = component<number>();
412+
const Disabled = component<void>();
413+
const setCalls: { entityId: EntityId; value: number }[] = [];
414+
415+
world.hook(
416+
[A],
417+
{
418+
on_set: (entityId, value) => {
419+
setCalls.push({ entityId, value });
420+
},
421+
},
422+
{ negativeComponentTypes: [Disabled] },
423+
);
424+
425+
const entity = world.spawn().with(A, 7).with(Disabled).build();
426+
world.sync();
427+
expect(setCalls.length).toBe(0);
428+
429+
world.remove(entity, Disabled);
430+
world.sync();
431+
432+
expect(setCalls.length).toBe(1);
433+
expect(setCalls[0]!.entityId).toBe(entity);
434+
expect(setCalls[0]!.value).toBe(7);
435+
});
436+
437+
it("should suppress normal set events while filtered until re-entering", () => {
438+
const world = new World();
439+
const A = component<number>();
440+
const B = component<string>();
441+
const Disabled = component<void>();
442+
const setCalls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
443+
444+
world.hook(
445+
[A, { optional: B }],
446+
{
447+
on_set: (entityId, ...components) => {
448+
setCalls.push({ entityId, components });
449+
},
450+
},
451+
{ negativeComponentTypes: [Disabled] },
452+
);
453+
454+
const entity = world.spawn().with(A, 1).with(Disabled).build();
455+
world.sync();
456+
expect(setCalls.length).toBe(0);
457+
458+
world.set(entity, B, "blocked");
459+
world.sync();
460+
expect(setCalls.length).toBe(0);
461+
462+
world.set(entity, A, 2);
463+
world.sync();
464+
expect(setCalls.length).toBe(0);
465+
466+
world.remove(entity, Disabled);
467+
world.sync();
468+
469+
expect(setCalls.length).toBe(1);
470+
expect(setCalls[0]!.entityId).toBe(entity);
471+
expect(setCalls[0]!.components).toEqual([2, { value: "blocked" }]);
472+
});
473+
356474
it("should stop triggering after unhook for multi-component hooks", () => {
357475
const world = new World();
358476
const A = component<number>();

src/core/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { QueryFilter } from "../query/filter";
12
import type { EntityId, WildcardRelationId } from "./entity";
23

34
/**
@@ -84,5 +85,6 @@ export interface LifecycleHookEntry {
8485
componentTypes: readonly ComponentType<any>[];
8586
requiredComponents: EntityId<any>[];
8687
optionalComponents: EntityId<any>[];
88+
filter: QueryFilter;
8789
hook: LifecycleHook<any>;
8890
}

src/core/world-hooks.ts

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -153,45 +153,46 @@ function triggerMultiComponentHooks(
153153
oldArchetype: Archetype,
154154
newArchetype: Archetype,
155155
): void {
156-
// Handle on_set: triggers if any required or optional component was added/removed and entity still matches
156+
// Handle on_set:
157+
// 1. Required/optional components changed while entity still matches
158+
// 2. Entity entered the matching set (e.g. removed a negative filter component)
157159
for (const entry of newArchetype.matchingMultiHooks) {
158160
const { hook, requiredComponents, optionalComponents, componentTypes } = entry;
159161
if (!hook.on_set) continue;
160162

161163
const anyRequiredAdded = requiredComponents.some((c) => anyComponentMatches(addedComponents, c));
162164
const anyOptionalAdded = optionalComponents.some((c) => anyComponentMatches(addedComponents, c));
163165
const anyOptionalRemoved = optionalComponents.some((c) => anyComponentMatches(removedComponents, c));
166+
const enteredMatchingSet = !oldArchetype.matchingMultiHooks.has(entry);
167+
const hasRelevantComponentChange = anyRequiredAdded || anyOptionalAdded || anyOptionalRemoved;
168+
const shouldTriggerSet =
169+
enteredMatchingSet || (hasRelevantComponentChange && entityHasAllComponents(ctx, entityId, requiredComponents));
164170

165-
if (
166-
(anyRequiredAdded || anyOptionalAdded || anyOptionalRemoved) &&
167-
entityHasAllComponents(ctx, entityId, requiredComponents)
168-
) {
171+
if (shouldTriggerSet) {
169172
hook.on_set(entityId, ...collectMultiHookComponents(ctx, entityId, componentTypes));
170173
}
171174
}
172175

173-
// Handle on_remove: triggers if any required component was removed and entity no longer matches
174-
if (removedComponents.size > 0) {
175-
for (const entry of oldArchetype.matchingMultiHooks) {
176-
const { hook, requiredComponents, componentTypes } = entry;
177-
if (!hook.on_remove) continue;
178-
179-
const anyRequiredRemoved = requiredComponents.some((c) => anyComponentMatches(removedComponents, c));
180-
181-
// Only trigger if:
182-
// 1. A required component was removed
183-
// 2. Entity matched before (had all required components)
184-
// 3. Entity no longer matches after removal
185-
if (
186-
anyRequiredRemoved &&
187-
entityHadAllComponentsBefore(ctx, entityId, requiredComponents, removedComponents) &&
188-
!entityHasAllComponents(ctx, entityId, requiredComponents)
189-
) {
190-
hook.on_remove(
191-
entityId,
192-
...collectMultiHookComponentsWithRemoved(ctx, entityId, componentTypes, removedComponents),
193-
);
194-
}
176+
// Handle on_remove:
177+
// 1. Required component removal made the entity stop matching
178+
// 2. Entity exited the matching set (e.g. added a negative filter component)
179+
for (const entry of oldArchetype.matchingMultiHooks) {
180+
const { hook, requiredComponents, componentTypes } = entry;
181+
if (!hook.on_remove) continue;
182+
183+
const anyRequiredRemoved = requiredComponents.some((c) => anyComponentMatches(removedComponents, c));
184+
const lostRequiredMatch =
185+
anyRequiredRemoved &&
186+
entityHadAllComponentsBefore(ctx, entityId, requiredComponents, removedComponents) &&
187+
!entityHasAllComponents(ctx, entityId, requiredComponents);
188+
const exitedMatchingSet = !newArchetype.matchingMultiHooks.has(entry);
189+
const shouldTriggerRemove = lostRequiredMatch || exitedMatchingSet;
190+
191+
if (shouldTriggerRemove) {
192+
hook.on_remove(
193+
entityId,
194+
...collectMultiHookComponentsWithRemoved(ctx, entityId, componentTypes, removedComponents),
195+
);
195196
}
196197
}
197198
}

src/core/world.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ComponentChangeset } from "../commands/changeset";
22
import { CommandBuffer, type Command } from "../commands/command-buffer";
3-
import { serializeQueryFilter, type QueryFilter } from "../query/filter";
3+
import { matchesFilter, serializeQueryFilter, type QueryFilter } from "../query/filter";
44
import { Query } from "../query/query";
55
import { getOrCompute } from "../utils/utils";
66
import { Archetype, MISSING_COMPONENT } from "./archetype";
@@ -663,12 +663,14 @@ export class World {
663663
* @overload hook<const T extends readonly ComponentType<any>[]>(
664664
* componentTypes: T,
665665
* hook: LifecycleHook<T> | LifecycleCallback<T>,
666+
* filter?: QueryFilter,
666667
* ): () => void
667668
* Registers a hook for multiple component types.
668-
* The hook is triggered when all required components change together.
669+
* The hook is triggered when entities enter/exit the matching set.
669670
*
670671
* @param componentTypesOrSingle - A single component type or an array of component types
671672
* @param hook - Either a hook object with on_init/on_set/on_remove handlers, or a callback function
673+
* @param filter - Optional filter, only applied to array overload
672674
* @returns A function that unsubscribes the hook when called
673675
*
674676
* @throws {Error} If no required components are specified in array overload
@@ -686,15 +688,26 @@ export class World {
686688
* const unsubscribe = world.hook([Position], (event, entityId, position) => {
687689
* if (event === "init") console.log("Initialized");
688690
* });
691+
*
692+
* // With filter
693+
* const unsubscribe2 = world.hook(
694+
* [Position, Velocity],
695+
* {
696+
* on_set: (entityId, position, velocity) => console.log(entityId, position, velocity),
697+
* },
698+
* { negativeComponentTypes: [Disabled] },
699+
* );
689700
*/
690701
hook<T>(componentType: EntityId<T>, hook: LegacyLifecycleHook<T> | LegacyLifecycleCallback<T>): () => void;
691702
hook<const T extends readonly ComponentType<any>[]>(
692703
componentTypes: T,
693704
hook: LifecycleHook<T> | LifecycleCallback<T>,
705+
filter?: QueryFilter,
694706
): () => void;
695707
hook(
696708
componentTypesOrSingle: EntityId<any> | readonly ComponentType<any>[],
697709
hook: LegacyLifecycleHook<any> | LifecycleHook<any> | LegacyLifecycleCallback<any> | LifecycleCallback<any>,
710+
filter?: QueryFilter,
698711
): () => void {
699712
// Normalize callback functions to hook objects
700713
if (typeof hook === "function") {
@@ -735,6 +748,7 @@ export class World {
735748
componentTypes,
736749
requiredComponents,
737750
optionalComponents,
751+
filter: filter || {},
738752
hook: hook as LifecycleHook<any>,
739753
};
740754
this.hooks.add(entry);
@@ -748,8 +762,8 @@ export class World {
748762

749763
const multiHook = hook as LifecycleHook<any>;
750764
if (multiHook.on_init !== undefined) {
751-
const matchingArchetypes = this.getMatchingArchetypes(requiredComponents);
752-
for (const archetype of matchingArchetypes) {
765+
for (const archetype of this.archetypes) {
766+
if (!this.archetypeMatchesHook(archetype, entry)) continue;
753767
for (const entityId of archetype.getEntities()) {
754768
const components = collectMultiHookComponents(this.createHooksContext(), entityId, componentTypes);
755769
multiHook.on_init(entityId, ...components);
@@ -1296,14 +1310,16 @@ export class World {
12961310
}
12971311

12981312
private archetypeMatchesHook(archetype: Archetype, entry: LifecycleHookEntry): boolean {
1299-
return entry.requiredComponents.every((c: EntityId<any>) => {
1300-
if (isWildcardRelationId(c)) {
1301-
if (isDontFragmentWildcard(c)) return true;
1302-
const componentId = getComponentIdFromRelationId(c);
1303-
return componentId !== undefined && archetype.hasRelationWithComponentId(componentId);
1304-
}
1305-
return archetype.componentTypeSet.has(c) || isDontFragmentRelation(c);
1306-
});
1313+
return (
1314+
entry.requiredComponents.every((c: EntityId<any>) => {
1315+
if (isWildcardRelationId(c)) {
1316+
if (isDontFragmentWildcard(c)) return true;
1317+
const componentId = getComponentIdFromRelationId(c);
1318+
return componentId !== undefined && archetype.hasRelationWithComponentId(componentId);
1319+
}
1320+
return archetype.componentTypeSet.has(c) || isDontFragmentRelation(c);
1321+
}) && matchesFilter(archetype, entry.filter)
1322+
);
13071323
}
13081324

13091325
private archetypeReferencesEntity(archetype: Archetype, entityId: EntityId): boolean {

0 commit comments

Comments
 (0)