@driftengine/script 4.7.3 → 4.8.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.
@@ -40,6 +40,10 @@ import { AtmosphereField, ChemistryWorld, ParcelStore, SpeciesRegistry, Substanc
40
40
  import { type ComponentType } from '@driftengine/entities';
41
41
  import { type CapabilityDefinition, type OpaqueType } from 'driftscript';
42
42
  export declare const CHEMISTRY_MODULE = "drift/chemistry";
43
+ /** A parcel's phase, in the package's order: `PHASE_SOLID`, `_LIQUID`, `_GAS`, then `PHASE_MIXED`. */
44
+ export declare const MATTER_PHASE: import("./variants.ts").HostEnum;
45
+ /** What happened to a parcel this tick, in the order of the package's `EVENT_*` constants. */
46
+ export declare const CHEMISTRY_EVENT: import("./variants.ts").HostEnum;
43
47
  /**
44
48
  * The three opaque types `§20.2` names.
45
49
  *
@@ -39,6 +39,7 @@
39
39
  import { AtmosphereField, ChemistryWorld, ParcelStore, SpeciesRegistry, SubstanceRegistry, } from '@driftengine/chemistry';
40
40
  import { defineComponent } from '@driftengine/entities';
41
41
  import { defineCapability } from 'driftscript';
42
+ import { hostEnum } from './variants.js';
42
43
  export const CHEMISTRY_MODULE = 'drift/chemistry';
43
44
  const define = (name, params, returns, effects, doc) => defineCapability({
44
45
  module: CHEMISTRY_MODULE,
@@ -52,6 +53,32 @@ const define = (name, params, returns, effects, doc) => defineCapability({
52
53
  doc,
53
54
  implementation: `${CHEMISTRY_MODULE}.${name}`,
54
55
  });
56
+ /** A parcel's phase, in the package's order: `PHASE_SOLID`, `_LIQUID`, `_GAS`, then `PHASE_MIXED`. */
57
+ export const MATTER_PHASE = hostEnum(CHEMISTRY_MODULE, 'MatterPhase', ['Solid', 'Liquid', 'Gas', 'Mixed'], 'What a parcel is made of now. Mixed is a real answer: wet wood is a solid and a liquid at once.');
58
+ /** What happened to a parcel this tick, in the order of the package's `EVENT_*` constants. */
59
+ export const CHEMISTRY_EVENT = hostEnum(CHEMISTRY_MODULE, 'ChemistryEvent', [
60
+ 'Ignited',
61
+ 'Extinguished',
62
+ 'SmoulderStart',
63
+ 'SmoulderEnd',
64
+ 'Consumed',
65
+ 'Charred',
66
+ 'StructuralFail',
67
+ 'Frozen',
68
+ 'Melted',
69
+ 'Boiled',
70
+ 'Condensed',
71
+ 'Sublimed',
72
+ 'Corroded',
73
+ 'Calcined',
74
+ 'Dissolved',
75
+ 'Denatured',
76
+ 'Browned',
77
+ 'Caramelised',
78
+ 'Fermented',
79
+ 'Decayed',
80
+ 'Exploded',
81
+ ], 'What happened to a parcel this tick: it caught, went out, melted, boiled and the rest.');
55
82
  /**
56
83
  * The three opaque types `§20.2` names.
57
84
  *
@@ -75,6 +102,8 @@ export const CHEMISTRY_TYPES = [
75
102
  name: 'Species',
76
103
  doc: 'A registered chemical species, such as `O2` or `cellulose`.',
77
104
  },
105
+ MATTER_PHASE.type,
106
+ CHEMISTRY_EVENT.type,
78
107
  ];
79
108
  const READ = ['chemistry.read'];
80
109
  const WRITE = ['chemistry.write'];
@@ -104,7 +133,7 @@ export const CHEMISTRY_CAPABILITIES = [
104
133
  define('charDepth', [P, PARCEL], 'f32', READ, 'Metres of char measured inward from the surface.'),
105
134
  define('wetness', [P, PARCEL], 'f32', READ, 'kg of liquid water on the surface, per square metre of it.'),
106
135
  define('wettable', [P, PARCEL], 'bool', READ, 'Whether this material can hold liquid water, which is what `wet` asks. Rain over everything asks first, since `wet` refuses an iron nail.'),
107
- define('phase', [P, PARCEL], 'i32', READ, '0 solid, 1 liquid, 2 gas, 3 mixed. Mixed is a real answer: wet wood is a solid and a liquid at once.'),
136
+ define('phase', [P, PARCEL], 'MatterPhase', READ, 'Whether the parcel is solid, liquid, gas or mixed. Mixed is a real answer: wet wood is a solid and a liquid at once.'),
108
137
  define('burning', [P, PARCEL], 'bool', READ, 'Whether a flame stands over it.'),
109
138
  define('smouldering', [P, PARCEL], 'bool', READ, 'Whether it is glowing with no flame. A smoulder survives air a flame cannot, which is why smothering leaves embers.'),
110
139
  define('heatRelease', [P, PARCEL], 'f32', READ, "kW the reactions released last tick. A fire's size is its heat release rate, not its temperature. Negative while a surface is gasifying, which is correct."),
@@ -153,7 +182,7 @@ export const CHEMISTRY_CAPABILITIES = [
153
182
  define('addAirHeat', [P, { name: 'joules', type: 'f32' }, ...XYZ], 'void', WRITE, 'Joules into a cell of air, which is what a heater does and what a fire does to the room.'),
154
183
  /* The event buffer, §20.5, in the shape `drift/physics`'s contact buffer established. */
155
184
  define('eventCount', [P], 'i32', READ, 'Events this tick. Cleared at the top of every one.'),
156
- define('eventKind', [P, { name: 'index', type: 'i32' }], 'i32', READ, '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.'),
185
+ define('eventKind', [P, { name: 'index', type: 'i32' }], 'ChemistryEvent', READ, 'What happened in one of this tick’s events. An index outside `eventCount` is refused, naming both.'),
157
186
  define('eventParcel', [P, { name: 'index', type: 'i32' }], 'i32', READ, 'Which parcel it happened to.'),
158
187
  define('eventSpecies', [P, { name: 'index', type: 'i32' }], 'i32', READ, 'Which species, or -1 where the kind does not name one.'),
159
188
  define('eventValue', [P, { name: 'index', type: 'i32' }], 'f32', READ, "The kind's own quantity."),
@@ -213,7 +242,7 @@ export function chemistryImplementation(services) {
213
242
  charDepth: (_, parcel) => parcels.charDepthOf(parcel),
214
243
  wetness: (_, parcel) => parcels.wetnessOf(parcel),
215
244
  wettable: (_, parcel) => parcels.wettable(parcel),
216
- phase: (_, parcel) => parcels.parcelPhaseOf(parcel),
245
+ phase: (_, parcel) => MATTER_PHASE.of[parcels.parcelPhaseOf(parcel)],
217
246
  burning: (_, parcel) => parcels.burning(parcel),
218
247
  smouldering: (_, parcel) => parcels.smouldering(parcel),
219
248
  /* Watts inside, kilowatts out: a fire is quoted in kW everywhere a person reads one. */
@@ -247,7 +276,14 @@ export function chemistryImplementation(services) {
247
276
  release: (_, s, kilograms, x, y, z) => air.addSpecies(x, y, z, s, kilograms),
248
277
  addAirHeat: (_, joules, x, y, z) => air.addHeat(x, y, z, joules),
249
278
  eventCount: () => world.events.count,
250
- eventKind: (_, index) => world.events.kindOf(index),
279
+ eventKind(_, index) {
280
+ /* The buffer is read unchecked, and past `count` it holds the last tick's events or nothing:
281
+ a kind that did not happen this tick, or no variant at all. */
282
+ if (!(index >= 0 && index < world.events.count)) {
283
+ throw new RangeError(`event ${index} is outside this tick's ${world.events.count}`);
284
+ }
285
+ return CHEMISTRY_EVENT.of[world.events.kindOf(index)];
286
+ },
251
287
  eventParcel: (_, index) => world.events.parcelOf(index),
252
288
  eventSpecies: (_, index) => world.events.speciesOf(index),
253
289
  eventValue: (_, index) => world.events.valueOf(index),
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { ActionMap, CinematicCamera, ColliderSet, RemoteSaveStore, MessageQueue, PhysicsWorld, SceneNode, angleDelta, boxCollider, createRayHit, hashToUnit, mulberry32, wrapAngle, } from '@driftengine/core';
22
22
  import { defineCapability } from 'driftscript';
23
+ import { hostEnum } from './variants.js';
23
24
  export const TIME_MODULE = 'drift/time';
24
25
  export const RANDOM_MODULE = 'drift/random';
25
26
  export const EVENTS_MODULE = 'drift/events';
@@ -39,6 +40,16 @@ const define = (module, name, params, returns, effects, deterministic, doc) => d
39
40
  doc,
40
41
  implementation: `${module}.${name}`,
41
42
  });
43
+ /** A contact event's kind, in `@driftengine/physics`'s order: `EVENT_ENTER`, `_STAY`, `_EXIT`. */
44
+ const CONTACT_KIND = hostEnum('drift/physics', 'ContactKind', ['Enter', 'Stay', 'Exit'], 'Whether two bodies began touching this step, are still touching, or have parted.');
45
+ /** A remote store's `SaveStatus`, one variant per string the store answers with. */
46
+ const SAVE_STATUS = hostEnum('drift/persistence', 'SaveStatus', ['Idle', 'Pending', 'Saving', 'Failed'], 'Whether a store’s writes have landed: idle, waiting to send, sending, or failed.');
47
+ const SAVE_STATUS_OF = {
48
+ idle: SAVE_STATUS.of[0],
49
+ pending: SAVE_STATUS.of[1],
50
+ saving: SAVE_STATUS.of[2],
51
+ failed: SAVE_STATUS.of[3],
52
+ };
42
53
  /**
43
54
  * Handles a script holds and passes back, never reads into.
44
55
  *
@@ -86,6 +97,8 @@ export const CORE_TYPES = [
86
97
  doc: 'A priority message queue with dedupe and a ceiling.',
87
98
  },
88
99
  { module: PERSISTENCE_MODULE, name: 'Store', doc: 'A key-value store the consumer supplied.' },
100
+ CONTACT_KIND.type,
101
+ SAVE_STATUS.type,
89
102
  ];
90
103
  /**
91
104
  * `drift/time` — the loop's three clocks, and the one that is deterministic is not here.
@@ -161,7 +174,7 @@ export const PERSISTENCE_CAPABILITIES = [
161
174
  * a script cannot hold. A consumer flushes from TypeScript, at the one place it knows it is
162
175
  * leaving.
163
176
  */
164
- define(PERSISTENCE_MODULE, 'saveStatus', [{ name: 'store', type: 'Store' }], 'String', ['persistence.read'], false, '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.'),
177
+ define(PERSISTENCE_MODULE, 'saveStatus', [{ name: 'store', type: 'Store' }], 'SaveStatus', ['persistence.read'], false, '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.'),
165
178
  define(PERSISTENCE_MODULE, 'pendingSaves', [{ name: 'store', type: 'Store' }], 'u32', ['persistence.read'], false, 'How many keys are waiting to reach the backend. Zero for a store that writes synchronously.'),
166
179
  ];
