Skip to content

Commit f6bbd22

Browse files
committed
feat(world): add singleton component overloads
- Added set/remove/has overloads for singleton components - Simplified singleton usage with componentId-only calls - Updated documentation and examples - Added comprehensive test coverage - Maintained backward compatibility
1 parent cdf3f15 commit f6bbd22

File tree

2 files changed

+227
-9
lines changed

2 files changed

+227
-9
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { component } from "../core/entity";
3+
import { World } from "../core/world";
4+
5+
describe("World - Singleton Component", () => {
6+
type GlobalConfig = { debug: boolean; version: string };
7+
type GameState = { score: number; level: number };
8+
9+
const GlobalConfigId = component<GlobalConfig>();
10+
const GameStateId = component<GameState>();
11+
12+
it("should set singleton component using shorthand syntax", () => {
13+
const world = new World();
14+
const config: GlobalConfig = { debug: true, version: "1.0.0" };
15+
16+
// Use singleton syntax: set(componentId, data)
17+
world.set(GlobalConfigId, config);
18+
world.sync();
19+
20+
// Verify it was set on the component entity itself
21+
expect(world.has(GlobalConfigId)).toBe(true);
22+
expect(world.get(GlobalConfigId)).toEqual(config);
23+
});
24+
25+
it("should update singleton component using shorthand syntax", () => {
26+
const world = new World();
27+
const config1: GlobalConfig = { debug: true, version: "1.0.0" };
28+
const config2: GlobalConfig = { debug: false, version: "2.0.0" };
29+
30+
world.set(GlobalConfigId, config1);
31+
world.sync();
32+
expect(world.get(GlobalConfigId)).toEqual(config1);
33+
34+
world.set(GlobalConfigId, config2);
35+
world.sync();
36+
expect(world.get(GlobalConfigId)).toEqual(config2);
37+
});
38+
39+
it("should be equivalent to set(comp, comp, data)", () => {
40+
const world1 = new World();
41+
const world2 = new World();
42+
const config: GlobalConfig = { debug: true, version: "1.0.0" };
43+
44+
// Singleton syntax
45+
world1.set(GlobalConfigId, config);
46+
world1.sync();
47+
48+
// Traditional syntax
49+
world2.set(GlobalConfigId, GlobalConfigId, config);
50+
world2.sync();
51+
52+
// Both should have the same result
53+
expect(world1.get(GlobalConfigId)).toEqual(world2.get(GlobalConfigId));
54+
});
55+
56+
it("should work with multiple singleton components", () => {
57+
const world = new World();
58+
const config: GlobalConfig = { debug: true, version: "1.0.0" };
59+
const state: GameState = { score: 100, level: 5 };
60+
61+
world.set(GlobalConfigId, config);
62+
world.set(GameStateId, state);
63+
world.sync();
64+
65+
expect(world.get(GlobalConfigId)).toEqual(config);
66+
expect(world.get(GameStateId)).toEqual(state);
67+
});
68+
69+
it("should throw error if component entity does not exist", () => {
70+
const world = new World();
71+
const config: GlobalConfig = { debug: true, version: "1.0.0" };
72+
73+
// Try to set a component on an entity that doesn't exist
74+
const nonExistentEntity = 99999 as any; // Use a fake entity ID
75+
expect(() => {
76+
world.set(nonExistentEntity, GlobalConfigId, config);
77+
}).toThrow("does not exist");
78+
});
79+
80+
it("should check singleton component existence using shorthand syntax", () => {
81+
const world = new World();
82+
const config: GlobalConfig = { debug: true, version: "1.0.0" };
83+
84+
// Before setting, should return false
85+
expect(world.has(GlobalConfigId)).toBe(false);
86+
87+
// Set singleton component
88+
world.set(GlobalConfigId, config);
89+
world.sync();
90+
91+
// After setting, should return true
92+
expect(world.has(GlobalConfigId)).toBe(true);
93+
});
94+
95+
it("should be equivalent to has(comp, comp)", () => {
96+
const world1 = new World();
97+
const world2 = new World();
98+
const config: GlobalConfig = { debug: true, version: "1.0.0" };
99+
100+
// Singleton syntax
101+
world1.set(GlobalConfigId, config);
102+
world1.sync();
103+
104+
// Traditional syntax
105+
world2.set(GlobalConfigId, GlobalConfigId, config);
106+
world2.sync();
107+
108+
// Both should have the same result
109+
expect(world1.has(GlobalConfigId)).toBe(world2.has(GlobalConfigId, GlobalConfigId));
110+
expect(world1.has(GlobalConfigId)).toBe(true);
111+
});
112+
113+
it("should remove singleton component using shorthand syntax", () => {
114+
const world = new World();
115+
const config: GlobalConfig = { debug: true, version: "1.0.0" };
116+
117+
world.set(GlobalConfigId, config);
118+
world.sync();
119+
expect(world.has(GlobalConfigId)).toBe(true);
120+
121+
// Remove using singleton syntax
122+
world.remove(GlobalConfigId);
123+
world.sync();
124+
expect(world.has(GlobalConfigId)).toBe(false);
125+
});
126+
127+
it("should be equivalent to remove(comp, comp)", () => {
128+
const world1 = new World();
129+
const world2 = new World();
130+
const config: GlobalConfig = { debug: true, version: "1.0.0" };
131+
132+
// Set on both worlds
133+
world1.set(GlobalConfigId, config);
134+
world1.sync();
135+
world2.set(GlobalConfigId, GlobalConfigId, config);
136+
world2.sync();
137+
138+
// Remove using different syntax
139+
world1.remove(GlobalConfigId); // Singleton syntax
140+
world2.remove(GlobalConfigId, GlobalConfigId); // Traditional syntax
141+
world1.sync();
142+
world2.sync();
143+
144+
// Both should have the same result
145+
expect(world1.has(GlobalConfigId)).toBe(world2.has(GlobalConfigId, GlobalConfigId));
146+
expect(world1.has(GlobalConfigId)).toBe(false);
147+
});
148+
});

