@driftengine/script 4.7.4 → 4.8.1

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,8 +1,17 @@
1
1
  import { BehaviorRunner } from '@driftengine/core';
2
2
  import { type CapabilityDefinition, type OpaqueType, defineCapability } from 'driftscript';
3
+ import { hostEnum } from './variants.ts';
3
4
 
4
5
  export const BEHAVIOR_MODULE = 'drift/behavior';
5
6
 
7
+ /** What a tick answered, in the engine's own order: `FAILURE`, `SUCCESS`, `RUNNING`. */
8
+ const TREE_STATUS = hostEnum(
9
+ BEHAVIOR_MODULE,
10
+ 'TreeStatus',
11
+ ['Failure', 'Success', 'Running'],
12
+ 'What a behaviour tree answered: it failed, it finished, or it is still going.',
13
+ );
14
+
6
15
  /**
7
16
  * `drift/behavior` — driving and watching a behaviour tree from a script.
8
17
  *
@@ -29,6 +38,7 @@ export const BEHAVIOR_TYPES: readonly OpaqueType[] = [
29
38
  name: 'Behavior',
30
39
  doc: 'One agent running one behaviour tree: where it is in its routine.',
31
40
  },
41
+ TREE_STATUS.type,
32
42
  ];
33
43
 
34
44
  const define = (
@@ -55,15 +65,15 @@ export const BEHAVIOR_CAPABILITIES: readonly CapabilityDefinition[] = [
55
65
  define(
56
66
  'tick',
57
67
  [{ name: 'behavior', type: 'Behavior' }],
58
- 'i32',
68
+ 'TreeStatus',
59
69
  ['behavior.write'],
60
- 'Advance the routine one tick. Answers 0 failed, 1 finished, 2 still going. Does nothing while paused.',
70
+ 'Advance the routine one tick, and answer whether it failed, finished or is still going. Does nothing while paused.',
61
71
  false,
62
72
  ),
63
73
  define(
64
74
  'step',
65
75
  [{ name: 'behavior', type: 'Behavior' }],
66
- 'i32',
76
+ 'TreeStatus',
67
77
  ['behavior.write'],
68
78
  'Advance one tick even while paused, which is what a step button is.',
69
79
  false,
@@ -97,7 +107,7 @@ export const BEHAVIOR_CAPABILITIES: readonly CapabilityDefinition[] = [
97
107
  define(
98
108
  'status',
99
109
  [{ name: 'behavior', type: 'Behavior' }],
100
- 'i32',
110
+ 'TreeStatus',
101
111
  ['behavior.read'],
102
112
  'What the last tick answered, without ticking again.',
103
113
  ),
@@ -142,14 +152,16 @@ export function behaviorImplementation(services: BehaviorServices): Record<strin
142
152
  let scratch: Uint32Array<ArrayBuffer> = new Uint32Array(32);
143
153
 
144
154
  return {
145
- tick: (character: BehaviorRunner<unknown>) => character.tick(services.context(character)),
146
- step: (character: BehaviorRunner<unknown>) => character.stepOnce(services.context(character)),
155
+ tick: (character: BehaviorRunner<unknown>) =>
156
+ TREE_STATUS.of[character.tick(services.context(character))],
157
+ step: (character: BehaviorRunner<unknown>) =>
158
+ TREE_STATUS.of[character.stepOnce(services.context(character))],
147
159
  restart: (character: BehaviorRunner<unknown>) => character.reset(),
148
160
  setPaused: (character: BehaviorRunner<unknown>, paused: boolean) => {
149
161
  character.paused = paused;
150
162
  },
151
163
  paused: (character: BehaviorRunner<unknown>) => character.paused,
152
- status: (character: BehaviorRunner<unknown>) => character.status,
164
+ status: (character: BehaviorRunner<unknown>) => TREE_STATUS.of[character.status],
153
165
  depth(character: BehaviorRunner<unknown>): number {
154
166
  scratch = fit(scratch, character);
155
167
  return character.activePath(scratch);
@@ -45,6 +45,7 @@ import {
45
45
  } from '@driftengine/chemistry';
46
46
  import { type ComponentType, defineComponent } from '@driftengine/entities';
47
47
  import { type CapabilityDefinition, type OpaqueType, defineCapability } from 'driftscript';
48
+ import { hostEnum } from './variants.ts';
48
49
 
49
50
  export const CHEMISTRY_MODULE = 'drift/chemistry';
50
51
 
@@ -68,6 +69,44 @@ const define = (
68
69
  implementation: `${CHEMISTRY_MODULE}.${name}`,
69
70
  });
70
71
 
72
+ /** A parcel's phase, in the package's order: `PHASE_SOLID`, `_LIQUID`, `_GAS`, then `PHASE_MIXED`. */
73
+ export const MATTER_PHASE = hostEnum(
74
+ CHEMISTRY_MODULE,
75
+ 'MatterPhase',
76
+ ['Solid', 'Liquid', 'Gas', 'Mixed'],
77
+ 'What a parcel is made of now. Mixed is a real answer: wet wood is a solid and a liquid at once.',
78
+ );
79
+
80
+ /** What happened to a parcel this tick, in the order of the package's `EVENT_*` constants. */
81
+ export const CHEMISTRY_EVENT = hostEnum(
82
+ CHEMISTRY_MODULE,
83
+ 'ChemistryEvent',
84
+ [
85
+ 'Ignited',
86
+ 'Extinguished',
87
+ 'SmoulderStart',
88
+ 'SmoulderEnd',
89
+ 'Consumed',
90
+ 'Charred',
91
+ 'StructuralFail',
92
+ 'Frozen',
93
+ 'Melted',
94
+ 'Boiled',
95
+ 'Condensed',
96
+ 'Sublimed',
97
+ 'Corroded',
98
+ 'Calcined',
99
+ 'Dissolved',
100
+ 'Denatured',
101
+ 'Browned',
102
+ 'Caramelised',
103
+ 'Fermented',
104
+ 'Decayed',
105
+ 'Exploded',
106
+ ],
107
+ 'What happened to a parcel this tick: it caught, went out, melted, boiled and the rest.',
108
+ );
109
+
71
110
  /**
72
111
  * The three opaque types `§20.2` names.
73
112
  *
@@ -91,6 +130,8 @@ export const CHEMISTRY_TYPES: readonly OpaqueType[] = [
91
130
  name: 'Species',
92
131
  doc: 'A registered chemical species, such as `O2` or `cellulose`.',
93
132
  },
133
+ MATTER_PHASE.type,
134
+ CHEMISTRY_EVENT.type,
94
135
  ];
95
136
 
96
137
  const READ = ['chemistry.read'] as const;
@@ -199,9 +240,9 @@ export const CHEMISTRY_CAPABILITIES: readonly CapabilityDefinition[] = [
199
240
  define(
200
241
  'phase',
201
242
  [P, PARCEL],
202
- 'i32',
243
+ 'MatterPhase',
203
244
  READ,
204
- '0 solid, 1 liquid, 2 gas, 3 mixed. Mixed is a real answer: wet wood is a solid and a liquid at once.',
245
+ 'Whether the parcel is solid, liquid, gas or mixed. Mixed is a real answer: wet wood is a solid and a liquid at once.',
205
246
  ),
206
247
  define('burning', [P, PARCEL], 'bool', READ, 'Whether a flame stands over it.'),
207
248
  define(
@@ -401,9 +442,9 @@ export const CHEMISTRY_CAPABILITIES: readonly CapabilityDefinition[] = [
401
442
  define(
402
443
  'eventKind',
403
444
  [P, { name: 'index', type: 'i32' }],
404
- 'i32',
445
+ 'ChemistryEvent',
405
446
  READ,
406
- '0 ignited, 1 extinguished, 2 smoulderStart, 3 smoulderEnd, 4 consumed, 5 charred, 6 structuralFail, 7 frozen, 8 melted, 9 boiled, 10 condensed, 11 sublimed, 12 corroded, 13 calcined, 14 dissolved, 15 denatured, 16 browned, 17 caramelised, 18 fermented, 19 decayed, 20 exploded.',
447
+ 'What happened in one of this tick’s events. An index outside `eventCount` is refused, naming both.',
407
448
  ),
408
449
  define(
409
450
  'eventParcel',
@@ -496,7 +537,7 @@ export function chemistryImplementation(services: ChemistryServices): Record<str
496
537
  charDepth: (_: unknown, parcel: number) => parcels.charDepthOf(parcel),
497
538
  wetness: (_: unknown, parcel: number) => parcels.wetnessOf(parcel),
498
539
  wettable: (_: unknown, parcel: number) => parcels.wettable(parcel),
499
- phase: (_: unknown, parcel: number) => parcels.parcelPhaseOf(parcel),
540
+ phase: (_: unknown, parcel: number) => MATTER_PHASE.of[parcels.parcelPhaseOf(parcel)],
500
541
  burning: (_: unknown, parcel: number) => parcels.burning(parcel),
501
542
  smouldering: (_: unknown, parcel: number) => parcels.smouldering(parcel),
502
543
  /* Watts inside, kilowatts out: a fire is quoted in kW everywhere a person reads one. */