167
180
  /**
@@ -381,7 +394,7 @@ export const PHYSICS_CAPABILITIES = [
381
394
  define(PHYSICS_MODULE, 'contactKind', [
382
395
  { name: 'world', type: 'PhysicsWorld' },
383
396
  { name: 'index', type: 'i32' },
384
- ], 'i32', ['physics.read'], true, "An event's kind: 0 entered, 1 still touching, 2 left."),
397
+ ], 'ContactKind', ['physics.read'], true, "An event's kind: the two bodies began touching, are still touching, or have parted."),
385
398
  define(PHYSICS_MODULE, 'contactA', [
386
399
  { name: 'world', type: 'PhysicsWorld' },
387
400
  { name: 'index', type: 'i32' },
@@ -599,7 +612,7 @@ export function persistenceImplementation() {
599
612
  },
600
613
  /* `instanceof` rather than a duck-typed property check: a store that happens to carry a
601
614
  `status` field meaning something else would otherwise be read as this one. */
602
- saveStatus: (store) => store instanceof RemoteSaveStore ? store.status : 'idle',
615
+ saveStatus: (store) => SAVE_STATUS_OF[store instanceof RemoteSaveStore ? store.status : 'idle'],
603
616
  pendingSaves: (store) => (store instanceof RemoteSaveStore ? store.pending : 0),
