@multiplekex/shallot 0.1.7 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplekex/shallot",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -25,9 +25,9 @@
25
25
  "@napi-rs/canvas": "^0.1.88",
26
26
  "@types/bun": "latest",
27
27
  "@types/three": "^0.182.0",
28
+ "bun-webgpu": "^0.1.4",
28
29
  "three": "^0.182.0",
29
30
  "troika-three-text": "^0.52.4",
30
- "webgpu": "^0.3.8",
31
31
  "wgpu-matrix": "^3.4.0"
32
32
  },
33
33
  "peerDependencies": {
@@ -1,14 +1,8 @@
1
- import type { State } from "./state";
2
1
  import { toKebabCase } from "./strings";
3
2
 
4
3
  export type ComponentArray = number[] | Float32Array | Uint32Array;
5
4
  export type ComponentData = Record<string, ComponentArray>;
6
-
7
- export interface ParseContext {
8
- readonly currentEid: number;
9
- getEntityByName(name: string): number | null;
10
- setName(name: string, eid: number): void;
11
- }
5
+ export type ComponentLike = Record<string, unknown>;
12
6
 
13
7
  export interface FieldAccessor {
14
8
  get(eid: number): number;
@@ -17,33 +11,29 @@ export interface FieldAccessor {
17
11
 
18
12
  export interface ComponentTraits {
19
13
  defaults?: () => Record<string, number>;
20
- adapter?: (
21
- attrs: Record<string, string>,
22
- state: State,
23
- context: ParseContext
24
- ) => Record<string, number>;
14
+ adapter?: (attrs: Record<string, string>, eid: number) => Record<string, number>;
25
15
  accessors?: Record<string, FieldAccessor>;
26
16
  }
27
17
 
28
- const traitsMap = new WeakMap<ComponentData, ComponentTraits>();
18
+ const traitsMap = new WeakMap<ComponentLike, ComponentTraits>();
29
19
 
30
- export function setTraits(component: ComponentData, traits: ComponentTraits): void {
20
+ export function setTraits(component: ComponentLike, traits: ComponentTraits): void {
31
21
  traitsMap.set(component, traits);
32
22
  }
33
23
 
34
- export function getTraits(component: ComponentData): ComponentTraits | undefined {
24
+ export function getTraits(component: ComponentLike): ComponentTraits | undefined {
35
25
  return traitsMap.get(component);
36
26
  }
37
27
 
38
28
  export interface RegisteredComponent {
39
- readonly component: ComponentData;
29
+ readonly component: ComponentLike;
40
30
  readonly name: string;
41
31
  readonly traits?: ComponentTraits;
42
32
  }
43
33
 
44
34
  const registry = new Map<string, RegisteredComponent>();
45
35
 
46
- export function registerComponent(name: string, component: ComponentData): void {
36
+ export function registerComponent(name: string, component: ComponentLike): void {
47
37
  const kebabName = toKebabCase(name);
48
38
  const traits = traitsMap.get(component);
49
39
  registry.set(kebabName, { component, name: kebabName, traits });
package/src/core/index.ts CHANGED
@@ -43,9 +43,9 @@ export {
43
43
  getTraits,
44
44
  setTraits,
45
45
  directAccessor,
46
- type ParseContext,
47
46
  type ComponentTraits,
48
47
  type ComponentData,
48
+ type ComponentLike,
49
49
  type RegisteredComponent,
50
50
  type FieldAccessor,
51
51
  } from "./component";
@@ -89,4 +89,5 @@ export {
89
89
  type ParseError,
90
90
  type LoadResult,
91
91
  type PostLoadHook,
92
+ type PostLoadContext,
92
93
  } from "./xml";
package/src/core/math.ts CHANGED
@@ -20,6 +20,12 @@ export function slerp(
20
20
  toW: number,
21
21
  t: number
22
22
  ): { x: number; y: number; z: number; w: number } {
23
+ if (!Number.isFinite(t) || !Number.isFinite(fromW) || !Number.isFinite(toW)) {
24
+ throw new Error(
25
+ `slerp received NaN: from=[${fromX},${fromY},${fromZ},${fromW}], to=[${toX},${toY},${toZ},${toW}], t=${t}`
26
+ );
27
+ }
28
+
23
29
  let dot = fromX * toX + fromY * toY + fromZ * toZ + fromW * toW;
24
30
 
25
31
  if (dot < 0) {
@@ -154,6 +160,19 @@ export function lookAt(
154
160
  upY = 1,
155
161
  upZ = 0
156
162
  ): { x: number; y: number; z: number; w: number } {
163
+ if (
164
+ !Number.isFinite(eyeX) ||
165
+ !Number.isFinite(eyeY) ||
166
+ !Number.isFinite(eyeZ) ||
167
+ !Number.isFinite(targetX) ||
168
+ !Number.isFinite(targetY) ||
169
+ !Number.isFinite(targetZ)
170
+ ) {
171
+ throw new Error(
172
+ `lookAt received NaN: eye=[${eyeX},${eyeY},${eyeZ}], target=[${targetX},${targetY},${targetZ}]`
173
+ );
174
+ }
175
+
157
176
  let zx = eyeX - targetX;
158
177
  let zy = eyeY - targetY;
159
178
  let zz = eyeZ - targetZ;
@@ -173,7 +192,7 @@ export function lookAt(
173
192
  let xz = upX * zy - upY * zx;
174
193
  let xLen = Math.sqrt(xx * xx + xy * xy + xz * xz);
175
194
 
176
- if (xLen === 0) {
195
+ if (xLen < 1e-6) {
177
196
  if (Math.abs(zz) > Math.abs(zx)) {
178
197
  upX += 1e-4;
179
198
  } else {
@@ -185,10 +204,16 @@ export function lookAt(
185
204
  xLen = Math.sqrt(xx * xx + xy * xy + xz * xz);
186
205
  }
187
206
 
188
- xLen = 1 / xLen;
189
- xx *= xLen;
190
- xy *= xLen;
191
- xz *= xLen;
207
+ if (xLen < 1e-6) {
208
+ xx = 1;
209
+ xy = 0;
210
+ xz = 0;
211
+ } else {
212
+ xLen = 1 / xLen;
213
+ xx *= xLen;
214
+ xy *= xLen;
215
+ xz *= xLen;
216
+ }
192
217
 
193
218
  const yx = zy * xz - zz * xy;
194
219
  const yy = zz * xx - zx * xz;
package/src/core/state.ts CHANGED
@@ -37,6 +37,7 @@ export class State {
37
37
  readonly world: World;
38
38
  readonly scheduler = new Scheduler();
39
39
  readonly canvas: HTMLCanvasElement | null;
40
+ maxEid = 0;
40
41
 
41
42
  private _resources = new Map<symbol, unknown>();
42
43
  private _disposed = false;
@@ -140,6 +141,7 @@ export class State {
140
141
  if (eid >= MAX_ENTITIES) {
141
142
  throw new Error(`Entity limit exceeded: ${eid} >= ${MAX_ENTITIES}`);
142
143
  }
144
+ if (eid > this.maxEid) this.maxEid = eid;
143
145
  return eid;
144
146
  }
145
147
 
@@ -268,9 +270,9 @@ export class State {
268
270
  }
269
271
 
270
272
  const array = registered.component[camelPath];
271
- if (array == null) return null;
273
+ if (array == null || !(ArrayBuffer.isView(array) || Array.isArray(array))) return null;
272
274
 
273
- const accessor = directAccessor(array, 1, 0);
275
+ const accessor = directAccessor(array as number[], 1, 0);
274
276
  this.ensureFieldAccessors().set(bindingId, accessor);
275
277
  return accessor;
276
278
  }
package/src/core/types.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type { System } from "./scheduler";
2
- import type { ComponentData } from "./component";
2
+ import type { ComponentLike } from "./component";
3
3
  import type { RelationDef } from "./relation";
4
4
  import type { State } from "./state";
5
5
 
6
6
  export interface Plugin {
7
7
  readonly systems?: readonly System[];
8
- readonly components?: Record<string, ComponentData>;
8
+ readonly components?: Record<string, ComponentLike>;
9
9
  readonly relations?: readonly RelationDef[];
10
10
  readonly dependencies?: readonly Plugin[];
11
11
  readonly initialize?: (state: State) => void | Promise<void>;
package/src/core/xml.ts CHANGED
@@ -1,12 +1,7 @@
1
1
  import { XMLParser } from "fast-xml-parser";
2
2
  import { addComponent, Pair } from "bitecs";
3
3
  import type { State } from "./state";
4
- import {
5
- getRegisteredComponent,
6
- type ParseContext,
7
- type ComponentData,
8
- type RegisteredComponent,
9
- } from "./component";
4
+ import { getRegisteredComponent, type ComponentLike, type RegisteredComponent } from "./component";
10
5
  import { getRelationDef, ChildOf } from "./relation";
11
6
  import { toKebabCase, toCamelCase } from "./strings";
12
7
 
@@ -100,6 +95,13 @@ export interface ParseError {
100
95
  readonly path?: string;
101
96
  }
102
97
 
98
+ export interface PendingFieldRef {
99
+ readonly eid: number;
100
+ readonly component: ComponentLike;
101
+ readonly field: string;
102
+ readonly targetName: string;
103
+ }
104
+
103
105
  export function parseXml(xml: string): ParseResult {
104
106
  const errors: ParseError[] = [];
105
107
  const warnings: string[] = [];
@@ -276,7 +278,11 @@ export interface LoadResult {
276
278
  readonly errors: readonly ParseError[];
277
279
  }
278
280
 
279
- export type PostLoadHook = (state: State, context: ParseContext) => void;
281
+ export interface PostLoadContext {
282
+ getEntityByName(name: string): number | null;
283
+ }
284
+
285
+ export type PostLoadHook = (state: State, context: PostLoadContext) => void;
280
286
 
281
287
  const postLoadHooks: PostLoadHook[] = [];
282
288
 
@@ -289,17 +295,8 @@ export function unregisterPostLoadHook(hook: PostLoadHook): void {
289
295
  if (idx !== -1) postLoadHooks.splice(idx, 1);
290
296
  }
291
297
 
292
- class LoadParseContext implements ParseContext {
298
+ class LoadParseContext implements PostLoadContext {
293
299
  private readonly nameToEntity = new Map<string, number>();
294
- private _currentEid = 0;
295
-
296
- get currentEid(): number {
297
- return this._currentEid;
298
- }
299
-
300
- setCurrentEid(eid: number): void {
301
- this._currentEid = eid;
302
- }
303
300
 
304
301
  getEntityByName(name: string): number | null {
305
302
  return this.nameToEntity.get(name) ?? null;
@@ -343,6 +340,7 @@ function instantiateScene(
343
340
  const errors: ParseError[] = [...parseErrors];
344
341
  const queue: QueuedEntity[] = [];
345
342
  const roots: number[] = [];
343
+ const pendingFieldRefs: PendingFieldRef[] = [];
346
344
 
347
345
  for (const entityDef of entityDefs) {
348
346
  const eid = createEntityTree(state, entityDef, context, undefined, queue);
@@ -359,10 +357,19 @@ function instantiateScene(
359
357
  }
360
358
 
361
359
  for (const compDef of def.components) {
362
- applyComponent(state, eid, compDef, context, errors);
360
+ applyComponent(state, eid, compDef, context, errors, pendingFieldRefs);
363
361
  }
364
362
  }
365
363
 
364
+ for (const ref of pendingFieldRefs) {
365
+ const targetEid = context.getEntityByName(ref.targetName);
366
+ if (targetEid === null) {
367
+ errors.push({ message: `Unknown entity: "@${ref.targetName}"` });
368
+ continue;
369
+ }
370
+ setFieldValue(ref.component, ref.field, ref.eid, targetEid);
371
+ }
372
+
366
373
  for (const hook of postLoadHooks) {
367
374
  hook(state, context);
368
375
  }
@@ -423,7 +430,8 @@ function applyComponent(
423
430
  eid: number,
424
431
  compDef: ComponentDef,
425
432
  context: LoadParseContext,
426
- errors: ParseError[]
433
+ errors: ParseError[],
434
+ pendingFieldRefs: PendingFieldRef[]
427
435
  ): void {
428
436
  const { def, attrs } = compDef;
429
437
  const { component, name, traits } = def;
@@ -436,13 +444,14 @@ function applyComponent(
436
444
  }
437
445
 
438
446
  let values: Record<string, number>;
447
+ let entityRefs: FieldEntityRef[] = [];
439
448
 
440
- context.setCurrentEid(eid);
441
449
  if (traits?.adapter) {
442
- values = traits.adapter(attrs, state, context) as Record<string, number>;
450
+ values = traits.adapter(attrs, eid);
443
451
  } else {
444
452
  const result = parseAttrs(def, attrs);
445
453
  values = result.values;
454
+ entityRefs = result.entityRefs;
446
455
  for (const err of result.errors) {
447
456
  errors.push({ message: `<${name}> ${err}` });
448
457
  }
@@ -451,19 +460,33 @@ function applyComponent(
451
460
  for (const [field, value] of Object.entries(values)) {
452
461
  setFieldValue(component, field, eid, value);
453
462
  }
463
+
464
+ for (const ref of entityRefs) {
465
+ pendingFieldRefs.push({
466
+ eid,
467
+ component,
468
+ field: ref.field,
469
+ targetName: ref.targetName,
470
+ });
471
+ }
472
+ }
473
+
474
+ interface ParseAttrsResult {
475
+ values: Record<string, number>;
476
+ entityRefs: FieldEntityRef[];
477
+ errors: string[];
454
478
  }
455
479
 
456
- function parseAttrs(
457
- def: RegisteredComponent,
458
- attrs: Record<string, string>
459
- ): { values: Record<string, number>; errors: string[] } {
480
+ function parseAttrs(def: RegisteredComponent, attrs: Record<string, string>): ParseAttrsResult {
460
481
  const allValues: Record<string, number> = {};
482
+ const allEntityRefs: FieldEntityRef[] = [];
461
483
  const allErrors: string[] = [];
462
484
 
463
485
  if (attrs._value) {
464
486
  if (isCSSAttrSyntax(attrs._value)) {
465
487
  const result = parsePropertyString(def.name, attrs._value, def.component);
466
488
  Object.assign(allValues, result.values);
489
+ allEntityRefs.push(...result.entityRefs);
467
490
  allErrors.push(...result.errors);
468
491
  }
469
492
  }
@@ -475,6 +498,7 @@ function parseAttrs(
475
498
  if (isCSSAttrSyntax(attrValue)) {
476
499
  const result = parsePropertyString(def.name, attrValue, def.component);
477
500
  Object.assign(allValues, result.values);
501
+ allEntityRefs.push(...result.entityRefs);
478
502
  allErrors.push(...result.errors);
479
503
  } else {
480
504
  const result = parsePropertyString(
@@ -483,34 +507,41 @@ function parseAttrs(
483
507
  def.component
484
508
  );
485
509
  Object.assign(allValues, result.values);
510
+ allEntityRefs.push(...result.entityRefs);
486
511
  allErrors.push(...result.errors);
487
512
  }
488
513
  }
489
514
 
490
- return { values: allValues, errors: allErrors };
515
+ return { values: allValues, entityRefs: allEntityRefs, errors: allErrors };
491
516
  }
492
517
 
493
- function setFieldValue(component: ComponentData, field: string, eid: number, value: number): void {
518
+ function setFieldValue(component: ComponentLike, field: string, eid: number, value: number): void {
494
519
  const arr = component[field];
495
520
  if (arr != null && (ArrayBuffer.isView(arr) || Array.isArray(arr))) {
496
- arr[eid] = value;
521
+ (arr as number[])[eid] = value;
497
522
  }
498
523
  }
499
524
 
525
+ interface FieldEntityRef {
526
+ readonly field: string;
527
+ readonly targetName: string;
528
+ }
529
+
500
530
  interface PropertyParseResult {
501
531
  readonly values: Record<string, number>;
532
+ readonly entityRefs: readonly FieldEntityRef[];
502
533
  readonly errors: readonly string[];
503
534
  }
504
535
 
505
- function detectVec2(component: ComponentData, base: string): boolean {
536
+ function detectVec2(component: ComponentLike, base: string): boolean {
506
537
  return `${base}X` in component && `${base}Y` in component;
507
538
  }
508
539
 
509
- function detectVec3(component: ComponentData, base: string): boolean {
540
+ function detectVec3(component: ComponentLike, base: string): boolean {
510
541
  return detectVec2(component, base) && `${base}Z` in component;
511
542
  }
512
543
 
513
- function detectVec4(component: ComponentData, base: string): boolean {
544
+ function detectVec4(component: ComponentLike, base: string): boolean {
514
545
  return detectVec3(component, base) && `${base}W` in component;
515
546
  }
516
547
 
@@ -565,9 +596,10 @@ function splitProperties(str: string): string[] {
565
596
  function parsePropertyString(
566
597
  componentName: string,
567
598
  propertyString: string,
568
- component: ComponentData
599
+ component: ComponentLike
569
600
  ): PropertyParseResult {
570
601
  const values: Record<string, number> = {};
602
+ const entityRefs: FieldEntityRef[] = [];
571
603
  const errors: string[] = [];
572
604
 
573
605
  const properties = splitProperties(propertyString);
@@ -588,6 +620,24 @@ function parsePropertyString(
588
620
  }
589
621
 
590
622
  const name = toCamelCase(rawName);
623
+
624
+ if (valueStr.startsWith("@") && valueStr.length > 1) {
625
+ if (name in component) {
626
+ entityRefs.push({ field: name, targetName: valueStr.slice(1) });
627
+ } else {
628
+ const fieldNames = Object.keys(component);
629
+ const suggestion = findClosestMatch(rawName, fieldNames);
630
+ if (suggestion) {
631
+ errors.push(
632
+ `${componentName}: unknown field "${rawName}", did you mean "${toKebabCase(suggestion)}"?`
633
+ );
634
+ } else {
635
+ errors.push(`${componentName}: unknown field "${rawName}"`);
636
+ }
637
+ }
638
+ continue;
639
+ }
640
+
591
641
  const parsed = parseValues(valueStr);
592
642
 
593
643
  if (parsed.some((v) => v === null)) {
@@ -668,7 +718,7 @@ function parsePropertyString(
668
718
  }
669
719
  }
670
720
 
671
- return { values, errors };
721
+ return { values, entityRefs, errors };
672
722
  }
673
723
 
674
724
  function isCSSAttrSyntax(value: string): boolean {