archetype-ecs 1.0.0 → 1.2.0

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.
@@ -1,26 +1,87 @@
1
+ import { TYPED, componentSchemas, toSym } from './ComponentRegistry.js';
2
+
3
+ const INITIAL_CAPACITY = 64;
4
+
5
+ function createSoAStore(schema, capacity) {
6
+ const store = { [TYPED]: true, _schema: schema, _capacity: capacity };
7
+ for (const [field, Ctor] of Object.entries(schema)) {
8
+ store[field] = new Ctor(capacity);
9
+ }
10
+ return store;
11
+ }
12
+
13
+ function growSoAStore(store, newCapacity) {
14
+ store._capacity = newCapacity;
15
+ for (const [field, Ctor] of Object.entries(store._schema)) {
16
+ const old = store[field];
17
+ store[field] = new Ctor(newCapacity);
18
+ if (Ctor === Array) {
19
+ for (let i = 0; i < old.length; i++) store[field][i] = old[i];
20
+ } else {
21
+ store[field].set(old);
22
+ }
23
+ }
24
+ }
25
+
26
+ function soaWrite(store, idx, data) {
27
+ for (const field in store._schema) {
28
+ store[field][idx] = data[field];
29
+ }
30
+ }
31
+
32
+ function soaRead(store, idx) {
33
+ const obj = {};
34
+ for (const field in store._schema) {
35
+ obj[field] = store[field][idx];
36
+ }
37
+ return obj;
38
+ }
39
+
40
+ function soaSwap(store, idxA, idxB) {
41
+ for (const field in store._schema) {
42
+ const arr = store[field];
43
+ const tmp = arr[idxA];
44
+ arr[idxA] = arr[idxB];
45
+ arr[idxB] = tmp;
46
+ }
47
+ }
48
+
1
49
  export function createEntityManager() {
2
50
  let nextId = 1;
3
51
  const allEntityIds = new Set();
4
52
 
53
+ // Component bit registry (symbol → bit index 0..31)
54
+ const componentBitIndex = new Map();
55
+ let nextBitIndex = 0;
56
+
57
+ function getBit(type) {
58
+ const sym = toSym(type);
59
+ let bit = componentBitIndex.get(sym);
60
+ if (bit === undefined) {
61
+ bit = nextBitIndex++;
62
+ componentBitIndex.set(sym, bit);
63
+ }
64
+ return bit;
65
+ }
66
+
67
+ function computeMask(types) {
68
+ let mask = 0;
69
+ for (const t of types) {
70
+ mask |= (1 << getBit(t));
71
+ }
72
+ return mask;
73
+ }
74
+
5
75
  // Archetype storage
6
- const archetypes = new Map(); // key → Archetype
76
+ const archetypes = new Map(); // bitmask → Archetype
7
77
  const entityArchetype = new Map(); // entityId → Archetype
8
78
 
9
79
  // Query cache
10
80
  let queryCacheVersion = 0;
11
81
  const queryCache = new Map(); // queryKey → { version, archetypes[] }
12
82
 
13
- function makeArchetypeKey(types) {
14
- const descs = [];
15
- for (const sym of types) {
16
- descs.push(sym.description);
17
- }
18
- descs.sort();
19
- return descs.join('|');
20
- }
21
-
22
83
  function getOrCreateArchetype(types) {
23
- const key = makeArchetypeKey(types);
84
+ const key = computeMask(types);
24
85
  let arch = archetypes.get(key);
25
86
  if (!arch) {
26
87
  arch = {
@@ -29,10 +90,12 @@ export function createEntityManager() {
29
90
  entityIds: [],
30
91
  components: new Map(),
31
92
  entityToIndex: new Map(),
32
- count: 0
93
+ count: 0,
94
+ capacity: INITIAL_CAPACITY
33
95
  };
34
96
  for (const t of types) {
35
- arch.components.set(t, []);
97
+ const schema = componentSchemas.get(t);
98
+ arch.components.set(t, schema ? createSoAStore(schema, INITIAL_CAPACITY) : null);
36
99
  }
37
100
  archetypes.set(key, arch);
38
101
  queryCacheVersion++;
@@ -40,11 +103,22 @@ export function createEntityManager() {
40
103
  return arch;
41
104
  }
42
105
 
106
+ function ensureCapacity(arch) {
107
+ if (arch.count < arch.capacity) return;
108
+ const newCap = arch.capacity * 2;
109
+ arch.capacity = newCap;
110
+ for (const [type, store] of arch.components) {
111
+ if (store) growSoAStore(store, newCap);
112
+ }
113
+ }
114
+
43
115
  function addToArchetype(arch, entityId, componentMap) {
116
+ ensureCapacity(arch);
44
117
  const idx = arch.count;
45
118
  arch.entityIds[idx] = entityId;
46
119
  for (const t of arch.types) {
47
- arch.components.get(t)[idx] = componentMap.get(t);
120
+ const store = arch.components.get(t);
121
+ if (store) soaWrite(store, idx, componentMap[t]);
48
122
  }
49
123
  arch.entityToIndex.set(entityId, idx);
50
124
  arch.count++;
@@ -56,30 +130,31 @@ export function createEntityManager() {
56
130
  const lastIdx = arch.count - 1;
57
131
 
58
132
  if (idx !== lastIdx) {
59
- // Swap with last
60
133
  const lastEntity = arch.entityIds[lastIdx];
61
134
  arch.entityIds[idx] = lastEntity;
62
- for (const [type, arr] of arch.components) {
63
- arr[idx] = arr[lastIdx];
135
+ for (const [type, store] of arch.components) {
136
+ if (store) soaSwap(store, idx, lastIdx);
64
137
  }
65
138
  arch.entityToIndex.set(lastEntity, idx);
66
139
  }
67
140
 
68
- // Pop last
69
141
  arch.entityIds.length = lastIdx;
70
- for (const [type, arr] of arch.components) {
71
- arr.length = lastIdx;
72
- }
73
142
  arch.entityToIndex.delete(entityId);
74
143
  arch.count--;
75
144
  entityArchetype.delete(entityId);
76
145
  }
77
146
 
147
+ function readComponentData(arch, type, idx) {
148
+ const store = arch.components.get(type);
149
+ if (!store) return undefined;
150
+ return soaRead(store, idx);
151
+ }
152
+
78
153
  function getMatchingArchetypes(types, excludeTypes) {
79
- let queryKey = makeArchetypeKey(types);
80
- if (excludeTypes && excludeTypes.length > 0) {
81
- queryKey += '!' + makeArchetypeKey(excludeTypes);
82
- }
154
+ const includeMask = computeMask(types);
155
+ const excludeMask = excludeTypes && excludeTypes.length > 0 ? computeMask(excludeTypes) : 0;
156
+ const queryKey = `${includeMask}:${excludeMask}`;
157
+
83
158
  const cached = queryCache.get(queryKey);
84
159
  if (cached && cached.version === queryCacheVersion) {
85
160
  return cached.archetypes;
@@ -87,22 +162,10 @@ export function createEntityManager() {
87
162
 
88
163
  const matching = [];
89
164
  for (const arch of archetypes.values()) {
90
- let match = true;
91
- for (const t of types) {
92
- if (!arch.types.has(t)) {
93
- match = false;
94
- break;
95
- }
165
+ if ((arch.key & includeMask) === includeMask &&
166
+ (arch.key & excludeMask) === 0) {
167
+ matching.push(arch);
96
168
  }
97
- if (match && excludeTypes) {
98
- for (const t of excludeTypes) {
99
- if (arch.types.has(t)) {
100
- match = false;
101
- break;
102
- }
103
- }
104
- }
105
- if (match) matching.push(arch);
106
169
  }
107
170
 
108
171
  queryCache.set(queryKey, { version: queryCacheVersion, archetypes: matching });
@@ -124,78 +187,95 @@ export function createEntityManager() {
124
187
  allEntityIds.delete(id);
125
188
  },
126
189
 
127
- addComponent(entityId, componentName, data) {
190
+ addComponent(entityId, comp, data) {
191
+ const type = toSym(comp);
128
192
  const arch = entityArchetype.get(entityId);
129
193
 
130
194
  if (!arch) {
131
- // Entity has no archetype yet — create single-type archetype
132
- const newArch = getOrCreateArchetype([componentName]);
133
- const map = new Map();
134
- map.set(componentName, data);
135
- addToArchetype(newArch, entityId, map);
195
+ const newArch = getOrCreateArchetype([type]);
196
+ addToArchetype(newArch, entityId, { [type]: data });
136
197
  return;
137
198
  }
138
199
 
139
- if (arch.types.has(componentName)) {
140
- // Already has this component type — just update data
141
- const idx = arch.entityToIndex.get(entityId);
142
- arch.components.get(componentName)[idx] = data;
200
+ if (arch.types.has(type)) {
201
+ const store = arch.components.get(type);
202
+ if (store) {
203
+ const idx = arch.entityToIndex.get(entityId);
204
+ soaWrite(store, idx, data);
205
+ }
143
206
  return;
144
207
  }
145
208
 
146
- // Need to move to a new archetype with the extra type
147
- const newTypes = [...arch.types, componentName];
209
+ const newTypes = [...arch.types, type];
148
210
  const newArch = getOrCreateArchetype(newTypes);
149
211
 
150
- // Collect component data from old archetype
151
212
  const idx = arch.entityToIndex.get(entityId);
152
- const map = new Map();
213
+ const map = { [type]: data };
153
214
  for (const t of arch.types) {
154
- map.set(t, arch.components.get(t)[idx]);
215
+ map[t] = readComponentData(arch, t, idx);
155
216
  }
156
- map.set(componentName, data);
157
217
 
158
218
  removeFromArchetype(arch, entityId);
159
219
  addToArchetype(newArch, entityId, map);
160
220
  },
161
221
 
162
- removeComponent(entityId, componentName) {
222
+ removeComponent(entityId, comp) {
223
+ const type = toSym(comp);
163
224
  const arch = entityArchetype.get(entityId);
164
- if (!arch || !arch.types.has(componentName)) return;
225
+ if (!arch || !arch.types.has(type)) return;
165
226
 
166
227
  if (arch.types.size === 1) {
167
- // Removing last component — entity has no archetype
168
228
  removeFromArchetype(arch, entityId);
169
229
  return;
170
230
  }
171
231
 
172
232
  const newTypes = [];
173
233
  for (const t of arch.types) {
174
- if (t !== componentName) newTypes.push(t);
234
+ if (t !== type) newTypes.push(t);
175
235
  }
176
236
  const newArch = getOrCreateArchetype(newTypes);
177
237
 
178
238
  const idx = arch.entityToIndex.get(entityId);
179
- const map = new Map();
239
+ const map = {};
180
240
  for (const t of newTypes) {
181
- map.set(t, arch.components.get(t)[idx]);
241
+ map[t] = readComponentData(arch, t, idx);
182
242
  }
183
243
 
184
244
  removeFromArchetype(arch, entityId);
185
245
  addToArchetype(newArch, entityId, map);
186
246
  },
187
247
 
188
- getComponent(entityId, componentName) {
248
+ getComponent(entityId, comp) {
249
+ const type = toSym(comp);
250
+ const arch = entityArchetype.get(entityId);
251
+ if (!arch) return undefined;
252
+ const idx = arch.entityToIndex.get(entityId);
253
+ if (idx === undefined) return undefined;
254
+ return readComponentData(arch, type, idx);
255
+ },
256
+
257
+ get(entityId, fieldRef) {
189
258
  const arch = entityArchetype.get(entityId);
190
259
  if (!arch) return undefined;
191
- const arr = arch.components.get(componentName);
192
- if (!arr) return undefined;
193
- return arr[arch.entityToIndex.get(entityId)];
260
+ const store = arch.components.get(fieldRef._sym);
261
+ if (!store) return undefined;
262
+ const idx = arch.entityToIndex.get(entityId);
263
+ return store[fieldRef._field][idx];
264
+ },
265
+
266
+ set(entityId, fieldRef, value) {
267
+ const arch = entityArchetype.get(entityId);
268
+ if (!arch) return;
269
+ const store = arch.components.get(fieldRef._sym);
270
+ if (!store) return;
271
+ const idx = arch.entityToIndex.get(entityId);
272
+ store[fieldRef._field][idx] = value;
194
273
  },
195
274
 
196
- hasComponent(entityId, componentName) {
275
+ hasComponent(entityId, comp) {
276
+ const type = toSym(comp);
197
277
  const arch = entityArchetype.get(entityId);
198
- return arch ? arch.types.has(componentName) : false;
278
+ return arch ? arch.types.has(type) : false;
199
279
  },
200
280
 
201
281
  query(includeTypes, excludeTypes) {
@@ -215,13 +295,19 @@ export function createEntityManager() {
215
295
  return [...allEntityIds];
216
296
  },
217
297
 
218
- createEntityWith(componentMap) {
298
+ createEntityWith(...args) {
219
299
  const id = nextId++;
220
300
  allEntityIds.add(id);
221
301
 
222
- const types = [...componentMap.keys()];
302
+ const types = [];
303
+ const map = {};
304
+ for (let i = 0; i < args.length; i += 2) {
305
+ const sym = toSym(args[i]);
306
+ types.push(sym);
307
+ map[sym] = args[i + 1];
308
+ }
223
309
  const arch = getOrCreateArchetype(types);
224
- addToArchetype(arch, id, componentMap);
310
+ addToArchetype(arch, id, map);
225
311
 
226
312
  return id;
227
313
  },
@@ -235,12 +321,30 @@ export function createEntityManager() {
235
321
  return total;
236
322
  },
237
323
 
324
+ forEach(includeTypes, callback, excludeTypes) {
325
+ const matching = getMatchingArchetypes(includeTypes, excludeTypes);
326
+ for (let a = 0; a < matching.length; a++) {
327
+ const arch = matching[a];
328
+ if (arch.count === 0) continue;
329
+ const view = {
330
+ entityIds: arch.entityIds,
331
+ count: arch.count,
332
+ field(ref) {
333
+ const sym = ref._sym || ref;
334
+ const store = arch.components.get(sym);
335
+ if (!store) return undefined;
336
+ return store[ref._field];
337
+ }
338
+ };
339
+ callback(view);
340
+ }
341
+ },
342
+
238
343
  serialize(symbolToName, stripComponents = [], skipEntitiesWith = [], { serializers } = {}) {
239
- const stripSymbols = new Set(stripComponents);
240
- const skipSymbols = new Set(skipEntitiesWith);
344
+ const stripSymbols = new Set(stripComponents.map(toSym));
345
+ const skipSymbols = new Set(skipEntitiesWith.map(toSym));
241
346
  const skipEntityIds = new Set();
242
347
 
243
- // Find entities that have any "skip entity" component — these are fully excluded
244
348
  if (skipSymbols.size > 0) {
245
349
  for (const arch of archetypes.values()) {
246
350
  let hasSkip = false;
@@ -257,7 +361,8 @@ export function createEntityManager() {
257
361
  const serializedComponents = {};
258
362
 
259
363
  for (const arch of archetypes.values()) {
260
- for (const [sym, arr] of arch.components) {
364
+ for (const [sym, store] of arch.components) {
365
+ if (!store) continue;
261
366
  if (stripSymbols.has(sym) || skipSymbols.has(sym)) continue;
262
367
  const name = symbolToName.get(sym);
263
368
  if (!name) continue;
@@ -272,16 +377,12 @@ export function createEntityManager() {
272
377
  for (let i = 0; i < arch.count; i++) {
273
378
  const entityId = arch.entityIds[i];
274
379
  if (skipEntityIds.has(entityId)) continue;
275
- if (customSerializer) {
276
- entries[entityId] = customSerializer(arr[i]);
277
- } else {
278
- entries[entityId] = structuredClone(arr[i]);
279
- }
380
+ const value = soaRead(store, i);
381
+ entries[entityId] = customSerializer ? customSerializer(value) : value;
280
382
  }
281
383
  }
282
384
  }
283
385
 
284
- // Remove empty component groups
285
386
  for (const name of Object.keys(serializedComponents)) {
286
387
  if (Object.keys(serializedComponents[name]).length === 0) {
287
388
  delete serializedComponents[name];
@@ -301,7 +402,6 @@ export function createEntityManager() {
301
402
  },
302
403
 
303
404
  deserialize(data, nameToSymbol, { deserializers } = {}) {
304
- // Clear all state
305
405
  allEntityIds.clear();
306
406
  archetypes.clear();
307
407
  entityArchetype.clear();
@@ -310,39 +410,39 @@ export function createEntityManager() {
310
410
 
311
411
  nextId = data.nextId;
312
412
 
313
- // Build per-entity component maps
314
413
  const entityComponents = new Map();
315
414
 
316
415
  for (const id of data.entities) {
317
416
  allEntityIds.add(id);
318
- entityComponents.set(id, new Map());
417
+ entityComponents.set(id, {});
319
418
  }
320
419
 
321
420
  for (const [name, store] of Object.entries(data.components)) {
322
- const sym = nameToSymbol[name];
323
- if (!sym) continue;
421
+ const entry = nameToSymbol[name];
422
+ if (!entry) continue;
423
+ const sym = toSym(entry);
324
424
 
325
425
  const customDeserializer = deserializers && deserializers.get(name);
326
426
 
327
427
  for (const [entityIdStr, compData] of Object.entries(store)) {
328
428
  const entityId = Number(entityIdStr);
329
- const map = entityComponents.get(entityId);
330
- if (!map) continue;
429
+ const obj = entityComponents.get(entityId);
430
+ if (!obj) continue;
331
431
 
332
432
  if (customDeserializer) {
333
- map.set(sym, customDeserializer(compData));
433
+ obj[sym] = customDeserializer(compData);
334
434
  } else {
335
- map.set(sym, compData);
435
+ obj[sym] = compData;
336
436
  }
337
437
  }
338
438
  }
339
439
 
340
- // Group by archetype key and bulk-insert
341
440
  const groupedByKey = new Map();
342
441
  for (const [entityId, compMap] of entityComponents) {
343
- if (compMap.size === 0) continue; // entity with no components
442
+ const types = Object.getOwnPropertySymbols(compMap);
443
+ if (types.length === 0) continue;
344
444
 
345
- const key = makeArchetypeKey([...compMap.keys()]);
445
+ const key = computeMask(types);
346
446
  if (!groupedByKey.has(key)) {
347
447
  groupedByKey.set(key, []);
348
448
  }
@@ -350,7 +450,7 @@ export function createEntityManager() {
350
450
  }
351
451
 
352
452
  for (const [key, entries] of groupedByKey) {
353
- const types = [...entries[0].compMap.keys()];
453
+ const types = Object.getOwnPropertySymbols(entries[0].compMap);
354
454
  const arch = getOrCreateArchetype(types);
355
455
 
356
456
  for (const { entityId, compMap } of entries) {
package/src/index.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ // === Basis types ===
2
+ export type EntityId = number;
3
+
4
+ // === Field reference descriptor ===
5
+ export interface FieldRef<V = number> {
6
+ readonly _sym: symbol;
7
+ readonly _field: string;
8
+ }
9
+
10
+ // === Component definition ===
11
+ declare const __phantom: unique symbol;
12
+ export type ComponentDef<T = unknown> = {
13
+ readonly _sym: symbol;
14
+ readonly _name: string;
15
+ readonly [__phantom]?: T;
16
+ } & (T extends Record<string, number | string>
17
+ ? { readonly [K in keyof T & string]: FieldRef<T[K]> }
18
+ : {});
19
+
20
+ /** @deprecated Use ComponentDef<T> instead */
21
+ export type Component<T = unknown> = ComponentDef<T>;
22
+ export type ComponentType = ComponentDef;
23
+
24
+ // === TypedArray schema ===
25
+ export type TypedArrayType = 'f32' | 'f64' | 'i8' | 'i16' | 'i32' | 'u8' | 'u16' | 'u32' | 'string';
26
+
27
+ export type Schema = Record<string, TypedArrayType>;
28
+
29
+ /** Maps a schema type to its runtime value type */
30
+ type FieldToType<T extends TypedArrayType> = T extends 'string' ? string : number;
31
+
32
+ /** Maps a schema definition to its runtime value type */
33
+ type SchemaToType<S extends Schema> = { [K in keyof S]: FieldToType<S[K]> };
34
+
35
+ export declare const TYPED: unique symbol;
36
+
37
+ export declare const componentSchemas: Map<symbol, Record<string, Float32ArrayConstructor | Float64ArrayConstructor | Int8ArrayConstructor | Int16ArrayConstructor | Int32ArrayConstructor | Uint8ArrayConstructor | Uint16ArrayConstructor | Uint32ArrayConstructor | ArrayConstructor>>;
38
+
39
+ // === TypedArray union ===
40
+ type TypedArray = Float32Array | Float64Array | Int8Array | Int16Array | Int32Array | Uint8Array | Uint16Array | Uint32Array;
41
+
42
+ // === ArchetypeView (forEach callback) ===
43
+ export interface ArchetypeView {
44
+ readonly entityIds: EntityId[];
45
+ readonly count: number;
46
+ field(ref: FieldRef<any>): TypedArray | unknown[] | undefined;
47
+ }
48
+
49
+ // === Serialize/Deserialize ===
50
+ export interface SerializeOptions {
51
+ serializers?: Map<string, (data: unknown) => unknown>;
52
+ }
53
+
54
+ export interface DeserializeOptions {
55
+ deserializers?: Map<string, (data: unknown) => unknown>;
56
+ }
57
+
58
+ export interface SerializedData {
59
+ nextId: number;
60
+ entities: EntityId[];
61
+ components: Record<string, Record<string, unknown>>;
62
+ }
63
+
64
+ // === EntityManager ===
65
+ export interface EntityManager {
66
+ createEntity(): EntityId;
67
+ destroyEntity(id: EntityId): void;
68
+ addComponent<T>(entityId: EntityId, type: ComponentDef<T>, data: T): void;
69
+ removeComponent(entityId: EntityId, type: ComponentDef): void;
70
+ getComponent<T>(entityId: EntityId, type: ComponentDef<T>): T | undefined;
71
+ get<V>(entityId: EntityId, fieldRef: FieldRef<V>): V | undefined;
72
+ set<V>(entityId: EntityId, fieldRef: FieldRef<V>, value: V): void;
73
+ hasComponent(entityId: EntityId, type: ComponentDef): boolean;
74
+ query(include: ComponentDef[], exclude?: ComponentDef[]): EntityId[];
75
+ getAllEntities(): EntityId[];
76
+ createEntityWith(...args: unknown[]): EntityId;
77
+ count(include: ComponentDef[], exclude?: ComponentDef[]): number;
78
+ forEach(include: ComponentDef[], callback: (view: ArchetypeView) => void, exclude?: ComponentDef[]): void;
79
+ serialize(
80
+ symbolToName: Map<symbol, string>,
81
+ stripComponents?: ComponentDef[],
82
+ skipEntitiesWith?: ComponentDef[],
83
+ options?: SerializeOptions
84
+ ): SerializedData;
85
+ deserialize(
86
+ data: SerializedData,
87
+ nameToSymbol: Record<string, ComponentDef>,
88
+ options?: DeserializeOptions
89
+ ): void;
90
+ }
91
+
92
+ // === Profiler ===
93
+ export interface ProfilerEntry {
94
+ avg: number;
95
+ }
96
+
97
+ export interface Profiler {
98
+ readonly enabled: boolean;
99
+ setEnabled(value: boolean): void;
100
+ begin(): number;
101
+ end(name: string, t0: number): void;
102
+ record(name: string, ms: number): void;
103
+ getData(): Map<string, ProfilerEntry>;
104
+ }
105
+
106
+ // === Exports ===
107
+ export function createEntityManager(): EntityManager;
108
+ export function component(name: string): ComponentDef;
109
+ export function component<T extends TypedArrayType, F extends string>(name: string, type: T, fields: F[]): ComponentDef<Record<F, FieldToType<T>>>;
110
+ export function component<S extends Schema>(name: string, schema: S): ComponentDef<SchemaToType<S>>;
111
+ export const profiler: Profiler;
package/src/index.js CHANGED
@@ -1,5 +1,37 @@
1
1
  export { createEntityManager } from './EntityManager.js';
2
- export { createEventBus } from './EventBus.js';
3
- export { createContainer } from './Container.js';
4
- export { createGameLoop } from './GameLoop.js';
5
2
  export { profiler } from './Profiler.js';
3
+ export { TYPED, componentSchemas } from './ComponentRegistry.js';
4
+ import { TYPE_MAP, componentSchemas } from './ComponentRegistry.js';
5
+
6
+ export function component(name, typeOrSchema, fields) {
7
+ const sym = Symbol(name);
8
+ const comp = { _sym: sym, _name: name };
9
+
10
+ let schema;
11
+
12
+ if (typeof typeOrSchema === 'string' && Array.isArray(fields)) {
13
+ // Short form: component('Position', 'f32', ['x', 'y'])
14
+ const Ctor = TYPE_MAP[typeOrSchema];
15
+ if (!Ctor) throw new Error(`Unknown type "${typeOrSchema}"`);
16
+ schema = {};
17
+ for (const f of fields) {
18
+ schema[f] = Ctor;
19
+ comp[f] = { _sym: sym, _field: f };
20
+ }
21
+ } else if (typeOrSchema && typeof typeOrSchema === 'object') {
22
+ // Schema form: component('Position', { x: 'f32', y: 'f32' })
23
+ schema = {};
24
+ for (const [field, type] of Object.entries(typeOrSchema)) {
25
+ const Ctor = TYPE_MAP[type];
26
+ if (!Ctor) throw new Error(`Unknown type "${type}" for field "${field}"`);
27
+ schema[field] = Ctor;
28
+ comp[field] = { _sym: sym, _field: field };
29
+ }
30
+ }
31
+
32
+ if (schema) {
33
+ componentSchemas.set(sym, schema);
34
+ }
35
+
36
+ return comp;
37
+ }