604
617
  };
605
618
  }
@@ -778,7 +791,13 @@ export function physicsImplementation() {
778
791
  hitNormalZ: (world) => f(hitOf(world).nz),
779
792
  hitFraction: (world) => f(hitOf(world).fraction),
780
793
  contactCount: (world) => world.events.count,
781
- contactKind: (world, index) => world.events.data[index * 3] ?? 0,
794
+ contactKind(world, index) {
795
+ /* Past `count` the buffer holds an older step's events or nothing, so neither is answered. */
796
+ if (!(index >= 0 && index < world.events.count)) {
797
+ throw new RangeError(`contact ${index} is outside this step's ${world.events.count}`);
798
+ }
799
+ return CONTACT_KIND.of[world.events.data[index * 3]];
800
+ },
782
801
  contactA: (world, index) => world.events.data[index * 3 + 1] ?? 0,
783
802
  contactB: (world, index) => world.events.data[index * 3 + 2] ?? 0,
784
803
  applyImpulse: (world, body, px, py, pz, atX, atY, atZ) => world.applyImpulse(body, px, py, pz, atX, atY, atZ),
@@ -32,13 +32,16 @@ export declare const EDITOR_MODULE = "drift/editor";
32
32
  *
33
33
  * ## Three capabilities where an argument would have done, deliberately
34
34
  *
35
- * `translateMode`, `rotateMode` and `scaleMode` rather than `setMode(gizmo, mode)`. The language has
36
- * no enum, so the argument would be a string or a number, and an unrecognised one leaves exactly two
37
- * options — change nothing, the silent no-op `host.ts` rules out, or throw inside a frame, which
38
- * `AGENTS.md` rules out. Three names make the invalid call impossible to write, and the checker
39
- * rather than a runtime branch is what refuses it. `space` is the same argument again.
40
- *
41
- * The *readers* are strings because a reader is total: there is no invalid value to answer.
35
+ * `translateMode`, `rotateMode` and `scaleMode` rather than `setMode(gizmo, mode)`. When these were
36
+ * written a host could not declare an enum, so the argument would have been a string or a number,
37
+ * and an unrecognised one leaves exactly two options — change nothing, the silent no-op `host.ts`
38
+ * rules out, or throw inside a frame, which `AGENTS.md` rules out. Three names make the invalid call
39
+ * impossible to write, and the checker rather than a runtime branch is what refuses it. A
40
+ * `GizmoMode` argument would do the same now; the three stay because scripts call them. `space` is
41
+ * the same argument again.
42
+ *
43
+ * The *readers* answer variants, a `GizmoMode`, a `GizmoSpace` and a `GizmoHandle`, so a script
44
+ * that matches on one is told when a mode or a handle is added.
42
45
  */
43
46
  export declare const EDITOR_TYPES: readonly OpaqueType[];
44
47
  export declare const EDITOR_CAPABILITIES: readonly CapabilityDefinition[];
@@ -1,5 +1,43 @@
1
1
  import { defineCapability } from 'driftscript';
2
+ import { hostEnum } from './variants.js';
2
3
  export const EDITOR_MODULE = 'drift/editor';
4
+ /** Which tool the gizmo is, one variant per `Gizmo.mode`. */
5
+ const GIZMO_MODE = hostEnum(EDITOR_MODULE, 'GizmoMode', ['Translate', 'Rotate', 'Scale'], 'Which tool a gizmo is: it moves, turns or scales.');
6
+ /** Which axes the handles follow, one variant per `Gizmo.space`. */
7
+ const GIZMO_SPACE = hostEnum(EDITOR_MODULE, 'GizmoSpace', ['World', 'Local'], 'Which axes a gizmo’s handles point along: the world’s or the object’s own.');
8
+ /**
9
+ * The handle under the pointer, in the order of `Gizmo`'s integer constants, `None` first.
10
+ *
11
+ * A variant per handle where this used to answer a name such as `"translate.yz"`, which a script
12
+ * compared as a string: a misspelt name compared false forever and nothing said so. A handle added
13
+ * in the middle now changes this list, and every script `match` over it that misses it stops
14
+ * compiling.
15
+ */
16
+ const GIZMO_HANDLE = hostEnum(EDITOR_MODULE, 'GizmoHandle', [
17
+ 'None',
18
+ 'TranslateX',
19
+ 'TranslateY',
20
+ 'TranslateZ',
21
+ 'TranslateYZ',
22
+ 'TranslateZX',
23
+ 'TranslateXY',
24
+ 'RotateX',
25
+ 'RotateY',
26
+ 'RotateZ',
27
+ 'ScaleX',
28
+ 'ScaleY',
29
+ 'ScaleZ',
30
+ 'ScaleUniform',
31
+ ], 'Which handle the pointer is over, or `None`.');
32
+ /** What the editor is doing, one variant per `EditorMode`. */
33
+ const EDITOR_MODE = hostEnum(EDITOR_MODULE, 'EditorMode', ['Edit', 'Play', 'Paused'], 'Whether the editor is editing, playing the world, or paused part way through playing it.');
34
+ /** How the inspector shows a field, one variant per `FieldKind`. */
35
+ const FIELD_KIND = hostEnum(EDITOR_MODULE, 'FieldKind', ['Number', 'Integer', 'Boolean', 'Text', 'Entity', 'Enum'], 'How an inspector field is shown, taken from the type it was declared with.');
36
+ const byName = (names, of) => new Map(names.map((name, index) => [name, of[index]]));
37
+ const GIZMO_MODE_OF = byName(['translate', 'rotate', 'scale'], GIZMO_MODE.of);
38
+ const GIZMO_SPACE_OF = byName(['world', 'local'], GIZMO_SPACE.of);
39
+ const EDITOR_MODE_OF = byName(['edit', 'play', 'paused'], EDITOR_MODE.of);
40
+ const FIELD_KIND_OF = byName(['number', 'integer', 'boolean', 'text', 'entity', 'enum'], FIELD_KIND.of);
3
41
  /**
4
42
  * `drift/editor` — the transform tool, driven from a script that never ships.
5
43
  *
@@ -32,13 +70,16 @@ export const EDITOR_MODULE = 'drift/editor';
32
70
  *
33
71
  * ## Three capabilities where an argument would have done, deliberately
34
72
  *
35
- * `translateMode`, `rotateMode` and `scaleMode` rather than `setMode(gizmo, mode)`. The language has
36
- * no enum, so the argument would be a string or a number, and an unrecognised one leaves exactly two
37
- * options — change nothing, the silent no-op `host.ts` rules out, or throw inside a frame, which
38
- * `AGENTS.md` rules out. Three names make the invalid call impossible to write, and the checker
39
- * rather than a runtime branch is what refuses it. `space` is the same argument again.
73
+ * `translateMode`, `rotateMode` and `scaleMode` rather than `setMode(gizmo, mode)`. When these were
74
+ * written a host could not declare an enum, so the argument would have been a string or a number,
75
+ * and an unrecognised one leaves exactly two options — change nothing, the silent no-op `host.ts`
76
+ * rules out, or throw inside a frame, which `AGENTS.md` rules out. Three names make the invalid call
77
+ * impossible to write, and the checker rather than a runtime branch is what refuses it. A
78
+ * `GizmoMode` argument would do the same now; the three stay because scripts call them. `space` is
79
+ * the same argument again.
40
80
  *
41
- * The *readers* are strings because a reader is total: there is no invalid value to answer.
81
+ * The *readers* answer variants, a `GizmoMode`, a `GizmoSpace` and a `GizmoHandle`, so a script
82
+ * that matches on one is told when a mode or a handle is added.
42
83
  */
43
84
  export const EDITOR_TYPES = [
44
85
  {
@@ -51,6 +92,11 @@ export const EDITOR_TYPES = [
51
92
  name: 'Editor',
52
93
  doc: 'An editing session: what is selected, the tree of what exists, and whether the world is playing.',
53
94
  },
95
+ GIZMO_MODE.type,
96
+ GIZMO_SPACE.type,
97
+ GIZMO_HANDLE.type,
98
+ EDITOR_MODE.type,
99
+ FIELD_KIND.type,
54
100
  ];
55
101
  const define = (name, params, returns, doc) => defineCapability({
56
102
  module: EDITOR_MODULE,
@@ -81,18 +127,19 @@ export const EDITOR_CAPABILITIES = [
81
127
  define('translateMode', OF, 'void', 'Make this an arrow gizmo, which moves what it is on.'),
82
128
  define('rotateMode', OF, 'void', 'Make this a ring gizmo, which turns what it is on.'),
83
129
  define('scaleMode', OF, 'void', 'Make this a scale gizmo. Scale is always in the object’s own axes, whatever the space says, because a non-uniform scale along a world axis would shear it.'),
84
- define('gizmoMode', OF, 'String', 'Which of the three the gizmo is: "translate", "rotate" or "scale".'),
130
+ define('gizmoMode', OF, 'GizmoMode', 'Which of the three the gizmo is: translate, rotate or scale.'),
85
131
  define('worldSpace', OF, 'void', 'Point the handles along the world axes.'),
86
132
  define('localSpace', OF, 'void', 'Point the handles along the object’s own axes.'),
87
- define('space', OF, 'String', 'Which the handles are pointing along: "world" or "local".'),
133
+ define('space', OF, 'GizmoSpace', 'Which the handles are pointing along: the world or the object.'),
88
134
  define('size', OF, 'f32', 'How big the gizmo is in metres. The host keeps this the same on screen as the camera moves.'),
89
135
  define('setSize', [...OF, { name: 'metres', type: 'f32' }], 'void', 'Set how big the gizmo is in metres. Non-positive draws nothing and picks nothing rather than failing.'),
90
136
  /*
91
- * **A name rather than a number, and total.** The handles are integer constants in TypeScript, and
92
- * a script comparing against a magic number is a script that breaks silently the day a handle is
93
- * added in the middle. `"none"` is a real answer rather than a missing one.
137
+ * **A variant, and total.** The handles are integer constants in TypeScript, and a script comparing
138
+ * against a magic number breaks silently the day a handle is added in the middle; a script
139
+ * comparing against a name breaks silently the day it misspells one. `None` is a real answer
140
+ * rather than a missing one.
94
141
  */
95
- define('hovered', OF, 'String', 'What the pointer is over, as a name: "none", or one of "translate.x", "translate.yz", "rotate.y", "scale.uniform" and their siblings.'),
142
+ define('hovered', OF, 'GizmoHandle', 'What the pointer is over: `None`, or a handle such as `TranslateX`, `TranslateYZ`, `RotateY` or `ScaleUniform`.'),
96
143
  define('dragging', OF, 'bool', 'Whether a drag is running.'),
97
144
  define('dragAngle', OF, 'f32', 'How far a rotation drag has turned, in radians, signed and past a full turn if it went that far. Zero when the drag is not a rotation. This is what to snap or clamp; the rotation itself cannot tell a three-quarter turn from a quarter turn back.'),
98
145
  define('positionX', OF, 'f32', 'Where the thing being edited is, along x.'),
@@ -116,7 +163,7 @@ export const EDITOR_CAPABILITIES = [
116
163
  * **The session, from `@driftengine/editor`.** Everything above drives one tool; everything below
117
164
  * drives the editor around it — which is what an `@editor` script is actually for.
118
165
  */
119
- define('mode', ON, 'String', 'What the editor is doing: "edit", "play" or "paused".'),
166
+ define('mode', ON, 'EditorMode', 'What the editor is doing: editing, playing or paused.'),
120
167
  define('playing', ON, 'bool', 'Whether the world is advancing. False while paused and while editing.'),
121
168
  define('play', ON, 'bool', 'Start playing, taking a snapshot of the world first so `stop` can put it back. Resuming from paused does not re-snapshot. False when no world is bound to snapshot.'),
122
169
  define('pause', ON, 'void', 'Hold the world where it is. The frame keeps drawing.'),
@@ -133,31 +180,10 @@ export const EDITOR_CAPABILITIES = [
133
180
  define('fieldCount', ON, 'i32', 'How many fields the inspector is showing.'),
134
181
  define('fieldLabel', [...ON, { name: 'field', type: 'i32' }], 'String', 'A field\u2019s name as it was declared, or "" for a field that does not exist.'),
135
182
  define('fieldGroup', [...ON, { name: 'field', type: 'i32' }], 'String', 'What a field belongs to: a component\u2019s name, or "transform" for a node.'),
136
- define('fieldKind', [...ON, { name: 'field', type: 'i32' }], 'String', 'How to show a field: "number", "integer", "boolean", "text", "entity" or "enum". Taken from the declared type, because a read cannot tell them apart.'),
183
+ define('fieldKind', [...ON, { name: 'field', type: 'i32' }], 'FieldKind', '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.'),
137
184
  define('fieldNumber', [...ON, { name: 'field', type: 'i32' }], 'f32', 'A field\u2019s value as a number. Zero for a field that is not one, and for one that does not exist.'),
138
185
  define('setFieldNumber', [...ON, { name: 'field', type: 'i32' }, { name: 'value', type: 'f32' }], 'bool', 'Write a number into a field. False where the field does not exist or will not take one.'),
139
186
  ];
140
- /**
141
- * Handle constants as the names a script reads, in the order the constants run.
142
- *
143
- * A table rather than a `switch`, so adding a handle is one line here and the reader stays total.
144
- */
145
- const HANDLE_NAMES = [
146
- 'none',
147
- 'translate.x',
148
- 'translate.y',
149
- 'translate.z',
150
- 'translate.yz',
151
- 'translate.zx',
152
- 'translate.xy',
153
- 'rotate.x',
154
- 'rotate.y',
155
- 'rotate.z',
156
- 'scale.x',
157
- 'scale.y',
158
- 'scale.z',
159
- 'scale.uniform',
160
- ];
161
187
  /**
162
188
  * **No services, and that is the shape rather than an omission.**
163
189
  *
@@ -177,19 +203,19 @@ export function editorImplementation() {
177
203
  scaleMode: (gizmo) => {
178
204
  gizmo.mode = 'scale';
179
205
  },
180
- gizmoMode: (gizmo) => gizmo.mode,
206
+ gizmoMode: (gizmo) => GIZMO_MODE_OF.get(gizmo.mode),
181
207
  worldSpace: (gizmo) => {
182
208
  gizmo.space = 'world';
183
209
  },
184
210
  localSpace: (gizmo) => {
185
211
  gizmo.space = 'local';
186
212
  },
187
- space: (gizmo) => gizmo.space,
213
+ space: (gizmo) => GIZMO_SPACE_OF.get(gizmo.space),
188
214
  size: (gizmo) => gizmo.size,
189
215
  setSize: (gizmo, metres) => {
190
216
  gizmo.size = metres;
191
217
  },
192
- hovered: (gizmo) => HANDLE_NAMES[gizmo.hovered] ?? 'none',
218
+ hovered: (gizmo) => GIZMO_HANDLE.of[gizmo.hovered] ?? GIZMO_HANDLE.of[0],
193
219
  dragging: (gizmo) => gizmo.dragging,
194
220
  dragAngle: (gizmo) => gizmo.dragAngle,
195
221
  positionX: (gizmo) => gizmo.position[0] ?? 0,
@@ -207,7 +233,7 @@ export function editorImplementation() {
207
233
  rotationY: (gizmo) => gizmo.rotation[1] ?? 0,
208
234
  rotationZ: (gizmo) => gizmo.rotation[2] ?? 0,
209
235
  rotationW: (gizmo) => gizmo.rotation[3] ?? 1,
210
- mode: (editor) => editor.mode,
236
+ mode: (editor) => EDITOR_MODE_OF.get(editor.mode),
211
237
  playing: (editor) => editor.mode === 'play',
212
238
  play: (editor) => editor.play(),
213
239
  pause: (editor) => {
@@ -238,7 +264,13 @@ export function editorImplementation() {
238
264
  fieldCount: (editor) => editor.inspector.fields.length,
239
265
  fieldLabel: (editor, field) => editor.inspector.fields[field]?.label ?? '',
240
266
  fieldGroup: (editor, field) => editor.inspector.fields[field]?.group ?? '',
241
- fieldKind: (editor, field) => editor.inspector.fields[field]?.kind ?? '',
267
+ fieldKind(editor, field) {
268
+ const found = editor.inspector.fields[field];
269
+ if (found === undefined) {
270
+ throw new RangeError(`field ${field} is outside the ${editor.inspector.fields.length} shown`);
271
+ }
272
+ return FIELD_KIND_OF.get(found.kind);
273
+ },
242
274
  fieldNumber: (editor, field) => {
243
275
  const value = editor.inspector.fields[field]?.value;
244
276
  return typeof value === 'number' ? value : 0;
@@ -221,10 +221,18 @@ export function entitiesImplementation(services) {
221
221
  attach: (w, entity, component) => w.add(entity, resolve(component)),
222
222
  detach: (w, entity, component) => w.remove(entity, resolve(component)),
223
223
  read: (w, entity, component, field) => {
224
- const value = w.read(entity, resolve(component), field);
225
- /* An entity without the component reads `undefined`, and a script's `f64` may not hold one:
226
- the language has no null, so the honest zero is what a missing value is. */
227
- return typeof value === 'number' ? value : 0;
224
+ const type = resolve(component);
225
+ const value = w.read(entity, type, field);
226
+ if (value !== undefined)
227
+ return value;
228
+ /*
229
+ * Nothing to read: an absent optional field, or an entity without the component. An optional
230
+ * field answers `undefined`, which the generated code turns into `none`; the compiler wraps
231
+ * every read of one. Any other field answers zero, since a script's `f64` cannot hold
232
+ * nothing. Answering zero for an optional field too was what made it unreadable: `none` and
233
+ * a stored zero were the same answer.
234
+ */
235
+ return optionalField(type, field) ? undefined : 0;
228
236
  },
229
237
  write: (w, entity, component, field, value) => w.write(entity, resolve(component), field, value),
230
238
  /*
@@ -342,3 +350,7 @@ export function entitiesImplementation(services) {
342
350
  },
343
351
  };
344
352
  }
353
+ /** Whether a component declared this field as an option, `option:<inner>` in its schema. */
354
+ function optionalField(type, field) {
355
+ return type.schema.fields.some((f) => f.name === field && f.type.startsWith('option:'));
356
+ }
@@ -0,0 +1,25 @@
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
+ export interface Variant {
15
+ readonly tag: string;
16
+ }
17
+ export interface HostEnum {
18
+ /** The type to register, so a script can name it. */
19
+ readonly type: OpaqueType;
20
+ /** The variant for an engine code, in the order the engine numbers them. */
21
+ readonly of: readonly Variant[];
22
+ /** The engine's code for a variant a script handed back, or -1 for anything else. */
23
+ code(variant: Variant): number;
24
+ }
25
+ export declare function hostEnum(module: string, name: string, variants: readonly string[], doc: string): HostEnum;
@@ -0,0 +1,9 @@
1
+ export function hostEnum(module, name, variants, doc) {
2
+ const of = variants.map((tag) => Object.freeze({ tag }));
3
+ const codes = new Map(variants.map((tag, index) => [tag, index]));
4
+ return {
5
+ type: { module, name, doc, variants },
6
+ of,
7
+ code: (variant) => codes.get(variant.tag) ?? -1,
8
+ };
9
+ }
@@ -36,9 +36,22 @@
36
36
  * that reason, which is the one this needs.
37
37
  */
38
38
  import { defineCapability } from 'driftscript';
39
- import { JOINT_INDEX } from '@driftengine/xr';
39
+ import { hostEnum } from './variants.js';
40
+ import { HAND_JOINTS, JOINT_INDEX } from '@driftengine/xr';
40
41
  export const XR_MODULE = 'drift/xr';
41
- export const XR_TYPES = [];
42
+ /** Which hand, one variant per `Handedness`; `None` is the source a runtime gives no side. */
43
+ const HAND = hostEnum(XR_MODULE, 'Hand', ['Left', 'Right', 'None'], 'Which hand: left, right, or the input source with no side.');
44
+ const HANDEDNESS = ['left', 'right', 'none'];
45
+ /**
46
+ * A hand joint, one variant per name in `@driftengine/xr`'s `HAND_JOINTS`, in its order:
47
+ * `index-finger-tip` is `IndexFingerTip`. Derived from that list, so a joint the package adds is a
48
+ * variant here with nothing else to change, and a script cannot ask for one it misspelt.
49
+ */
50
+ const HAND_JOINT = hostEnum(XR_MODULE, 'HandJoint', HAND_JOINTS.map((name) => name
51
+ .split('-')
52
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
53
+ .join('')), 'One of the twenty-five joints WebXR tracks on a hand, from the wrist to each fingertip.');
54
+ export const XR_TYPES = [HAND.type, HAND_JOINT.type];
42
55
  const define = (name, params, returns, effects, doc, implementation) => defineCapability({
43
56
  module: XR_MODULE,
44
57
  name,
@@ -54,8 +67,8 @@ const define = (name, params, returns, effects, doc, implementation) => defineCa
54
67
  doc,
55
68
  implementation,
56
69
  });
57
- /** `left`, `right`, or anything else for the source with no handedness. */
58
- const HAND = { name: 'hand', type: 'String' };
70
+ const HAND_PARAM = { name: 'hand', type: 'Hand' };
71
+ const JOINT_PARAM = { name: 'joint', type: 'HandJoint' };
59
72
  export const XR_CAPABILITIES = [
60
73
  define('presenting', [], 'bool', ['scene.read'], 'Whether a session is running. False before one starts and after the user takes the headset off.', 'XrRuntime.presenting'),
61
74
  define('supported', [], 'bool', ['scene.read'], 'Whether this device could present at all. False on a machine with no headset attached.', 'XrRuntime.supported'),
@@ -76,9 +89,9 @@ export const XR_CAPABILITIES = [
76
89
  define('headX', [], 'f32', ['input.read'], 'Where the viewer’s head is, in metres. Zero outside a session.', 'XrRuntime.headX'),
77
90
  define('headY', [], 'f32', ['input.read'], 'Where the viewer’s head is, in metres. Zero outside a session.', 'XrRuntime.headY'),
78
91
  define('headZ', [], 'f32', ['input.read'], 'Where the viewer’s head is, in metres. Zero outside a session.', 'XrRuntime.headZ'),
79
- define('trigger', [HAND], 'f32', ['input.read'], 'How far a hand’s trigger is pulled, 0 to 1. Zero for a hand that is not there.', 'XrRuntime.trigger'),
80
- define('squeeze', [HAND], 'f32', ['input.read'], 'How far a hand’s grip is squeezed, 0 to 1. Zero for a hand that is not there.', 'XrRuntime.squeeze'),
81
- define('holding', [HAND], 'bool', ['input.read'], 'Whether a hand is tracked this frame. False while a controller is set down or out of view.', 'XrRuntime.holding'),
92
+ define('trigger', [HAND_PARAM], 'f32', ['input.read'], 'How far a hand’s trigger is pulled, 0 to 1. Zero for a hand that is not there.', 'XrRuntime.trigger'),
93
+ define('squeeze', [HAND_PARAM], 'f32', ['input.read'], 'How far a hand’s grip is squeezed, 0 to 1. Zero for a hand that is not there.', 'XrRuntime.squeeze'),
94
+ define('holding', [HAND_PARAM], 'bool', ['input.read'], 'Whether a hand is tracked this frame. False while a controller is set down or out of view.', 'XrRuntime.holding'),
82
95
  /*
83
96
  * A joint by name and not by index.
84
97
  *
@@ -86,15 +99,15 @@ export const XR_CAPABILITIES = [
86
99
  * number a script would have to keep its own copy of and watch drift against a specification.
87
100
  * A name is checked at the call and answers false for one nobody has heard of.
88
101
  */
89
- define('jointX', [HAND, { name: 'joint', type: 'String' }], 'f32', ['input.read'], 'Where a named hand joint is, in metres. Zero for a joint that is not tracked.', 'XrRuntime.jointX'),
90
- define('jointY', [HAND, { name: 'joint', type: 'String' }], 'f32', ['input.read'], 'Where a named hand joint is, in metres. Zero for a joint that is not tracked.', 'XrRuntime.jointY'),
91
- define('jointZ', [HAND, { name: 'joint', type: 'String' }], 'f32', ['input.read'], 'Where a named hand joint is, in metres. Zero for a joint that is not tracked.', 'XrRuntime.jointZ'),
92
- define('pinching', [HAND], 'bool', ['input.read'], 'Whether a hand’s thumb and index tips are close enough to count as a pinch.', 'XrRuntime.pinching'),
102
+ define('jointX', [HAND_PARAM, JOINT_PARAM], 'f32', ['input.read'], 'Where a hand joint is, in metres. Zero for a joint that is not tracked.', 'XrRuntime.jointX'),
103
+ define('jointY', [HAND_PARAM, JOINT_PARAM], 'f32', ['input.read'], 'Where a hand joint is, in metres. Zero for a joint that is not tracked.', 'XrRuntime.jointY'),
104
+ define('jointZ', [HAND_PARAM, JOINT_PARAM], 'f32', ['input.read'], 'Where a hand joint is, in metres. Zero for a joint that is not tracked.', 'XrRuntime.jointZ'),
105
+ define('pinching', [HAND_PARAM], 'bool', ['input.read'], 'Whether a hand’s thumb and index tips are close enough to count as a pinch.', 'XrRuntime.pinching'),
93
106
  ];
94
107
  /** How close two fingertips count as a pinch, in metres. */
95
108
  const PINCH_METRES = 0.025;
96
- function handOf(value) {
97
- return value === 'left' ? 'left' : value === 'right' ? 'right' : 'none';
109
+ function handOf(hand) {
110
+ return HANDEDNESS[HAND.code(hand)] ?? 'none';
98
111
  }
99
112
  export function xrImplementation(runtime) {
100
113
  const at = new Float32Array(3);
@@ -107,7 +120,7 @@ export function xrImplementation(runtime) {
107
120
  /* The flat arrays the package already fills are read directly. `jointPosition` would answer the
108
121
  same question through a `Float32Array` this would then have to unpack, three times per joint
109
122
  per frame. */
110
- const index = jointIndexOf(name);
123
+ const index = HAND_JOINT.code(name);
111
124
  if (index < 0 || skeleton.tracked[index] !== 1)
112
125
  return 0;
113
126
  return skeleton.matrices[index * 16 + 12 + axis] ?? 0;
@@ -95,7 +95,7 @@ export function registerEntityModule(module, registry, resources = new Map()) {
95
95
  const existing = registry.get(declared.name);
96
96
  /* A module reloaded keeps the store it already made: replacing the type would orphan every
97
97
  entity holding one, which is a world that silently loses its components on an edit. */
98
- const type = existing ?? defineComponent(declared.schema);
98
+ const type = existing ?? defineComponent(declared.schema, declared.defaults);
99
99
  registry.set(declared.name, type);
100
100
  components.push(type);
101
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/script",
3
- "version": "4.7.3",
3
+ "version": "4.8.0",
4
4
  "description": "The drift/* capability bindings: what this engine provides to DriftScript, and the only place the two are coupled",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,20 +28,20 @@
28
28
  ],
29
29
  "sideEffects": false,
30
30
  "dependencies": {
31
- "driftscript": "1.13.1"
31
+ "driftscript": "1.16.0"
32
32
  },
33
33
  "peerDependencies": {
34
- "@driftengine/ai": "4.7.3",
35
- "@driftengine/animation": "4.7.3",
36
- "@driftengine/audio": "4.7.3",
37
- "@driftengine/chemistry": "4.7.3",
38
- "@driftengine/core": "4.7.3",
39
- "@driftengine/editor": "4.7.3",
40
- "@driftengine/entities": "4.7.3",
41
- "@driftengine/network": "4.7.3",
42
- "@driftengine/terrain": "4.7.3",
43
- "@driftengine/ui2d": "4.7.3",
44
- "@driftengine/xr": "4.7.3"
34
+ "@driftengine/ai": "4.8.0",
35
+ "@driftengine/animation": "4.8.0",
36
+ "@driftengine/audio": "4.8.0",
37
+ "@driftengine/chemistry": "4.8.0",
38
+ "@driftengine/core": "4.8.0",
39
+ "@driftengine/editor": "4.8.0",
40
+ "@driftengine/entities": "4.8.0",
41
+ "@driftengine/network": "4.8.0",
42
+ "@driftengine/terrain": "4.8.0",
43
+ "@driftengine/ui2d": "4.8.0",
44
+ "@driftengine/xr": "4.8.0"
45
45
  },
46
46
  "author": "Drift Technologies",
47
47
  "repository": {