@@ -548,7 +589,14 @@ export function chemistryImplementation(services: ChemistryServices): Record<str
548
589
  air.addHeat(x, y, z, joules),
549
590
 
550
591
  eventCount: () => world.events.count,
551
- eventKind: (_: unknown, index: number) => world.events.kindOf(index),
592
+ eventKind(_: unknown, index: number) {
593
+ /* The buffer is read unchecked, and past `count` it holds the last tick's events or nothing:
594
+ a kind that did not happen this tick, or no variant at all. */
595
+ if (!(index >= 0 && index < world.events.count)) {
596
+ throw new RangeError(`event ${index} is outside this tick's ${world.events.count}`);
597
+ }
598
+ return CHEMISTRY_EVENT.of[world.events.kindOf(index)];
599
+ },
552
600
  eventParcel: (_: unknown, index: number) => world.events.parcelOf(index),
553
601
  eventSpecies: (_: unknown, index: number) => world.events.speciesOf(index),
554
602
  eventValue: (_: unknown, index: number) => world.events.valueOf(index),
@@ -25,6 +25,7 @@ import {
25
25
  type Collider,
26
26
  ColliderSet,
27
27
  type KeyValueStore,
28
+ type SaveStatus,
28
29
  RemoteSaveStore,
29
30
  MessageQueue,
30
31
  PhysicsWorld,
@@ -41,6 +42,7 @@ import {
41
42
  wrapAngle,
42
43
  } from '@driftengine/core';
43
44
  import { type CapabilityDefinition, type OpaqueType, defineCapability } from 'driftscript';
45
+ import { type Variant, hostEnum } from './variants.ts';
44
46
 
45
47
  export const TIME_MODULE = 'drift/time';
46
48
  export const RANDOM_MODULE = 'drift/random';
@@ -72,6 +74,28 @@ const define = (
72
74
  implementation: `${module}.${name}`,
73
75
  });
