Skip to content

Commit 9242954

Browse files
committed
feat(hooks): add multi-component hooks support
1 parent ca92191 commit 9242954

File tree

3 files changed

+376
-22
lines changed

3 files changed

+376
-22
lines changed

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export interface LifecycleHook<T = unknown> {
1818
on_remove?: (entityId: EntityId, componentType: EntityId<T>, component: T) => void;
1919
}
2020

21+
export interface MultiLifecycleHook<T extends readonly ComponentType<any>[]> {
22+
on_init?: (entityId: EntityId, componentTypes: T, components: ComponentTuple<T>) => void;
23+
on_set?: (entityId: EntityId, componentTypes: T, components: ComponentTuple<T>) => void;
24+
on_remove?: (entityId: EntityId, componentTypes: T, components: ComponentTuple<T>) => void;
25+
}
26+
2127
export type ComponentType<T> = EntityId<T> | OptionalEntityId<T>;
2228

2329
export type OptionalEntityId<T> = { optional: EntityId<T> };

src/world.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,4 +845,159 @@ describe("World", () => {
845845
expect(hookCalled).toBe(false);
846846
});
847847
});
848+
849+
describe("Multi-Component Hooks", () => {
850+
it("should trigger on_set when all required components are present", () => {
851+
const world = new World();
852+
const A = component<number>();
853+
const B = component<string>();
854+
const calls: { entityId: EntityId; components: readonly [number, string] }[] = [];
855+
856+
world.hook([A, B], {
857+
on_set: (entityId, _componentTypes, components) => {
858+
calls.push({ entityId, components });
859+
},
860+
});
861+
862+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
863+
world.sync();
864+
865+
expect(calls.length).toBe(1);
866+
expect(calls[0]!.entityId).toBe(entity);
867+
expect(calls[0]!.components).toEqual([42, "hello"]);
868+
});
869+
870+
it("should not trigger on_set when some required components are missing", () => {
871+
const world = new World();
872+
const A = component<number>();
873+
const B = component<string>();
874+
const calls: any[] = [];
875+
876+
world.hook([A, B], {
877+
on_set: (entityId, _componentTypes, components) => {
878+
calls.push({ entityId, components });
879+
},
880+
});
881+
882+
const entity = world.spawn().with(A, 42).build();
883+
world.sync();
884+
885+
expect(calls.length).toBe(0);
886+
expect(world.has(entity, A)).toBe(true);
887+
expect(world.has(entity, B)).toBe(false);
888+
});
889+
890+
it("should trigger on_set with optional component present", () => {
891+
const world = new World();
892+
const A = component<number>();
893+
const B = component<string>();
894+
const calls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
895+
896+
world.hook([A, { optional: B }], {
897+
on_set: (entityId, _componentTypes, components) => {
898+
calls.push({ entityId, components });
899+
},
900+
});
901+
902+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
903+
world.sync();
904+
905+
expect(calls.length).toBe(1);
906+
expect(calls[0]!.entityId).toBe(entity);
907+
expect(calls[0]!.components).toEqual([42, { value: "hello" }]);
908+
});
909+
910+
it("should trigger on_set with optional component absent", () => {
911+
const world = new World();
912+
const A = component<number>();
913+
const B = component<string>();
914+
const calls: { entityId: EntityId; components: readonly [number, { value: string } | undefined] }[] = [];
915+
916+
world.hook([A, { optional: B }], {
917+
on_set: (entityId, _componentTypes, components) => {
918+
calls.push({ entityId, components });
919+
},
920+
});
921+
922+
const entity = world.spawn().with(A, 42).build();
923+
world.sync();
924+
925+
expect(calls.length).toBe(1);
926+
expect(calls[0]!.entityId).toBe(entity);
927+
expect(calls[0]!.components).toEqual([42, undefined]);
928+
});
929+
930+
it("should trigger on_remove with complete snapshot when required component is removed", () => {
931+
const world = new World();
932+
const A = component<number>();
933+
const B = component<string>();
934+
const removeCalls: { entityId: EntityId; components: readonly [number, string] }[] = [];
935+
936+
world.hook([A, B], {
937+
on_remove: (entityId, _componentTypes, components) => {
938+
removeCalls.push({ entityId, components });
939+
},
940+
});
941+
942+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
943+
world.sync();
944+
945+
world.remove(entity, A);
946+
world.sync();
947+
948+
expect(removeCalls.length).toBe(1);
949+
expect(removeCalls[0]!.entityId).toBe(entity);
950+
expect(removeCalls[0]!.components).toEqual([42, "hello"]);
951+
});
952+
953+
it("should trigger on_init for existing entities matching all required components", () => {
954+
const world = new World();
955+
const A = component<number>();
956+
const B = component<string>();
957+
958+
const entity = world.spawn().with(A, 42).with(B, "hello").build();
959+
world.sync();
960+
961+
const initCalls: { entityId: EntityId; components: readonly [number, string] }[] = [];
962+
963+
world.hook([A, B], {
964+
on_init: (entityId, _componentTypes, components) => {
965+
initCalls.push({ entityId, components });
966+
},
967+
});
968+
969+
expect(initCalls.length).toBe(1);
970+
expect(initCalls[0]!.entityId).toBe(entity);
971+
expect(initCalls[0]!.components).toEqual([42, "hello"]);
972+
});
973+
974+
it("should stop triggering after unhook for multi-component hooks", () => {
975+
const world = new World();
976+
const A = component<number>();
977+
const B = component<string>();
978+
const calls: any[] = [];
979+
980+
const hook = {
981+
on_set: (entityId: EntityId, _componentTypes: any, components: any) => {
982+
calls.push({ entityId, components });
983+
},
984+
};
985+
986+
world.hook([A, B], hook);
987+
988+
const entity1 = world.spawn().with(A, 1).with(B, "first").build();
989+
world.sync();
990+
991+
expect(calls.length).toBe(1);
992+
993+
world.unhook([A, B], hook);
994+
995+
const entity2 = world.spawn().with(A, 2).with(B, "second").build();
996+
world.sync();
997+
998+
expect(calls.length).toBe(1);
999+
expect(world.has(entity1, A)).toBe(true);
1000+
expect(world.has(entity2, A)).toBe(true);
1001+
});
1002+
});
8481003
});

0 commit comments

Comments
 (0)