@codehz/ecs 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/examples/advanced-scheduling.ts +96 -0
- package/examples/collision-detection.ts +229 -0
- package/examples/inventory-system-relations.ts +108 -0
- package/examples/parent-child-hierarchy.ts +206 -0
- package/examples/serialization.ts +337 -0
- package/examples/simple.ts +96 -0
- package/examples/spatial-grid.ts +276 -0
- package/examples/state-machine.ts +273 -0
- package/examples/tag-filtering.ts +266 -0
- package/package.json +60 -12
- package/src/__tests__/commands/buffer-limits.test.ts +72 -0
- package/src/__tests__/commands/buffer.test.ts +195 -0
- package/src/__tests__/component/singleton.test.ts +148 -0
- package/src/__tests__/core/archetype.test.ts +247 -0
- package/src/__tests__/core/bitset.test.ts +171 -0
- package/src/__tests__/core/changeset.test.ts +254 -0
- package/src/__tests__/core/multi-map.test.ts +74 -0
- package/src/__tests__/entity/component-registry.test.ts +66 -0
- package/src/__tests__/entity/entity.test.ts +520 -0
- package/src/__tests__/entity/id-manager.test.ts +157 -0
- package/src/__tests__/entity/id-system.test.ts +260 -0
- package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
- package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
- package/src/__tests__/query/basic.test.ts +341 -0
- package/src/__tests__/query/caching.test.ts +112 -0
- package/src/__tests__/query/filter.test.ts +111 -0
- package/src/__tests__/query/optional.test.ts +231 -0
- package/src/__tests__/query/perf.test.ts +99 -0
- package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
- package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
- package/src/__tests__/relations/wildcard.test.ts +179 -0
- package/src/__tests__/serialization/bounds.test.ts +237 -0
- package/src/__tests__/testing/assertions.test.ts +224 -0
- package/src/__tests__/testing/entity-builder.test.ts +84 -0
- package/src/__tests__/testing/snapshot.test.ts +150 -0
- package/src/__tests__/testing/world-fixture.test.ts +73 -0
- package/src/__tests__/world/component-hooks.test.ts +185 -0
- package/src/__tests__/world/component-management.test.ts +447 -0
- package/src/__tests__/world/entity-management.test.ts +86 -0
- package/src/__tests__/world/get-optional.test.ts +96 -0
- package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
- package/src/__tests__/world/perf.test.ts +93 -0
- package/src/__tests__/world/query.test.ts +223 -0
- package/src/__tests__/world/serialize.test.ts +83 -0
- package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
- package/src/archetype/archetype.ts +472 -0
- package/src/archetype/helpers.ts +186 -0
- package/src/archetype/store.ts +33 -0
- package/src/commands/buffer.ts +110 -0
- package/src/commands/changeset.ts +104 -0
- package/src/component/entity-store.ts +223 -0
- package/src/component/registry.ts +657 -0
- package/src/component/type-utils.ts +9 -0
- package/src/entity/index.ts +63 -0
- package/src/entity/manager.ts +115 -0
- package/src/entity/relation.ts +319 -0
- package/src/entity/types.ts +135 -0
- package/src/index.ts +41 -0
- package/src/query/filter.ts +75 -0
- package/src/query/query.ts +313 -0
- package/src/query/registry.ts +101 -0
- package/src/storage/serialization.ts +130 -0
- package/src/testing/index.ts +634 -0
- package/src/types/index.ts +99 -0
- package/src/utils/bit-set.ts +133 -0
- package/src/utils/multi-map.ts +96 -0
- package/src/utils/utils.ts +19 -0
- package/src/world/builder.ts +100 -0
- package/src/world/commands.ts +378 -0
- package/src/world/hooks.ts +358 -0
- package/src/world/references.ts +38 -0
- package/src/world/serialization.ts +122 -0
- package/src/world/world.ts +1201 -0
- /package/{builder.d.mts → dist/builder.d.mts} +0 -0
- /package/{index.d.mts → dist/index.d.mts} +0 -0
- /package/{index.mjs → dist/index.mjs} +0 -0
- /package/{testing.d.mts → dist/testing.d.mts} +0 -0
- /package/{testing.mjs → dist/testing.mjs} +0 -0
- /package/{testing.mjs.map → dist/testing.mjs.map} +0 -0
- /package/{world.mjs → dist/world.mjs} +0 -0
- /package/{world.mjs.map → dist/world.mjs.map} +0 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { ComponentIdAllocator } from "../entity/manager";
|
|
2
|
+
import { decodeRelationRaw } from "../entity/relation";
|
|
3
|
+
import type { ComponentId, EntityId } from "../entity/types";
|
|
4
|
+
import {
|
|
5
|
+
COMPONENT_ID_MAX,
|
|
6
|
+
ENTITY_ID_START,
|
|
7
|
+
isComponentId,
|
|
8
|
+
isValidComponentId,
|
|
9
|
+
WILDCARD_TARGET_ID,
|
|
10
|
+
} from "../entity/types";
|
|
11
|
+
import { BitSet } from "../utils/bit-set";
|
|
12
|
+
|
|
13
|
+
const globalComponentIdAllocator = new ComponentIdAllocator();
|
|
14
|
+
|
|
15
|
+
const ComponentIdForNames: Map<string, ComponentId<any>> = new Map();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Merge function type for combining repeated `set()` values within a single sync batch.
|
|
19
|
+
*
|
|
20
|
+
* When `world.set(entity, componentType, value)` is called **multiple times** for the
|
|
21
|
+
* same entity and same component type **before** the next `world.sync()`, the merge
|
|
22
|
+
* callback is invoked to combine the values instead of simply overwriting. This allows
|
|
23
|
+
* additive or custom composition of component data in a single frame.
|
|
24
|
+
*
|
|
25
|
+
* @typeParam T - The component's value type.
|
|
26
|
+
*
|
|
27
|
+
* @param prev - The value from the **previous** `set()` call (or the merged result of
|
|
28
|
+
* earlier calls) for this entity/componentType pair within the current sync batch.
|
|
29
|
+
* @param next - The value from the **current** `set()` call being processed.
|
|
30
|
+
*
|
|
31
|
+
* @returns The merged value to be stored. This becomes `prev` if another `set()` for
|
|
32
|
+
* the same entity and componentType is encountered later in the same batch.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* **Idempotency**: Merge functions **must be idempotent**. The ECS does not guarantee
|
|
36
|
+
* that `world.sync()` won't be called multiple times in edge cases (e.g., intermediate
|
|
37
|
+
* syncs during pipeline execution), so the merge result should not depend on call
|
|
38
|
+
* count or non-deterministic state.
|
|
39
|
+
*
|
|
40
|
+
* **Single-batch scope**: Merging only applies to `set()` calls within the **same sync
|
|
41
|
+
* batch** (i.e., between two `world.sync()` calls). After `world.sync()`, the component
|
|
42
|
+
* value is committed to storage, and the next `set()` starts with a fresh `prev` value.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* // Accumulate damage events in a single frame
|
|
47
|
+
* const DamageEvents = component<DamageEvent[]>({
|
|
48
|
+
* merge: (prev, next) => [...prev, ...next],
|
|
49
|
+
* });
|
|
50
|
+
*
|
|
51
|
+
* world.set(player, DamageEvents, [{ source: "fire", amount: 10 }]);
|
|
52
|
+
* world.set(player, DamageEvents, [{ source: "ice", amount: 5 }]);
|
|
53
|
+
* // After sync: player has [{ source: "fire", amount: 10 }, { source: "ice", amount: 5 }]
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
type ComponentMerge<T = any> = (prev: T, next: T) => T;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Component options that define intrinsic properties
|
|
60
|
+
*/
|
|
61
|
+
export interface ComponentOptions<T = any> {
|
|
62
|
+
/**
|
|
63
|
+
* An optional human-readable name for the component, used for debugging and
|
|
64
|
+
* serialization.
|
|
65
|
+
*
|
|
66
|
+
* While `name` is **optional** at registration time, omitting it can cause
|
|
67
|
+
* problems when serializing and later deserializing the world:
|
|
68
|
+
*
|
|
69
|
+
* 1. **Cross-session portability**: Without a name, the component is
|
|
70
|
+
* serialized as a raw numeric ID. Component IDs are allocated sequentially
|
|
71
|
+
* at registration time, so if the order of `component()` calls changes
|
|
72
|
+
* between sessions (e.g. due to code refactoring, lazy-loading, or
|
|
73
|
+
* tree-shaking), those numeric IDs will no longer point to the same
|
|
74
|
+
* component type, leading to **silent data corruption** on restore.
|
|
75
|
+
*
|
|
76
|
+
* 2. **Runtime warnings**: `encodeEntityId` logs a `console.warn` for every
|
|
77
|
+
* unnamed component it encounters during `world.serialize()`, which can be
|
|
78
|
+
* noisy in production when serialization is used for save-games or
|
|
79
|
+
* snapshots.
|
|
80
|
+
*
|
|
81
|
+
* 3. **Debugging ergonomics**: Named components make serialized snapshots
|
|
82
|
+
* human-readable (e.g. `"Position"` instead of `42`), which is invaluable
|
|
83
|
+
* when inspecting save files or network dumps.
|
|
84
|
+
*
|
|
85
|
+
* **Recommendation**: Always provide a `name` for any component that may
|
|
86
|
+
* appear in a serialized world — even if it's just the same string as the
|
|
87
|
+
* variable name.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* // ✅ Good: explicit name ensures stable serialization
|
|
92
|
+
* const Position = component<{ x: number; y: number }>({ name: "Position" });
|
|
93
|
+
*
|
|
94
|
+
* // ⚠️ Risky: no name — serialization falls back to numeric ID
|
|
95
|
+
* const Velocity = component<{ dx: number; dy: number }>();
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
name?: string;
|
|
99
|
+
/**
|
|
100
|
+
* If `true`, an entity can have **at most one** relation per base component type.
|
|
101
|
+
* When a new relation with the same base component is added, any existing relations
|
|
102
|
+
* with that base component are **automatically removed** before the new one is applied.
|
|
103
|
+
*
|
|
104
|
+
* **Only applicable to relation components** — components used via
|
|
105
|
+
* `relation(componentId, target)`. Regular (non-relation) components ignore this flag.
|
|
106
|
+
*
|
|
107
|
+
* ## Behavior
|
|
108
|
+
*
|
|
109
|
+
* Exclusive relations enforce a **one-to-one** constraint at the entity level:
|
|
110
|
+
* each entity can hold at most one relation of a given exclusive component type.
|
|
111
|
+
*
|
|
112
|
+
* - **Same base component, different targets**: `set(entity, relation(Comp, A))`
|
|
113
|
+
* followed by `set(entity, relation(Comp, B))` results in only `(Comp, B)` —
|
|
114
|
+
* the `(Comp, A)` relation is automatically removed.
|
|
115
|
+
* - **Same base component, same target**: Re-setting the same relation target
|
|
116
|
+
* simply updates the component value (no extra removal overhead).
|
|
117
|
+
* - **Different exclusive components**: Independent — `exclusive` on `CompA` does
|
|
118
|
+
* not affect relations using `CompB`.
|
|
119
|
+
*
|
|
120
|
+
* The removal happens **during `world.sync()`**, as part of the command buffer
|
|
121
|
+
* processing, so it respects the same deferred execution model as other structural
|
|
122
|
+
* changes.
|
|
123
|
+
*
|
|
124
|
+
* ## Use cases
|
|
125
|
+
*
|
|
126
|
+
* - **Ownership**: An entity can only be owned by one parent at a time
|
|
127
|
+
* (`ChildOf` with `exclusive: true`).
|
|
128
|
+
* - **Equipment slots**: An item can only be in one slot at a time
|
|
129
|
+
* (`EquippedBy` with `exclusive: true`).
|
|
130
|
+
* - **Targeting**: An AI agent can only track one target at a time
|
|
131
|
+
* (`Targeting` with `exclusive: true`).
|
|
132
|
+
* - **State machines**: An entity can only have one active state from a set
|
|
133
|
+
* (`ActiveState` with `exclusive: true`).
|
|
134
|
+
*
|
|
135
|
+
* ## Interaction with other options
|
|
136
|
+
*
|
|
137
|
+
* - **`cascadeDelete`**: Compatible. When an exclusive relation uses
|
|
138
|
+
* `cascadeDelete`, deleting the target entity will both (a) delete the
|
|
139
|
+
* referencing entity, and (b) the exclusivity constraint prevents the
|
|
140
|
+
* entity from having multiple cascade-delete relations of the same type.
|
|
141
|
+
* - **`dontFragment`**: Compatible. Exclusivity is enforced at the data level
|
|
142
|
+
* regardless of whether the archetype is fragmented.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```ts
|
|
146
|
+
* // Without exclusive: entity can have multiple ChildOf relations
|
|
147
|
+
* const ChildOf = component();
|
|
148
|
+
* world.set(child, relation(ChildOf, parentA));
|
|
149
|
+
* world.set(child, relation(ChildOf, parentB));
|
|
150
|
+
* world.sync();
|
|
151
|
+
* // child now has TWO ChildOf relations (parentA and parentB)
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* // With exclusive: only the last relation survives
|
|
157
|
+
* const ChildOf = component({ exclusive: true });
|
|
158
|
+
* world.set(child, relation(ChildOf, parentA));
|
|
159
|
+
* world.set(child, relation(ChildOf, parentB));
|
|
160
|
+
* world.sync();
|
|
161
|
+
* // child has only (ChildOf, parentB); (ChildOf, parentA) was auto-removed
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
exclusive?: boolean;
|
|
165
|
+
/**
|
|
166
|
+
* If true, when a relation target entity is deleted, all entities that reference
|
|
167
|
+
* it through this component will also be deleted (cascade delete).
|
|
168
|
+
*
|
|
169
|
+
* Only applicable to entity-relation components.
|
|
170
|
+
*
|
|
171
|
+
* **Important distinction from default cleanup**:
|
|
172
|
+
* By default, the ECS library **always** cleans up relation components that point
|
|
173
|
+
* to a deleted entity — the relation component is removed from the referencing
|
|
174
|
+
* entity, but the referencing entity itself **survives**. When `cascadeDelete` is
|
|
175
|
+
* enabled, the **entire referencing entity** is deleted, not just the relation
|
|
176
|
+
* component. This deletion is transitive: if entity C references entity B (which
|
|
177
|
+
* is cascade-deleted), entity C will also be deleted, and so on.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* // Without cascadeDelete (default behavior):
|
|
181
|
+
* const ChildOf = component(); // no cascadeDelete
|
|
182
|
+
* world.set(child, relation(ChildOf, parent));
|
|
183
|
+
* world.sync();
|
|
184
|
+
* world.delete(parent);
|
|
185
|
+
* world.sync();
|
|
186
|
+
* // child still exists, but the ChildOf relation is cleaned up
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* // With cascadeDelete:
|
|
190
|
+
* const ChildOf = component({ cascadeDelete: true });
|
|
191
|
+
* world.set(child, relation(ChildOf, parent));
|
|
192
|
+
* world.sync();
|
|
193
|
+
* world.delete(parent);
|
|
194
|
+
* world.sync();
|
|
195
|
+
* // child is also deleted (entity deleted, not just relation cleaned up)
|
|
196
|
+
*/
|
|
197
|
+
cascadeDelete?: boolean;
|
|
198
|
+
/**
|
|
199
|
+
* If true, relations with this component will not cause archetype fragmentation.
|
|
200
|
+
*
|
|
201
|
+
* **Problem it solves**: By default, each unique relation pair `(component, target)`
|
|
202
|
+
* creates a **separate archetype**. If 100 entities each have a `ChildOf` relation
|
|
203
|
+
* to a different parent, you get 100 archetypes — this is **archetype fragmentation**.
|
|
204
|
+
* Queries that iterate over all entities with a `ChildOf` relation must check all
|
|
205
|
+
* 100 archetypes, which degrades iteration performance and increases memory overhead.
|
|
206
|
+
*
|
|
207
|
+
* **How it works**: When `dontFragment` is enabled, the relation's target does **not**
|
|
208
|
+
* contribute to the archetype signature. Entities with different targets for the same
|
|
209
|
+
* relation component share a **single archetype**, and the per-entity target data is
|
|
210
|
+
* stored in a separate `DontFragmentStore` (a `Map<EntityId, Map<EntityId, any>>`).
|
|
211
|
+
* A wildcard relation marker (`relation(Comp, "*")`) is placed in the archetype
|
|
212
|
+
* component list so queries can still discover matching archetypes.
|
|
213
|
+
*
|
|
214
|
+
* **Use cases**:
|
|
215
|
+
* - **Hierarchy/ownership**: `ChildOf` relations where thousands of entities each
|
|
216
|
+
* point to different parent entities.
|
|
217
|
+
* - **Dynamic targeting**: Relations where targets change frequently (e.g., AI
|
|
218
|
+
* targeting, inventory slots) — without `dontFragment`, each target change would
|
|
219
|
+
* cause an archetype migration, which is expensive.
|
|
220
|
+
* - **High-cardinality relations**: Any relation where the number of unique targets
|
|
221
|
+
* is large compared to the number of entities.
|
|
222
|
+
*
|
|
223
|
+
* **Performance implications**:
|
|
224
|
+
* - **Without `dontFragment`**: Archetype count grows linearly with unique targets.
|
|
225
|
+
* Each archetype migration (changing a relation target) requires moving the entity's
|
|
226
|
+
* data between component arrays.
|
|
227
|
+
* - **With `dontFragment`**: Archetype count stays constant regardless of target
|
|
228
|
+
* diversity. Changing a relation target is an O(1) update in the `DontFragmentStore`.
|
|
229
|
+
* The trade-off is an extra map lookup when accessing the relation data.
|
|
230
|
+
*
|
|
231
|
+
* **Constraints**:
|
|
232
|
+
* - Only applicable to **relation components** (components used with `relation()`).
|
|
233
|
+
* - Wildcard queries (e.g., `relation(Comp, "*")`) still work correctly — the
|
|
234
|
+
* archetype carries a wildcard marker so queries can discover it.
|
|
235
|
+
* - Works with `exclusive` and `cascadeDelete` simultaneously.
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```ts
|
|
239
|
+
* // Without dontFragment: 100 entities with different parents = 100 archetypes
|
|
240
|
+
* const ChildOf = component(); // default: fragmentation happens
|
|
241
|
+
*
|
|
242
|
+
* // With dontFragment: 100 entities with different parents = 1 archetype
|
|
243
|
+
* const ChildOf = component({ dontFragment: true });
|
|
244
|
+
*
|
|
245
|
+
* for (let i = 0; i < 100; i++) {
|
|
246
|
+
* const parent = world.new();
|
|
247
|
+
* const child = world.new();
|
|
248
|
+
* world.set(child, Position);
|
|
249
|
+
* world.set(child, relation(ChildOf, parent));
|
|
250
|
+
* }
|
|
251
|
+
* world.sync();
|
|
252
|
+
* // dontFragment: 1 archetype for all 100 entities
|
|
253
|
+
* // without: 100 archetypes, one per unique parent
|
|
254
|
+
* ```
|
|
255
|
+
*
|
|
256
|
+
* Inspired by Flecs' `DontFragment` trait.
|
|
257
|
+
*/
|
|
258
|
+
dontFragment?: boolean;
|
|
259
|
+
/**
|
|
260
|
+
* Custom merge behavior for repeated `set()` of the same component type on the
|
|
261
|
+
* same entity within a single sync batch.
|
|
262
|
+
*
|
|
263
|
+
* By default, calling `world.set(entity, comp, value)` multiple times for the same
|
|
264
|
+
* entity and component before `world.sync()` simply overwrites the previous value —
|
|
265
|
+
* the last `set()` wins. When `merge` is provided, the values are combined using
|
|
266
|
+
* your function instead.
|
|
267
|
+
*
|
|
268
|
+
* @remarks
|
|
269
|
+
* **Use cases**:
|
|
270
|
+
* - **Accumulation**: Collecting events, tags, or modifiers that multiple systems
|
|
271
|
+
* contribute to within the same frame.
|
|
272
|
+
* - **Composition**: Merging partial updates into a single component value (e.g.,
|
|
273
|
+
* applying multiple `Vec3` deltas to a position).
|
|
274
|
+
* - **Conflict resolution**: Choosing the max/min/latest value when multiple
|
|
275
|
+
* systems want to set the same component.
|
|
276
|
+
*
|
|
277
|
+
* **Scope**: This only affects `set()` calls on the **same entity** with the **same
|
|
278
|
+
* component type** within **one sync batch** (i.e., between `world.sync()` calls).
|
|
279
|
+
* It does NOT merge values across different entities or across sync boundaries.
|
|
280
|
+
*
|
|
281
|
+
* **Relation support**: If the component is used as a relation (via
|
|
282
|
+
* `relation(componentId, target)`), the merge function also applies per-target.
|
|
283
|
+
* `set(entity, relation(Comp, A), v1)` and `set(entity, relation(Comp, A), v2)`
|
|
284
|
+
* will be merged, but `set(entity, relation(Comp, B), v)` is independent.
|
|
285
|
+
*
|
|
286
|
+
* **Idempotency required**: Your merge function should be idempotent — calling it
|
|
287
|
+
* multiple times with the same inputs must produce the same result. The ECS
|
|
288
|
+
* runtime does not guarantee exactly-once `sync()` execution in all scenarios.
|
|
289
|
+
*
|
|
290
|
+
* **Return value**: The function **must return** the merged value. It should not
|
|
291
|
+
* mutate `prev` or `next` in place unless you intentionally want shared mutable
|
|
292
|
+
* state (which is discouraged).
|
|
293
|
+
*
|
|
294
|
+
* @example
|
|
295
|
+
* ```ts
|
|
296
|
+
* // Collect tags from multiple systems in one frame
|
|
297
|
+
* const Tags = component<string[]>({
|
|
298
|
+
* merge: (prev, next) => [...prev, ...next],
|
|
299
|
+
* });
|
|
300
|
+
* ```
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* ```ts
|
|
304
|
+
* // Only keep the highest priority value
|
|
305
|
+
* const Alert = component<{ level: number; msg: string }>({
|
|
306
|
+
* merge: (prev, next) => prev.level >= next.level ? prev : next,
|
|
307
|
+
* });
|
|
308
|
+
* ```
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* ```ts
|
|
312
|
+
* // Accumulate numeric deltas (e.g., for movement)
|
|
313
|
+
* const Velocity = component<{ x: number; y: number }>({
|
|
314
|
+
* merge: (prev, next) => ({ x: prev.x + next.x, y: prev.y + next.y }),
|
|
315
|
+
* });
|
|
316
|
+
* ```
|
|
317
|
+
*/
|
|
318
|
+
merge?: ComponentMerge<T>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Array for component names (Component ID range: 1-1023)
|
|
322
|
+
const componentNames: (string | undefined)[] = new Array(COMPONENT_ID_MAX + 1);
|
|
323
|
+
|
|
324
|
+
// BitSets for fast component option checks (Component ID range: 1-1023)
|
|
325
|
+
const exclusiveFlags = new BitSet(COMPONENT_ID_MAX + 1);
|
|
326
|
+
const cascadeDeleteFlags = new BitSet(COMPONENT_ID_MAX + 1);
|
|
327
|
+
const dontFragmentFlags = new BitSet(COMPONENT_ID_MAX + 1);
|
|
328
|
+
const componentMerges: (ComponentMerge<any> | undefined)[] = new Array(COMPONENT_ID_MAX + 1);
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Allocate a new component ID from the global allocator.
|
|
332
|
+
* @param nameOrOptions Optional name for the component (for serialization/debugging) or options object
|
|
333
|
+
* @returns The allocated component ID
|
|
334
|
+
* @example
|
|
335
|
+
* // Just a name
|
|
336
|
+
* const Position = component<Position>("Position");
|
|
337
|
+
*
|
|
338
|
+
* // With options
|
|
339
|
+
* const ChildOf = component({ exclusive: true, cascadeDelete: true });
|
|
340
|
+
*
|
|
341
|
+
* // With name and options
|
|
342
|
+
* const ChildOf = component({ name: "ChildOf", exclusive: true });
|
|
343
|
+
*/
|
|
344
|
+
export function component<T = void>(nameOrOptions?: string | ComponentOptions<T>): ComponentId<T> {
|
|
345
|
+
const id = globalComponentIdAllocator.allocate<T>();
|
|
346
|
+
|
|
347
|
+
let name: string | undefined;
|
|
348
|
+
let options: ComponentOptions<T> | undefined;
|
|
349
|
+
|
|
350
|
+
// Parse the parameter
|
|
351
|
+
if (typeof nameOrOptions === "string") {
|
|
352
|
+
name = nameOrOptions;
|
|
353
|
+
} else if (typeof nameOrOptions === "object" && nameOrOptions !== null) {
|
|
354
|
+
options = nameOrOptions;
|
|
355
|
+
name = options.name;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Register name if provided
|
|
359
|
+
if (name) {
|
|
360
|
+
if (ComponentIdForNames.has(name)) {
|
|
361
|
+
throw new Error(`Component name "${name}" is already registered`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
componentNames[id] = name;
|
|
365
|
+
ComponentIdForNames.set(name, id);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Register options if provided
|
|
369
|
+
if (options) {
|
|
370
|
+
// Set bitset flags for fast lookup
|
|
371
|
+
if (options.exclusive) exclusiveFlags.set(id);
|
|
372
|
+
if (options.cascadeDelete) cascadeDeleteFlags.set(id);
|
|
373
|
+
if (options.dontFragment) dontFragmentFlags.set(id);
|
|
374
|
+
if (options.merge) componentMerges[id] = options.merge;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return id;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Get a component ID by its registered name
|
|
382
|
+
* @param name The component name
|
|
383
|
+
* @returns The component ID if found, undefined otherwise
|
|
384
|
+
*/
|
|
385
|
+
export function getComponentIdByName(name: string): ComponentId<any> | undefined {
|
|
386
|
+
return ComponentIdForNames.get(name);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Get a component name by its ID
|
|
390
|
+
* @param id The component ID
|
|
391
|
+
* @returns The component name if found, undefined otherwise
|
|
392
|
+
*/
|
|
393
|
+
export function getComponentNameById(id: ComponentId<any>): string | undefined {
|
|
394
|
+
return componentNames[id];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Get component options by its ID
|
|
399
|
+
* @param id The component ID
|
|
400
|
+
* @returns The component options
|
|
401
|
+
*/
|
|
402
|
+
export function getComponentOptions<T = any>(id: ComponentId<T>): ComponentOptions<T> {
|
|
403
|
+
if (!isComponentId(id)) {
|
|
404
|
+
throw new Error("Invalid component ID");
|
|
405
|
+
}
|
|
406
|
+
const hasName = componentNames[id] !== undefined;
|
|
407
|
+
const hasExclusive = exclusiveFlags.has(id);
|
|
408
|
+
const hasCascadeDelete = cascadeDeleteFlags.has(id);
|
|
409
|
+
const hasDontFragment = dontFragmentFlags.has(id);
|
|
410
|
+
return {
|
|
411
|
+
name: hasName ? componentNames[id] : undefined,
|
|
412
|
+
exclusive: hasExclusive ? true : undefined,
|
|
413
|
+
cascadeDelete: hasCascadeDelete ? true : undefined,
|
|
414
|
+
dontFragment: hasDontFragment ? true : undefined,
|
|
415
|
+
merge: componentMerges[id] as ComponentMerge<T> | undefined,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function getBaseComponentId(componentType: EntityId<any>): ComponentId<any> | undefined {
|
|
420
|
+
if (isComponentId(componentType)) {
|
|
421
|
+
return componentType;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const decoded = decodeRelationRaw(componentType);
|
|
425
|
+
if (decoded === null) return undefined;
|
|
426
|
+
return isValidComponentId(decoded.componentId) ? (decoded.componentId as ComponentId<any>) : undefined;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Get the merge callback for a component type (including relation component types).
|
|
431
|
+
*
|
|
432
|
+
* Looks up the base component's merge function, resolving through relation wrappers.
|
|
433
|
+
* For example, if `ChildOf` has a merge function and you pass `relation(ChildOf, parent)`,
|
|
434
|
+
* the same merge function is returned.
|
|
435
|
+
*
|
|
436
|
+
* @param componentType - A raw component ID or a relation-wrapped component type
|
|
437
|
+
* (e.g., `relation(MyComp, targetEntity)`).
|
|
438
|
+
* @returns The merge callback if one was registered via {@link ComponentOptions.merge},
|
|
439
|
+
* or `undefined` if no merge was configured for the base component.
|
|
440
|
+
*/
|
|
441
|
+
export function getComponentMerge<T = any>(componentType: EntityId<any>): ComponentMerge<T> | undefined {
|
|
442
|
+
const baseComponentId = getBaseComponentId(componentType);
|
|
443
|
+
if (baseComponentId === undefined) return undefined;
|
|
444
|
+
return componentMerges[baseComponentId] as ComponentMerge<T> | undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Check if a component was created with `exclusive: true`.
|
|
449
|
+
*
|
|
450
|
+
* This is a fast O(1) bitset lookup that determines whether the component enforces
|
|
451
|
+
* the one-to-one relation constraint — an entity can have at most one relation of
|
|
452
|
+
* this component type, and setting a new relation target automatically removes the
|
|
453
|
+
* previous one.
|
|
454
|
+
*
|
|
455
|
+
* **Note**: This only checks the component's intrinsic property, not whether a
|
|
456
|
+
* specific entity/relation ID is actually an exclusive relation. For checking
|
|
457
|
+
* runtime relation IDs (including wildcards), use {@link isExclusiveRelation}
|
|
458
|
+
* or {@link isExclusiveWildcard}.
|
|
459
|
+
*
|
|
460
|
+
* @param id - The component ID to check. Must be a plain component ID (1–1023),
|
|
461
|
+
* not a relation-wrapped ID.
|
|
462
|
+
* @returns `true` if the component was created with `exclusive: true`.
|
|
463
|
+
*
|
|
464
|
+
* @see {@link ComponentOptions.exclusive} for the full explanation of exclusive
|
|
465
|
+
* relation behavior.
|
|
466
|
+
* @see {@link isExclusiveRelation} for checking specific-target exclusive relations.
|
|
467
|
+
* @see {@link isExclusiveWildcard} for checking wildcard exclusive relations.
|
|
468
|
+
*/
|
|
469
|
+
export function isExclusiveComponent(id: ComponentId<any>): boolean {
|
|
470
|
+
return exclusiveFlags.has(id);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Check if a component is marked as cascade delete.
|
|
475
|
+
*
|
|
476
|
+
* When enabled, deleting the target entity of an entity-relation with this
|
|
477
|
+
* component will cause the **entire referencing entity** to be deleted (not
|
|
478
|
+
* just cleanup of the relation component, which happens by default for all
|
|
479
|
+
* relations).
|
|
480
|
+
*
|
|
481
|
+
* @param id The component ID
|
|
482
|
+
* @returns true if the component is cascade delete, false otherwise
|
|
483
|
+
* @see {@link ComponentOptions.cascadeDelete}
|
|
484
|
+
*/
|
|
485
|
+
export function isCascadeDeleteComponent(id: ComponentId<any>): boolean {
|
|
486
|
+
return cascadeDeleteFlags.has(id);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Check if a component is marked as `dontFragment`.
|
|
491
|
+
*
|
|
492
|
+
* When a component has `dontFragment: true`, relations using it do not cause
|
|
493
|
+
* archetype fragmentation — entities with different relation targets can share
|
|
494
|
+
* the same archetype. This is a fast O(1) bitset lookup.
|
|
495
|
+
*
|
|
496
|
+
* @param id - The component ID to check.
|
|
497
|
+
* @returns `true` if the component was created with `dontFragment: true`.
|
|
498
|
+
*
|
|
499
|
+
* @see {@link ComponentOptions.dontFragment} for the full explanation of how
|
|
500
|
+
* `dontFragment` prevents archetype fragmentation.
|
|
501
|
+
*/
|
|
502
|
+
export function isDontFragmentComponent(id: ComponentId<any>): boolean {
|
|
503
|
+
return dontFragmentFlags.has(id);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Generic optimized function to check whether a relation ID's base component
|
|
508
|
+
* has a specific flag in a bitset.
|
|
509
|
+
*
|
|
510
|
+
* Avoids the overhead of `getDetailedIdType` by directly decoding the relation
|
|
511
|
+
* ID and checking: (1) the ID is a valid relation, (2) the component ID is in the
|
|
512
|
+
* valid range, (3) the target satisfies the condition, and (4) the flag bit is set.
|
|
513
|
+
*
|
|
514
|
+
* Used as the fast-path implementation for `isDontFragmentRelation`,
|
|
515
|
+
* `isDontFragmentWildcard`, `isExclusiveRelation`, `isExclusiveWildcard`,
|
|
516
|
+
* and `isCascadeDeleteRelation`.
|
|
517
|
+
*
|
|
518
|
+
* @param id - The entity/relation ID to check.
|
|
519
|
+
* @param flagBitSet - The bitset tracking which component IDs have the flag.
|
|
520
|
+
* @param targetCondition - Predicate on the target ID (e.g., check for wildcard
|
|
521
|
+
* vs. specific entity target).
|
|
522
|
+
* @returns `true` if the relation's base component has the flag and the target
|
|
523
|
+
* condition is met.
|
|
524
|
+
*/
|
|
525
|
+
function checkRelationFlag(
|
|
526
|
+
id: EntityId<any>,
|
|
527
|
+
flagBitSet: BitSet,
|
|
528
|
+
targetCondition: (targetId: number) => boolean,
|
|
529
|
+
): boolean {
|
|
530
|
+
const decoded = decodeRelationRaw(id);
|
|
531
|
+
if (decoded === null) return false;
|
|
532
|
+
const { componentId, targetId } = decoded;
|
|
533
|
+
return isValidComponentId(componentId) && targetCondition(targetId) && flagBitSet.has(componentId);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Check if an ID is a specific (non-wildcard) relation backed by a `dontFragment`
|
|
538
|
+
* component.
|
|
539
|
+
*
|
|
540
|
+
* This is used in hot paths (archetype resolution, command processing) to determine
|
|
541
|
+
* whether a relation should be excluded from the archetype signature. Relations with
|
|
542
|
+
* `dontFragment` components are stored in the shared {@link DontFragmentStore} instead
|
|
543
|
+
* of being part of the archetype's component type list.
|
|
544
|
+
*
|
|
545
|
+
* This is an optimized function that avoids the overhead of `getDetailedIdType`
|
|
546
|
+
* by directly decoding and checking the relation's component ID against the
|
|
547
|
+
* `dontFragment` bitset.
|
|
548
|
+
*
|
|
549
|
+
* @param id - The entity/relation ID to check (must be a relation ID, not a plain
|
|
550
|
+
* component ID).
|
|
551
|
+
* @returns `true` if this is a specific-target relation (not wildcard) whose base
|
|
552
|
+
* component was created with `dontFragment: true`.
|
|
553
|
+
*
|
|
554
|
+
* @see {@link isDontFragmentWildcard} for the wildcard variant.
|
|
555
|
+
* @see {@link ComponentOptions.dontFragment} for the full explanation.
|
|
556
|
+
*/
|
|
557
|
+
export function isDontFragmentRelation(id: EntityId<any>): boolean {
|
|
558
|
+
return checkRelationFlag(id, dontFragmentFlags, (targetId) => targetId !== WILDCARD_TARGET_ID);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Check if an ID is a wildcard relation (`relation(Comp, "*")`) backed by a
|
|
563
|
+
* `dontFragment` component.
|
|
564
|
+
*
|
|
565
|
+
* Wildcard markers for `dontFragment` components are placed in the archetype
|
|
566
|
+
* component list so that queries can discover archetypes containing entities
|
|
567
|
+
* with that relation type. This function is used in `filterRegularComponentTypes`
|
|
568
|
+
* to **keep** these wildcard markers in the archetype signature while stripping
|
|
569
|
+
* out specific-target `dontFragment` relations.
|
|
570
|
+
*
|
|
571
|
+
* This is an optimized function that avoids the overhead of `getDetailedIdType`
|
|
572
|
+
* by directly decoding and checking the relation's component ID against the
|
|
573
|
+
* `dontFragment` bitset.
|
|
574
|
+
*
|
|
575
|
+
* @param id - The entity/relation ID to check.
|
|
576
|
+
* @returns `true` if this is a wildcard relation (`"*"` target) whose base
|
|
577
|
+
* component was created with `dontFragment: true`.
|
|
578
|
+
*
|
|
579
|
+
* @see {@link isDontFragmentRelation} for the specific-target variant.
|
|
580
|
+
* @see {@link ComponentOptions.dontFragment} for the full explanation.
|
|
581
|
+
*/
|
|
582
|
+
export function isDontFragmentWildcard(id: EntityId<any>): boolean {
|
|
583
|
+
return checkRelationFlag(id, dontFragmentFlags, (targetId) => targetId === WILDCARD_TARGET_ID);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Check if an ID is a specific (non-wildcard) relation backed by an `exclusive`
|
|
588
|
+
* component.
|
|
589
|
+
*
|
|
590
|
+
* This is used in hot paths (command buffer processing, relation management) to
|
|
591
|
+
* determine whether setting this relation should trigger automatic removal of
|
|
592
|
+
* other relations with the same base component on the same entity.
|
|
593
|
+
*
|
|
594
|
+
* This is an optimized function that avoids the overhead of `getDetailedIdType`
|
|
595
|
+
* by directly decoding and checking the relation's component ID against the
|
|
596
|
+
* `exclusive` bitset.
|
|
597
|
+
*
|
|
598
|
+
* @param id - The entity/relation ID to check (must be a relation ID, not a plain
|
|
599
|
+
* component ID).
|
|
600
|
+
* @returns `true` if this is a specific-target relation (not wildcard) whose base
|
|
601
|
+
* component was created with `exclusive: true`.
|
|
602
|
+
*
|
|
603
|
+
* @see {@link isExclusiveWildcard} for the wildcard variant.
|
|
604
|
+
* @see {@link ComponentOptions.exclusive} for the full explanation of exclusive
|
|
605
|
+
* relation behavior.
|
|
606
|
+
*/
|
|
607
|
+
export function isExclusiveRelation(id: EntityId<any>): boolean {
|
|
608
|
+
return checkRelationFlag(id, exclusiveFlags, (targetId) => targetId !== WILDCARD_TARGET_ID);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Check if an ID is a wildcard relation (`relation(Comp, "*")`) backed by an
|
|
613
|
+
* `exclusive` component.
|
|
614
|
+
*
|
|
615
|
+
* Wildcard markers for exclusive components are used to detect that an archetype
|
|
616
|
+
* may contain exclusive relations, so the runtime can apply exclusivity enforcement
|
|
617
|
+
* when processing relation commands.
|
|
618
|
+
*
|
|
619
|
+
* This is an optimized function that avoids the overhead of `getDetailedIdType`
|
|
620
|
+
* by directly decoding and checking the relation's component ID against the
|
|
621
|
+
* `exclusive` bitset.
|
|
622
|
+
*
|
|
623
|
+
* @param id - The entity/relation ID to check.
|
|
624
|
+
* @returns `true` if this is a wildcard relation (`"*"` target) whose base
|
|
625
|
+
* component was created with `exclusive: true`.
|
|
626
|
+
*
|
|
627
|
+
* @see {@link isExclusiveRelation} for the specific-target variant.
|
|
628
|
+
* @see {@link ComponentOptions.exclusive} for the full explanation of exclusive
|
|
629
|
+
* relation behavior.
|
|
630
|
+
*/
|
|
631
|
+
export function isExclusiveWildcard(id: EntityId<any>): boolean {
|
|
632
|
+
return checkRelationFlag(id, exclusiveFlags, (targetId) => targetId === WILDCARD_TARGET_ID);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Check if a relation ID is a cascade delete entity-relation.
|
|
637
|
+
*
|
|
638
|
+
* This is an optimized function that avoids the overhead of getDetailedIdType.
|
|
639
|
+
*
|
|
640
|
+
* Cascade delete only applies to entity-relations (not component-relations or
|
|
641
|
+
* wildcards). When a cascade-delete-marked relation's target entity is deleted,
|
|
642
|
+
* the **entire source entity** (the one holding the relation) is deleted — not
|
|
643
|
+
* just the relation component. Without cascade delete, the relation component
|
|
644
|
+
* is simply removed (which is the default cleanup for all relations when their
|
|
645
|
+
* target is deleted).
|
|
646
|
+
*
|
|
647
|
+
* @param id The entity/relation ID to check
|
|
648
|
+
* @returns true if this is an entity-relation with cascade delete, false otherwise
|
|
649
|
+
* @see {@link ComponentOptions.cascadeDelete}
|
|
650
|
+
*/
|
|
651
|
+
export function isCascadeDeleteRelation(id: EntityId<any>): boolean {
|
|
652
|
+
return checkRelationFlag(
|
|
653
|
+
id,
|
|
654
|
+
cascadeDeleteFlags,
|
|
655
|
+
(targetId) => targetId !== WILDCARD_TARGET_ID && targetId >= ENTITY_ID_START,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EntityId } from "../entity";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalize component type collections into a stable ascending order.
|
|
5
|
+
* This keeps cache keys and archetype signatures deterministic.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeComponentTypes(componentTypes: Iterable<EntityId<any>>): EntityId<any>[] {
|
|
8
|
+
return [...componentTypes].sort((a, b) => a - b);
|
|
9
|
+
}
|