74
76
 
77
+ /** A contact event's kind, in `@driftengine/physics`'s order: `EVENT_ENTER`, `_STAY`, `_EXIT`. */
78
+ const CONTACT_KIND = hostEnum(
79
+ 'drift/physics',
80
+ 'ContactKind',
81
+ ['Enter', 'Stay', 'Exit'],
82
+ 'Whether two bodies began touching this step, are still touching, or have parted.',
83
+ );
84
+
85
+ /** A remote store's `SaveStatus`, one variant per string the store answers with. */
86
+ const SAVE_STATUS = hostEnum(
87
+ 'drift/persistence',
88
+ 'SaveStatus',
89
+ ['Idle', 'Pending', 'Saving', 'Failed'],
90
+ 'Whether a store’s writes have landed: idle, waiting to send, sending, or failed.',
91
+ );
92
+ const SAVE_STATUS_OF: Readonly<Record<SaveStatus, Variant>> = {
93
+ idle: SAVE_STATUS.of[0] as Variant,
94
+ pending: SAVE_STATUS.of[1] as Variant,
95
+ saving: SAVE_STATUS.of[2] as Variant,
96
+ failed: SAVE_STATUS.of[3] as Variant,
97
+ };
98
+
75
99
  /**
76
100
  * Handles a script holds and passes back, never reads into.
77
101
  *
@@ -119,6 +143,8 @@ export const CORE_TYPES: readonly OpaqueType[] = [
119
143
  doc: 'A priority message queue with dedupe and a ceiling.',
120
144
  },
121
145
  { module: PERSISTENCE_MODULE, name: 'Store', doc: 'A key-value store the consumer supplied.' },
146
+ CONTACT_KIND.type,
147
+ SAVE_STATUS.type,
122
148
  ];
123
149
 
124
150
  /**
@@ -306,10 +332,10 @@ export const PERSISTENCE_CAPABILITIES: readonly CapabilityDefinition[] = [
306
332
  PERSISTENCE_MODULE,
307
333
  'saveStatus',
308
334
  [{ name: 'store', type: 'Store' }],
309
- 'String',
335
+ 'SaveStatus',
310
336
  ['persistence.read'],
311
337
  false,
312
- 'Whether writes have landed: `idle`, `pending`, `saving` or `failed`. A store that writes synchronously is always `idle`, which is the true answer rather than a stub.',
338
+ 'Whether writes have landed: idle, pending, saving or failed. A store that writes synchronously is always idle, which is the true answer rather than a stub.',
313
339
  ),
314
340
  define(
315
341
  PERSISTENCE_MODULE,
@@ -899,10 +925,10 @@ export const PHYSICS_CAPABILITIES: readonly CapabilityDefinition[] = [
899
925
  { name: 'world', type: 'PhysicsWorld' },
900
926
  { name: 'index', type: 'i32' },
901
927
  ],
902
- 'i32',
928
+ 'ContactKind',
903
929
  ['physics.read'],
904
930
  true,
905
- "An event's kind: 0 entered, 1 still touching, 2 left.",
931
+ "An event's kind: the two bodies began touching, are still touching, or have parted.",
906
932
  ),
907
933
  define(
908
934
  PHYSICS_MODULE,
@@ -1341,7 +1367,7 @@ export function persistenceImplementation(): Record<string, unknown> {
1341
1367
  /* `instanceof` rather than a duck-typed property check: a store that happens to carry a
1342
1368
  `status` field meaning something else would otherwise be read as this one. */