src/core/world.ts

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -290,19 +290,52 @@ export class World {
290290
* @overload set<T>(entityId: EntityId, componentType: EntityId<T>, component: NoInfer<T>): void
291291
* Adds or updates a component with data on the entity
292292
*
293+
* @overload set<T>(componentId: ComponentId<T>, component: NoInfer<T>): void
294+
* Adds or updates a singleton component (shorthand for set(componentId, componentId, component))
295+
*
293296
* @throws {Error} If the entity does not exist
294297
* @throws {Error} If the component type is invalid or is a wildcard relation
295298
*
296299
* @example
297300
* world.set(entity, Position, { x: 10, y: 20 });
298301
* world.set(entity, Marker); // void component
302+
* world.set(GlobalConfig, { debug: true }); // singleton component
299303
* world.sync(); // Apply changes
300304
*/
301305
set(entityId: EntityId, componentType: EntityId<void>): void;
302306
set<T>(entityId: EntityId, componentType: EntityId<T>, component: NoInfer<T>): void;
303-
set(entityId: EntityId, componentType: EntityId, component?: any): void {
304-
if (!this.exists(entityId)) {
305-
throw new Error(`Entity ${entityId} does not exist`);
307+
set<T>(componentId: ComponentId<T>, component: NoInfer<T>): void;
308+
set(entityId: EntityId | ComponentId, componentTypeOrComponent?: EntityId | any, maybeComponent?: any): void {
309+
// Handle singleton component overload: set(componentId, data)
310+
if (maybeComponent === undefined && componentTypeOrComponent !== undefined) {
311+
const detailedType = getDetailedIdType(entityId);
312+
// Check if this looks like a singleton call (2 arguments, second is not an EntityId)
313+
if (detailedType.type === "component" || detailedType.type === "component-relation") {
314+
// Singleton component: set(componentId, data)
315+
const componentId = entityId as ComponentId;
316+
const component = componentTypeOrComponent;
317+
if (!this.exists(componentId)) {
318+
throw new Error(`Component entity ${componentId} does not exist`);
319+
}
320+
const detailedComponentType = getDetailedIdType(componentId);
321+
if (detailedComponentType.type === "invalid") {
322+
throw new Error(`Invalid component type: ${componentId}`);
323+
}
324+
if (detailedComponentType.type === "wildcard-relation") {
325+
throw new Error(`Cannot directly add wildcard relation components: ${componentId}`);
326+
}
327+
this.commandBuffer.set(componentId, componentId, component);
328+
return;
329+
}
330+
}
331+
332+
// Standard overload: set(entityId, componentType, data?) or set(entityId, componentType)
333+
const entityIdArg = entityId as EntityId;
334+
const componentType = componentTypeOrComponent as EntityId;
335+
const component = maybeComponent;
336+
337+
if (!this.exists(entityIdArg)) {
338+
throw new Error(`Entity ${entityIdArg} does not exist`);
306339
}
307340

308341
const detailedType = getDetailedIdType(componentType);
@@ -313,14 +346,20 @@ export class World {
313346
throw new Error(`Cannot directly add wildcard relation components: ${componentType}`);
314347
}
315348

316-
this.commandBuffer.set(entityId, componentType, component);
349+
this.commandBuffer.set(entityIdArg, componentType, component);
317350
}
318351

319352
/**
320353
* Removes a component from an entity.
321354
* The change is buffered and takes effect after calling `world.sync()`.
322355
* If the entity does not exist, throws an error.
323356
*
357+
* @overload remove<T>(entityId: EntityId, componentType: EntityId<T>): void
358+
* Removes a component from an entity.
359+
*
360+
* @overload remove<T>(componentId: ComponentId<T>): void
361+
* Removes a singleton component (shorthand for remove(componentId, componentId)).
362+
*
324363
* @template T - The component data type
325364
* @param entityId - The entity identifier
326365
* @param componentType - The component type to remove
@@ -330,19 +369,33 @@ export class World {
330369
*
331370
* @example
332371
* world.remove(entity, Position);
372+
* world.remove(GlobalConfig); // Remove singleton component
333373
* world.sync(); // Apply changes
334374
*/
335-
remove<T>(entityId: EntityId, componentType: EntityId<T>): void {
336-
if (!this.exists(entityId)) {
337-
throw new Error(`Entity ${entityId} does not exist`);
375+
remove<T>(componentId: ComponentId<T>): void;
376+
remove<T>(entityId: EntityId, componentType: EntityId<T>): void;
377+
remove<T>(entityId: EntityId | ComponentId, componentType?: EntityId<T>): void {
378+
// Handle singleton component overload: remove(componentId)
379+
if (componentType === undefined) {
380+
const componentId = entityId as ComponentId<T>;
381+
if (!this.exists(componentId)) {
382+
throw new Error(`Component entity ${componentId} does not exist`);
383+
}
384+
this.commandBuffer.remove(componentId, componentId);
385+
return;
386+
}
387+
388+
const entityIdArg = entityId as EntityId;
389+
if (!this.exists(entityIdArg)) {
390+
throw new Error(`Entity ${entityIdArg} does not exist`);
338391
}
339392

340393
const detailedType = getDetailedIdType(componentType);
341394
if (detailedType.type === "invalid") {
342395
throw new Error(`Invalid component type: ${componentType}`);
343396
}
344397

345-
this.commandBuffer.remove(entityId, componentType);
398+
this.commandBuffer.remove(entityIdArg, componentType);
346399
}
347400

348401
/**
@@ -364,6 +417,12 @@ export class World {
364417
* Checks if an entity has a specific component.
365418
* Immediately reflects the current state without waiting for `sync()`.
366419
*
420+
* @overload has<T>(entityId: EntityId, componentType: EntityId<T>): boolean
421+
* Checks if a specific component type is present on the entity.
422+
*
423+
* @overload has<T>(componentId: ComponentId<T>): boolean
424+
* Checks if a singleton component has data (shorthand for has(componentId, componentId)).
425+
*
367426
* @template T - The component data type
368427
* @param entityId - The entity identifier
369428
* @param componentType - The component type to check
@@ -373,8 +432,19 @@ export class World {
373432
* if (world.has(entity, Position)) {
374433
* const pos = world.get(entity, Position);
375434
* }
435+
* if (world.has(GlobalConfig)) {
436+
* const config = world.get(GlobalConfig);
437+
* }
376438
*/
377-
has<T>(entityId: EntityId, componentType: EntityId<T>): boolean {
439+
has<T>(componentId: ComponentId<T>): boolean;
440+
has<T>(entityId: EntityId, componentType: EntityId<T>): boolean;
441+
has<T>(entityId: EntityId | ComponentId, componentType?: EntityId<T>): boolean {
442+
// Handle singleton component overload: has(componentId)
443+
if (componentType === undefined) {
444+
const componentId = entityId as ComponentId<T>;
445+
return this.componentEntityComponents.get(componentId)?.has(componentId) ?? false;
446+
}
447+
378448
if (this.isComponentEntityId(entityId)) {
379449
if (isWildcardRelationId(componentType)) {
380450
const componentId = getComponentIdFromRelationId(componentType);

0 commit comments

Comments
 (0)