1343
1369
  saveStatus: (store: KeyValueStore) =>
1344
- store instanceof RemoteSaveStore ? store.status : 'idle',
1370
+ SAVE_STATUS_OF[store instanceof RemoteSaveStore ? store.status : 'idle'],
1345
1371
  pendingSaves: (store: KeyValueStore) => (store instanceof RemoteSaveStore ? store.pending : 0),
1346
1372
  };
1347
1373
  }
@@ -1583,7 +1609,13 @@ export function physicsImplementation(): Record<string, unknown> {
1583
1609
  hitFraction: (world: PhysicsWorld) => f(hitOf(world).fraction),
1584
1610
 
1585
1611
  contactCount: (world: PhysicsWorld) => world.events.count,
1586
- contactKind: (world: PhysicsWorld, index: number) => world.events.data[index * 3] ?? 0,
1612
+ contactKind(world: PhysicsWorld, index: number) {
1613
+ /* Past `count` the buffer holds an older step's events or nothing, so neither is answered. */
1614
+ if (!(index >= 0 && index < world.events.count)) {
1615
+ throw new RangeError(`contact ${index} is outside this step's ${world.events.count}`);
1616
+ }
1617
+ return CONTACT_KIND.of[world.events.data[index * 3] as number];
1618
+ },
1587
1619
  contactA: (world: PhysicsWorld, index: number) => world.events.data[index * 3 + 1] ?? 0,
1588
1620
  contactB: (world: PhysicsWorld, index: number) => world.events.data[index * 3 + 2] ?? 0,
1589
1621
 
@@ -1,10 +1,84 @@
1
1
  import type { Gizmo, SceneNode } from '@driftengine/core';
2
2
  import type { EditorHost } from '@driftengine/editor';
3
+ import type { EditorMode, FieldKind } from '@driftengine/editor';
3
4
  import type { CapabilityDefinition, OpaqueType } from 'driftscript';
4
5
  import { defineCapability } from 'driftscript';
6
+ import { type Variant, hostEnum } from './variants.ts';
5
7
 
6
8
  export const EDITOR_MODULE = 'drift/editor';
7
9
 
10
+ /** Which tool the gizmo is, one variant per `Gizmo.mode`. */
11
+ const GIZMO_MODE = hostEnum(
12
+ EDITOR_MODULE,
13
+ 'GizmoMode',
14
+ ['Translate', 'Rotate', 'Scale'],
15
+ 'Which tool a gizmo is: it moves, turns or scales.',
16
+ );
17
+
18
+ /** Which axes the handles follow, one variant per `Gizmo.space`. */
19
+ const GIZMO_SPACE = hostEnum(
20
+ EDITOR_MODULE,
21
+ 'GizmoSpace',
22
+ ['World', 'Local'],
23
+ 'Which axes a gizmo’s handles point along: the world’s or the object’s own.',
24
+ );
25
+
26
+ /**
27
+ * The handle under the pointer, in the order of `Gizmo`'s integer constants, `None` first.
28
+ *
29
+ * A variant per handle where this used to answer a name such as `"translate.yz"`, which a script
30
+ * compared as a string: a misspelt name compared false forever and nothing said so. A handle added
31
+ * in the middle now changes this list, and every script `match` over it that misses it stops
32
+ * compiling.
33
+ */
34
+ const GIZMO_HANDLE = hostEnum(
35
+ EDITOR_MODULE,
36
+ 'GizmoHandle',
37
+ [
38
+ 'None',
39
+ 'TranslateX',
40
+ 'TranslateY',
41
+ 'TranslateZ',
42
+ 'TranslateYZ',
43
+ 'TranslateZX',
44
+ 'TranslateXY',
45
+ 'RotateX',
46
+ 'RotateY',
47
+ 'RotateZ',
48
+ 'ScaleX',
49
+ 'ScaleY',
50
+ 'ScaleZ',
51
+ 'ScaleUniform',
52
+ ],
53
+ 'Which handle the pointer is over, or `None`.',
54
+ );
55
+
56
+ /** What the editor is doing, one variant per `EditorMode`. */
57
+ const EDITOR_MODE = hostEnum(
58
+ EDITOR_MODULE,
59
+ 'EditorMode',
60
+ ['Edit', 'Play', 'Paused'],
61
+ 'Whether the editor is editing, playing the world, or paused part way through playing it.',
62
+ );
63
+
64
+ /** How the inspector shows a field, one variant per `FieldKind`. */
65
+ const FIELD_KIND = hostEnum(
66
+ EDITOR_MODULE,
67
+ 'FieldKind',
68
+ ['Number', 'Integer', 'Boolean', 'Text', 'Entity', 'Enum'],
69
+ 'How an inspector field is shown, taken from the type it was declared with.',
70
+ );
71
+
72
+ const byName = (names: readonly string[], of: readonly Variant[]): ReadonlyMap<string, Variant> =>
73
+ new Map(names.map((name, index) => [name, of[index] as Variant]));
74
+ const GIZMO_MODE_OF = byName(['translate', 'rotate', 'scale'], GIZMO_MODE.of);
75
+ const GIZMO_SPACE_OF = byName(['world', 'local'], GIZMO_SPACE.of);
76
+ const EDITOR_MODE_OF = byName(['edit', 'play', 'paused'] satisfies EditorMode[], EDITOR_MODE.of);
77
+ const FIELD_KIND_OF = byName(
78
+ ['number', 'integer', 'boolean', 'text', 'entity', 'enum'] satisfies FieldKind[],
79
+ FIELD_KIND.of,
80
+ );
81
+
8
82
  /**
9
83
  * `drift/editor` — the transform tool, driven from a script that never ships.
10
84
  *
@@ -37,13 +111,16 @@ export const EDITOR_MODULE = 'drift/editor';
37
111
  *
38
112
  * ## Three capabilities where an argument would have done, deliberately
39
113
  *
40
- * `translateMode`, `rotateMode` and `scaleMode` rather than `setMode(gizmo, mode)`. The language has
41
- * no enum, so the argument would be a string or a number, and an unrecognised one leaves exactly two
42
- * options — change nothing, the silent no-op `host.ts` rules out, or throw inside a frame, which
43
- * `AGENTS.md` rules out. Three names make the invalid call impossible to write, and the checker
44
- * rather than a runtime branch is what refuses it. `space` is the same argument again.
114
+ * `translateMode`, `rotateMode` and `scaleMode` rather than `setMode(gizmo, mode)`. When these were
115
+ * written a host could not declare an enum, so the argument would have been a string or a number,
116
+ * and an unrecognised one leaves exactly two options — change nothing, the silent no-op `host.ts`
117
+ * rules out, or throw inside a frame, which `AGENTS.md` rules out. Three names make the invalid call
118
+ * impossible to write, and the checker rather than a runtime branch is what refuses it. A
119
+ * `GizmoMode` argument would do the same now; the three stay because scripts call them. `space` is
120
+ * the same argument again.
45
121
  *
46
- * The *readers* are strings because a reader is total: there is no invalid value to answer.
122
+ * The *readers* answer variants, a `GizmoMode`, a `GizmoSpace` and a `GizmoHandle`, so a script
123
+ * that matches on one is told when a mode or a handle is added.
47
124
  */
48
125
  export const EDITOR_TYPES: readonly OpaqueType[] = [
49
126
  {
@@ -56,6 +133,11 @@ export const EDITOR_TYPES: readonly OpaqueType[] = [
56
133
  name: 'Editor',
57
134
  doc: 'An editing session: what is selected, the tree of what exists, and whether the world is playing.',
58
135
  },
136
+ GIZMO_MODE.type,
137
+ GIZMO_SPACE.type,
138
+ GIZMO_HANDLE.type,
139
+ EDITOR_MODE.type,
140
+ FIELD_KIND.type,
59
141
  ];
60
142
 
61
143
  const define = (
@@ -103,13 +185,18 @@ export const EDITOR_CAPABILITIES: readonly CapabilityDefinition[] = [
103
185
  define(
104
186
  'gizmoMode',
105
187
  OF,
106
- 'String',
107
- 'Which of the three the gizmo is: "translate", "rotate" or "scale".',
188
+ 'GizmoMode',
189
+ 'Which of the three the gizmo is: translate, rotate or scale.',
108
190
  ),
109
191
 
110
192
  define('worldSpace', OF, 'void', 'Point the handles along the world axes.'),
111
193
  define('localSpace', OF, 'void', 'Point the handles along the object’s own axes.'),
112
- define('space', OF, 'String', 'Which the handles are pointing along: "world" or "local".'),
194
+ define(
195
+ 'space',
196
+ OF,
197
+ 'GizmoSpace',
198
+ 'Which the handles are pointing along: the world or the object.',
199
+ ),
113
200
 
114
201
  define(
115
202
  'size',
@@ -125,15 +212,16 @@ export const EDITOR_CAPABILITIES: readonly CapabilityDefinition[] = [
125
212
  ),
126
213
 
127
214
  /*
128
- * **A name rather than a number, and total.** The handles are integer constants in TypeScript, and
129
- * a script comparing against a magic number is a script that breaks silently the day a handle is
130
- * added in the middle. `"none"` is a real answer rather than a missing one.
215
+ * **A variant, and total.** The handles are integer constants in TypeScript, and a script comparing
216
+ * against a magic number breaks silently the day a handle is added in the middle; a script
217
+ * comparing against a name breaks silently the day it misspells one. `None` is a real answer
218
+ * rather than a missing one.
131
219
  */
132
220
  define(
133
221
  'hovered',
134
222
  OF,
135
- 'String',
136
- 'What the pointer is over, as a name: "none", or one of "translate.x", "translate.yz", "rotate.y", "scale.uniform" and their siblings.',
223
+ 'GizmoHandle',
224
+ 'What the pointer is over: `None`, or a handle such as `TranslateX`, `TranslateYZ`, `RotateY` or `ScaleUniform`.',
137
225
  ),
138
226
  define('dragging', OF, 'bool', 'Whether a drag is running.'),
139
227
  define(
@@ -172,7 +260,7 @@ export const EDITOR_CAPABILITIES: readonly CapabilityDefinition[] = [
172
260
  * **The session, from `@driftengine/editor`.** Everything above drives one tool; everything below
173
261
  * drives the editor around it — which is what an `@editor` script is actually for.
174
262
  */
175
- define('mode', ON, 'String', 'What the editor is doing: "edit", "play" or "paused".'),
263
+ define('mode', ON, 'EditorMode', 'What the editor is doing: editing, playing or paused.'),
176
264
  define(
177
265
  'playing',
178
266
  ON,
@@ -254,8 +342,8 @@ export const EDITOR_CAPABILITIES: readonly CapabilityDefinition[] = [
254
342
  define(
255
343
  'fieldKind',
256
344
  [...ON, { name: 'field', type: 'i32' }],
257
- 'String',
258
- 'How to show a field: "number", "integer", "boolean", "text", "entity" or "enum". Taken from the declared type, because a read cannot tell them apart.',
345
+ 'FieldKind',
346
+ 'How to show a field: a number, an integer, a boolean, text, an entity or an enum. Taken from the declared type, because a read cannot tell them apart. A field outside `fieldCount` is refused, naming both.',
259
347
  ),
260
348
  define(
261
349
  'fieldNumber',
@@ -271,28 +359,6 @@ export const EDITOR_CAPABILITIES: readonly CapabilityDefinition[] = [
271
359
  ),
272
360
  ];
273
361
 
274
- /**
275
- * Handle constants as the names a script reads, in the order the constants run.
276
- *
277
- * A table rather than a `switch`, so adding a handle is one line here and the reader stays total.
278
- */
279
- const HANDLE_NAMES: readonly string[] = [
280
- 'none',
281
- 'translate.x',
282
- 'translate.y',
283
- 'translate.z',
284
- 'translate.yz',
285
- 'translate.zx',
286
- 'translate.xy',
287
- 'rotate.x',
288
- 'rotate.y',
289
- 'rotate.z',
290
- 'scale.x',
291
- 'scale.y',
292
- 'scale.z',
293
- 'scale.uniform',
294
- ];
295
-
296
362
  /**
297
363
  * **No services, and that is the shape rather than an omission.**
298
364
  *
@@ -312,7 +378,7 @@ export function editorImplementation(): Record<string, unknown> {
312
378
  scaleMode: (gizmo: Gizmo) => {
313
379
  gizmo.mode = 'scale';
314
380
  },
315
- gizmoMode: (gizmo: Gizmo) => gizmo.mode,
381
+ gizmoMode: (gizmo: Gizmo) => GIZMO_MODE_OF.get(gizmo.mode),
316
382
 
317
383
  worldSpace: (gizmo: Gizmo) => {
318
384
  gizmo.space = 'world';
@@ -320,14 +386,14 @@ export function editorImplementation(): Record<string, unknown> {
320
386
  localSpace: (gizmo: Gizmo) => {
321
387
  gizmo.space = 'local';
322
388
  },
323
- space: (gizmo: Gizmo) => gizmo.space,
389
+ space: (gizmo: Gizmo) => GIZMO_SPACE_OF.get(gizmo.space),
324
390
 
325
391
  size: (gizmo: Gizmo) => gizmo.size,
326
392
  setSize: (gizmo: Gizmo, metres: number) => {
327
393
  gizmo.size = metres;
328
394
  },
329
395
 
330
- hovered: (gizmo: Gizmo) => HANDLE_NAMES[gizmo.hovered] ?? 'none',
396
+ hovered: (gizmo: Gizmo) => GIZMO_HANDLE.of[gizmo.hovered] ?? GIZMO_HANDLE.of[0],
331
397
  dragging: (gizmo: Gizmo) => gizmo.dragging,
332
398
  dragAngle: (gizmo: Gizmo) => gizmo.dragAngle,
333
399
 
@@ -349,7 +415,7 @@ export function editorImplementation(): Record<string, unknown> {
349
415
  rotationZ: (gizmo: Gizmo) => gizmo.rotation[2] ?? 0,
350
416
  rotationW: (gizmo: Gizmo) => gizmo.rotation[3] ?? 1,
351
417
 
352
- mode: (editor: EditorHost) => editor.mode,
418
+ mode: (editor: EditorHost) => EDITOR_MODE_OF.get(editor.mode),
353
419
  playing: (editor: EditorHost) => editor.mode === 'play',
354
420
  play: (editor: EditorHost) => editor.play(),
355
421
  pause: (editor: EditorHost) => {
@@ -382,7 +448,15 @@ export function editorImplementation(): Record<string, unknown> {
382
448
  fieldCount: (editor: EditorHost) => editor.inspector.fields.length,
383
449
  fieldLabel: (editor: EditorHost, field: number) => editor.inspector.fields[field]?.label ?? '',
384
450
  fieldGroup: (editor: EditorHost, field: number) => editor.inspector.fields[field]?.group ?? '',
385
- fieldKind: (editor: EditorHost, field: number) => editor.inspector.fields[field]?.kind ?? '',
451
+ fieldKind(editor: EditorHost, field: number) {
452
+ const found = editor.inspector.fields[field];
453
+ if (found === undefined) {
454
+ throw new RangeError(
455
+ `field ${field} is outside the ${editor.inspector.fields.length} shown`,
456
+ );
457
+ }
458
+ return FIELD_KIND_OF.get(found.kind);
459
+ },
386
460
  fieldNumber: (editor: EditorHost, field: number) => {
387
461
  const value = editor.inspector.fields[field]?.value;
388
462
  return typeof value === 'number' ? value : 0;
@@ -399,10 +399,17 @@ export function entitiesImplementation(services: EntityServices): Record<string,
399
399
  attach: (w: World, entity: Entity, component: string) => w.add(entity, resolve(component)),
400
400
  detach: (w: World, entity: Entity, component: string) => w.remove(entity, resolve(component)),
401
401
  read: (w: World, entity: Entity, component: string, field: string) => {
402
- const value = w.read(entity, resolve(component), field);
403
- /* An entity without the component reads `undefined`, and a script's `f64` may not hold one:
404
- the language has no null, so the honest zero is what a missing value is. */
405
- return typeof value === 'number' ? value : 0;
402
+ const type = resolve(component);
403
+ const value = w.read(entity, type, field);
404
+ if (value !== undefined) return value;
405
+ /*
406
+ * Nothing to read: an absent optional field, or an entity without the component. An optional
407
+ * field answers `undefined`, which the generated code turns into `none`; the compiler wraps
408
+ * every read of one. Any other field answers zero, since a script's `f64` cannot hold
409
+ * nothing. Answering zero for an optional field too was what made it unreadable: `none` and
410
+ * a stored zero were the same answer.
411
+ */
412
+ return optionalField(type, field) ? undefined : 0;
406
413
  },
407
414
  write: (w: World, entity: Entity, component: string, field: string, value: number) =>
408
415
  w.write(entity, resolve(component), field, value),
@@ -551,3 +558,8 @@ interface QuerySource {
551
558
  query(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): QueryCursor;
552
559
  view(type: ComponentType, forWriting: boolean): ComponentView;
553
560
  }
561
+
562
+ /** Whether a component declared this field as an option, `option:<inner>` in its schema. */
563
+ function optionalField(type: ComponentType, field: string): boolean {
564
+ return type.schema.fields.some((f) => f.name === field && f.type.startsWith('option:'));
565
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A host enum's variants, as the values a script holds and hands back.
3
+ *
4
+ * An engine answer that is one of a few things is an enum on the script's side: DriftScript lets a
5
+ * host declare one (`OpaqueType.variants`), a script names its variants and a `match` over it has
6
+ * to cover them all. The engine keeps its own numbering, and this is the one place a number becomes
7
+ * a variant and back. Before it, each such capability answered with the number and a sentence
8
+ * saying what each number meant, and every script restated that sentence as constants.
9
+ *
10
+ * One frozen object per variant, made once, so answering allocates nothing. A script compares a
11
+ * variant by its tag, so a script's own copy of `{ tag: 'Solid' }` equals this one.
12
+ */
13
+ import type { OpaqueType } from 'driftscript';
14
+
15
+ export interface Variant {
16
+ readonly tag: string;
17
+ }
18
+
19
+ export interface HostEnum {
20
+ /** The type to register, so a script can name it. */
21
+ readonly type: OpaqueType;
22
+ /** The variant for an engine code, in the order the engine numbers them. */
23
+ readonly of: readonly Variant[];
24
+ /** The engine's code for a variant a script handed back, or -1 for anything else. */
25
+ code(variant: Variant): number;
26
+ }
27
+
28
+ export function hostEnum(
29
+ module: string,
30
+ name: string,
31
+ variants: readonly string[],
32
+ doc: string,
33
+ ): HostEnum {
34
+ const of = variants.map((tag) => Object.freeze({ tag }));
35
+ const codes = new Map(variants.map((tag, index) => [tag, index]));
36
+ return {
37
+ type: { module, name, doc, variants },
38
+ of,
39
+ code: (variant) => codes.get(variant.tag) ?? -1,
40
+ };
41
+ }