@driftengine/script 4.7.0 → 4.7.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.
package/capabilities.json CHANGED
@@ -1034,6 +1034,93 @@
1034
1034
  "doc": "Where something sits in the stereo field, relative to a listener facing yaw.",
1035
1035
  "implementation": "audio.stereoPan"
1036
1036
  },
1037
+ {
1038
+ "module": "drift/audio",
1039
+ "name": "duck",
1040
+ "signature": "fn(bus: String, factor: f32, seconds: f32) -> bool",
1041
+ "params": [
1042
+ {
1043
+ "name": "bus",
1044
+ "type": "String"
1045
+ },
1046
+ {
1047
+ "name": "factor",
1048
+ "type": "f32"
1049
+ },
1050
+ {
1051
+ "name": "seconds",
1052
+ "type": "f32"
1053
+ }
1054
+ ],
1055
+ "returns": "bool",
1056
+ "effects": [
1057
+ "audio.write"
1058
+ ],
1059
+ "deterministic": false,
1060
+ "doc": "Take a bus down to factor of its fader over seconds, 1 to bring it back, leaving the fader where the player set it. False when the mix has no bus of that name.",
1061
+ "implementation": "MixBus.duck"
1062
+ },
1063
+ {
1064
+ "module": "drift/audio",
1065
+ "name": "fade",
1066
+ "signature": "fn(bus: String, level: f32, seconds: f32) -> bool",
1067
+ "params": [
1068
+ {
1069
+ "name": "bus",
1070
+ "type": "String"
1071
+ },
1072
+ {
1073
+ "name": "level",
1074
+ "type": "f32"
1075
+ },
1076
+ {
1077
+ "name": "seconds",
1078
+ "type": "f32"
1079
+ }
1080
+ ],
1081
+ "returns": "bool",
1082
+ "effects": [
1083
+ "audio.write"
1084
+ ],
1085
+ "deterministic": false,
1086
+ "doc": "Move a bus's fader to level over seconds. For a bus the game made: the music and effects faders are the player's, and duck is how a script goes over them. False when the mix has no bus of that name.",
1087
+ "implementation": "MixBus.fadeLevel"
1088
+ },
1089
+ {
1090
+ "module": "drift/audio",
1091
+ "name": "recall",
1092
+ "signature": "fn(snapshot: String, seconds: f32) -> bool",
1093
+ "params": [
1094
+ {
1095
+ "name": "snapshot",
1096
+ "type": "String"
1097
+ },
1098
+ {
1099
+ "name": "seconds",
1100
+ "type": "f32"
1101
+ }
1102
+ ],
1103
+ "returns": "bool",
1104
+ "effects": [
1105
+ "audio.write"
1106
+ ],
1107
+ "deterministic": false,
1108
+ "doc": "Crossfade every level, mute and send to a snapshot the host captured, over seconds. False when nothing was captured under that name.",
1109
+ "implementation": "MixConsole.recall"
1110
+ },
1111
+ {
1112
+ "module": "drift/audio",
1113
+ "name": "pulse",
1114
+ "signature": "fn() -> f32",
1115
+ "params": [],
1116
+ "returns": "f32",
1117
+ "effects": [
1118
+ "nondeterministic"
1119
+ ],
1120
+ "deterministic": false,
1121
+ "doc": "How hard the music is kicking, 0 to 1, spiking on each kick and falling back. 0 when the host runs no kick detector.",
1122
+ "implementation": "KickDetector.pulse"
1123
+ },
1037
1124
  {
1038
1125
  "module": "drift/behavior",
1039
1126
  "name": "tick",
@@ -1552,6 +1639,28 @@
1552
1639
  "doc": "kg of liquid water on the surface, per square metre of it.",
1553
1640
  "implementation": "drift/chemistry.wetness"
1554
1641
  },
1642
+ {
1643
+ "module": "drift/chemistry",
1644
+ "name": "wettable",
1645
+ "signature": "fn(chem: Chemistry, parcel: i32) -> bool",
1646
+ "params": [
1647
+ {
1648
+ "name": "chem",
1649
+ "type": "Chemistry"
1650
+ },
1651
+ {
1652
+ "name": "parcel",
1653
+ "type": "i32"
1654
+ }
1655
+ ],
1656
+ "returns": "bool",
1657
+ "effects": [
1658
+ "chemistry.read"
1659
+ ],
1660
+ "deterministic": true,
1661
+ "doc": "Whether this material can hold liquid water, which is what `wet` asks. Rain over everything asks first, since `wet` refuses an iron nail.",
1662
+ "implementation": "drift/chemistry.wettable"
1663
+ },
1555
1664
  {
1556
1665
  "module": "drift/chemistry",
1557
1666
  "name": "phase",
@@ -2838,6 +2947,28 @@
2838
2947
  "doc": "The entity the last `findNearest` on this world found. **Valid only until the next one**, and meaningless when that answered false — the same lifetime a raycast hit has.",
2839
2948
  "implementation": "drift/ecs.nearest"
2840
2949
  },
2950
+ {
2951
+ "module": "drift/ecs",
2952
+ "name": "instantiate",
2953
+ "signature": "fn(world: World, prefab: String) -> Entity",
2954
+ "params": [
2955
+ {
2956
+ "name": "world",
2957
+ "type": "World"
2958
+ },
2959
+ {
2960
+ "name": "prefab",
2961
+ "type": "String"
2962
+ }
2963
+ ],
2964
+ "returns": "Entity",
2965
+ "effects": [
2966
+ "ecs.write"
2967
+ ],
2968
+ "deterministic": false,
2969
+ "doc": "Make an entity from a prefab, with every component it names. Overrides are the host’s to apply; a script that wants a different value writes it after.",
2970
+ "implementation": "drift/ecs.instantiate"
2971
+ },
2841
2972
  {
2842
2973
  "module": "drift/editor",
2843
2974
  "name": "translateMode",
@@ -4326,6 +4457,96 @@
4326
4457
  "doc": "Stop whatever that pad is playing. Answers whether the platform took it.",
4327
4458
  "implementation": "drift/input.stopRumble"
4328
4459
  },
4460
+ {
4461
+ "module": "drift/input",
4462
+ "name": "touchX",
4463
+ "signature": "fn(touch: Touch) -> f32",
4464
+ "params": [
4465
+ {
4466
+ "name": "touch",
4467
+ "type": "Touch"
4468
+ }
4469
+ ],
4470
+ "returns": "f32",
4471
+ "effects": [
4472
+ "input.read"
4473
+ ],
4474
+ "deterministic": false,
4475
+ "doc": "The touch stick's horizontal, -1 to 1, 0 while no thumb is on it.",
4476
+ "implementation": "drift/input.touchX"
4477
+ },
4478
+ {
4479
+ "module": "drift/input",
4480
+ "name": "touchY",
4481
+ "signature": "fn(touch: Touch) -> f32",
4482
+ "params": [
4483
+ {
4484
+ "name": "touch",
4485
+ "type": "Touch"
4486
+ }
4487
+ ],
4488
+ "returns": "f32",
4489
+ "effects": [
4490
+ "input.read"
4491
+ ],
4492
+ "deterministic": false,
4493
+ "doc": "The touch stick's vertical, -1 to 1, up negative as a gamepad stick's is.",
4494
+ "implementation": "drift/input.touchY"
4495
+ },
4496
+ {
4497
+ "module": "drift/input",
4498
+ "name": "touchHeld",
4499
+ "signature": "fn(touch: Touch) -> bool",
4500
+ "params": [
4501
+ {
4502
+ "name": "touch",
4503
+ "type": "Touch"
4504
+ }
4505
+ ],
4506
+ "returns": "bool",
4507
+ "effects": [
4508
+ "input.read"
4509
+ ],
4510
+ "deterministic": false,
4511
+ "doc": "Whether a thumb is held still on the action side of the screen: the held form of the primary.",
4512
+ "implementation": "drift/input.touchHeld"
4513
+ },
4514
+ {
4515
+ "module": "drift/input",
4516
+ "name": "touchTap",
4517
+ "signature": "fn(touch: Touch) -> bool",
4518
+ "params": [
4519
+ {
4520
+ "name": "touch",
4521
+ "type": "Touch"
4522
+ }
4523
+ ],
4524
+ "returns": "bool",
4525
+ "effects": [
4526
+ "input.read"
4527
+ ],
4528
+ "deterministic": false,
4529
+ "doc": "Whether the action side was tapped since this was last asked. True once per tap, so ask it once a tick.",
4530
+ "implementation": "drift/input.touchTap"
4531
+ },
4532
+ {
4533
+ "module": "drift/input",
4534
+ "name": "touchSlide",
4535
+ "signature": "fn(touch: Touch) -> bool",
4536
+ "params": [
4537
+ {
4538
+ "name": "touch",
4539
+ "type": "Touch"
4540
+ }
4541
+ ],
4542
+ "returns": "bool",
4543
+ "effects": [
4544
+ "input.read"
4545
+ ],
4546
+ "deterministic": false,
4547
+ "doc": "Whether a downward flick on the action side is being held: the secondary, a slide or a crouch.",
4548
+ "implementation": "drift/input.touchSlide"
4549
+ },
4329
4550
  {
4330
4551
  "module": "drift/navigation",
4331
4552
  "name": "nearest",
@@ -5777,28 +5998,6 @@
5777
5998
  "doc": "The world's gravity, in metres per second squared.",
5778
5999
  "implementation": "drift/physics.setGravity"
5779
6000
  },
5780
- {
5781
- "module": "drift/prefab",
5782
- "name": "spawn",
5783
- "signature": "fn(world: World, prefab: String) -> Entity",
5784
- "params": [
5785
- {
5786
- "name": "world",
5787
- "type": "World"
5788
- },
5789
- {
5790
- "name": "prefab",
5791
- "type": "String"
5792
- }
5793
- ],
5794
- "returns": "Entity",
5795
- "effects": [
5796
- "ecs.write"
5797
- ],
5798
- "deterministic": false,
5799
- "doc": "Make one, with every component the prefab names. Overrides are the host’s to apply; a script that wants a different value writes it after.",
5800
- "implementation": "drift/prefab.spawn"
5801
- },
5802
6001
  {
5803
6002
  "module": "drift/random",
5804
6003
  "name": "unit",
@@ -7637,6 +7836,11 @@
7637
7836
  "name": "Actions",
7638
7837
  "doc": "A bound action map: what the player is pressing."
7639
7838
  },
7839
+ {
7840
+ "module": "drift/input",
7841
+ "name": "Touch",
7842
+ "doc": "Touch controls: a stick on one side of the screen, and taps, holds and flicks on the other."
7843
+ },
7640
7844
  {
7641
7845
  "module": "drift/camera",
7642
7846
  "name": "Camera",
@@ -1,39 +1,4 @@
1
- /**
2
- * `drift/audio` — what this engine lets a script do to sound.
3
- *
4
- * **Five capabilities: a slot, two ways to play it, and the two cheap positional helpers.** This
5
- * header read "playback, placement, the mix, and the rhythm the music is doing" until 2026-08-27,
6
- * and three of those four were never here — there is no mix verb, no rhythm verb, no HRTF-panned
7
- * source, no listener and no reverb zone. The sentence was written from *Track G's* deliverables
8
- * rather than from the list beneath it, which is the same failure `drift/physics` had one file
9
- * over, and the same one the first `drift/camera` binding had: a comment describing what a
10
- * subsystem ought to expose rather than what this array does.
11
- *
12
- * It is still the right first binding and still the thing that shows what a binding *is* — registry
13
- * entries describing capabilities the engine already has, plus a map of implementations the host
14
- * supplies at link time. **No new engine code.**
15
- *
16
- * **What is missing, and why it is not guessed at here.** `MixConsole`, `AudioListenerGraph`,
17
- * `SpatialSource` and `ReverbZone` are four stateful objects with lifetimes, and a script-side
18
- * surface for them is a design about handles and ownership rather than a table entry — who disposes
19
- * a source, what happens to its probe slot, whether a zone outlives the script that made it.
20
- * `docs/CAPABILITIES.md` §4 records it. Taking that badly would put a leak in front of every script
21
- * author, which is worse than a surface they can see is absent.
22
- *
23
- * Three constraints this surface inherits, none of them negotiable:
24
- *
25
- * - **`audio.write` is outside the determinism boundary.** `ARCHITECTURE.md` places audio there, so
26
- * a `@deterministic` function may not call any of it. A simulation decides *that* a door opened
27
- * and emits an event; something outside the fixed step decides what it sounds like. The compiler
28
- * is only making a line that already exists visible.
29
- * - **`audio.play` in a `@hot` function is a diagnostic**, for the reason the engine's own rules
30
- * forbid allocation there.
31
- * - **A sound slot may be absent, so `sound` returns an option.** The registry probes several
32
- * format candidates per slot and the file often is not there. A script that ignores that plays
33
- * silence and reports success, which is the failure the language's no-implicit-null rule exists
34
- * to prevent — and this is the first place in the engine where that rule earns its keep.
35
- */
36
- import { AudioGraph, SoundRegistry } from '@driftengine/audio';
1
+ import type { AudioGraph, KickDetector, SoundRegistry } from '@driftengine/audio';
37
2
  import { type CapabilityDefinition, type OpaqueType } from 'driftscript';
38
3
  export declare const AUDIO_MODULE = "drift/audio";
39
4
  /**
@@ -53,4 +18,4 @@ export declare const AUDIO_CAPABILITIES: readonly CapabilityDefinition[];
53
18
  * registry's `implementation` strings name these; nothing enforces that mechanically, which is why
54
19
  * a test asserts every definition has an implementation here.
55
20
  */
56
- export declare function audioImplementation(graph: AudioGraph, registry: SoundRegistry): Record<string, unknown>;
21
+ export declare function audioImplementation(graph: AudioGraph, registry: SoundRegistry, kick?: KickDetector | null): Record<string, unknown>;
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * `drift/audio` — what this engine lets a script do to sound.
3
3
  *
4
- * **Five capabilities: a slot, two ways to play it, and the two cheap positional helpers.** This
4
+ * **Nine capabilities: a slot, two ways to play it, the two cheap positional helpers, three dials on
5
+ * the mix, and the kick the music is making.** The first five are the original binding. This
5
6
  * header read "playback, placement, the mix, and the rhythm the music is doing" until 2026-08-27,
6
- * and three of those four were never here — there is no mix verb, no rhythm verb, no HRTF-panned
7
- * source, no listener and no reverb zone. The sentence was written from *Track G's* deliverables
7
+ * and three of those four were not here then — no mix verb, no rhythm verb, no HRTF-panned source,
8
+ * no listener and no reverb zone. The sentence was written from *Track G's* deliverables
8
9
  * rather than from the list beneath it, which is the same failure `drift/physics` had one file
9
10
  * over, and the same one the first `drift/camera` binding had: a comment describing what a
10
11
  * subsystem ought to expose rather than what this array does.
@@ -13,12 +14,18 @@
13
14
  * entries describing capabilities the engine already has, plus a map of implementations the host
14
15
  * supplies at link time. **No new engine code.**
15
16
  *
16
- * **What is missing, and why it is not guessed at here.** `MixConsole`, `AudioListenerGraph`,
17
- * `SpatialSource` and `ReverbZone` are four stateful objects with lifetimes, and a script-side
18
- * surface for them is a design about handles and ownership rather than a table entry — who disposes
19
- * a source, what happens to its probe slot, whether a zone outlives the script that made it.
20
- * `docs/CAPABILITIES.md` §4 records it. Taking that badly would put a leak in front of every script
21
- * author, which is worse than a surface they can see is absent.
17
+ * **The mix is bound as dials, by name.** `duck`, `fade` and `recall` move a bus or a snapshot the
18
+ * host already made, named by the string it was made under, and `pulse` reads a kick detector the
19
+ * host already runs. None of them hands a script an object, so none of them raises the question of
20
+ * who disposes it; a name nothing answers to is a `false`, never a silent success. The same rule
21
+ * `drift/render` follows: a dial is bound, and anything built from a table waits for a design.
22
+ *
23
+ * **What is missing, and why it is not guessed at here.** `AudioListenerGraph`, `SpatialSource` and
24
+ * `ReverbZone` are stateful objects with lifetimes, and a script-side surface for them is a design
25
+ * about handles and ownership rather than a table entry — who disposes a source, what happens to
26
+ * its probe slot, whether a zone outlives the script that made it. `docs/CAPABILITIES.md` §4
27
+ * records it. Taking that badly would put a leak in front of every script author, which is worse
28
+ * than a surface they can see is absent.
22
29
  *
23
30
  * Three constraints this surface inherits, none of them negotiable:
24
31
  *
@@ -33,7 +40,7 @@
33
40
  * silence and reports success, which is the failure the language's no-implicit-null rule exists
34
41
  * to prevent — and this is the first place in the engine where that rule earns its keep.
35
42
  */
36
- import { AudioGraph, SoundRegistry, distanceGain, stereoPan } from '@driftengine/audio';
43
+ import { distanceGain, stereoPan } from '@driftengine/audio';
37
44
  import { defineCapability } from 'driftscript';
38
45
  export const AUDIO_MODULE = 'drift/audio';
39
46
  /**
@@ -97,6 +104,31 @@ export const AUDIO_CAPABILITIES = [
97
104
  { name: 'dz', type: 'f32' },
98
105
  { name: 'yaw', type: 'f32' },
99
106
  ], 'f32', ['pure'], true, 'Where something sits in the stereo field, relative to a listener facing yaw.', 'audio.stereoPan'),
107
+ define('duck', [
108
+ { name: 'bus', type: 'String' },
109
+ { name: 'factor', type: 'f32' },
110
+ { name: 'seconds', type: 'f32' },
111
+ ], 'bool', ['audio.write'], false, 'Take a bus down to factor of its fader over seconds, 1 to bring it back, leaving the fader ' +
112
+ 'where the player set it. False when the mix has no bus of that name.', 'MixBus.duck'),
113
+ define('fade', [
114
+ { name: 'bus', type: 'String' },
115
+ { name: 'level', type: 'f32' },
116
+ { name: 'seconds', type: 'f32' },
117
+ ], 'bool', ['audio.write'], false, "Move a bus's fader to level over seconds. For a bus the game made: the music and effects " +
118
+ "faders are the player's, and duck is how a script goes over them. False when the mix has " +
119
+ 'no bus of that name.', 'MixBus.fadeLevel'),
120
+ define('recall', [
121
+ { name: 'snapshot', type: 'String' },
122
+ { name: 'seconds', type: 'f32' },
123
+ ], 'bool', ['audio.write'], false, 'Crossfade every level, mute and send to a snapshot the host captured, over seconds. False ' +
124
+ 'when nothing was captured under that name.', 'MixConsole.recall'),
125
+ define('pulse', [], 'f32',
126
+ /*
127
+ * Read from the music as it plays, so a replay hears whatever the speakers did: outside the
128
+ * determinism boundary for the same reason playing a sound is.
129
+ */
130
+ ['nondeterministic'], false, 'How hard the music is kicking, 0 to 1, spiking on each kick and falling back. 0 when the ' +
131
+ 'host runs no kick detector.', 'KickDetector.pulse'),
100
132
  ];
101
133
  const some = (value) => ({ tag: 'some', value });
102
134
  const none = { tag: 'none' };
@@ -108,7 +140,8 @@ const none = { tag: 'none' };
108
140
  * registry's `implementation` strings name these; nothing enforces that mechanically, which is why
109
141
  * a test asserts every definition has an implementation here.
110
142
  */
111
- export function audioImplementation(graph, registry) {
143
+ export function audioImplementation(graph, registry, kick = null) {
144
+ const mix = graph.console;
112
145
  return {
113
146
  sound(slot) {
114
147
  const buffer = registry.get(slot);
@@ -122,5 +155,21 @@ export function audioImplementation(graph, registry) {
122
155
  },
123
156
  distanceGain,
124
157
  stereoPan,
158
+ duck(bus, factor, seconds) {
159
+ const found = mix.find(bus);
160
+ found?.duck(factor, seconds);
161
+ return found !== undefined;
162
+ },
163
+ fade(bus, level, seconds) {
164
+ const found = mix.find(bus);
165
+ found?.fadeLevel(level, seconds);
166
+ return found !== undefined;
167
+ },
168
+ recall(snapshot, seconds) {
169
+ return mix.recall(snapshot, seconds);
170
+ },
171
+ pulse() {
172
+ return kick?.pulse ?? 0;
173
+ },
125
174
  };
126
175
  }
@@ -103,6 +103,7 @@ export const CHEMISTRY_CAPABILITIES = [
103
103
  define('charFraction', [P, PARCEL], 'f32', READ, 'Share of the parcel that is now carbon, 0 to 1.'),
104
104
  define('charDepth', [P, PARCEL], 'f32', READ, 'Metres of char measured inward from the surface.'),
105
105
  define('wetness', [P, PARCEL], 'f32', READ, 'kg of liquid water on the surface, per square metre of it.'),
106
+ 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.'),
106
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.'),
107
108
  define('burning', [P, PARCEL], 'bool', READ, 'Whether a flame stands over it.'),
108
109
  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.'),
@@ -211,6 +212,7 @@ export function chemistryImplementation(services) {
211
212
  charFraction: (_, parcel) => parcels.charFractionOf(parcel),
212
213
  charDepth: (_, parcel) => parcels.charDepthOf(parcel),
213
214
  wetness: (_, parcel) => parcels.wetnessOf(parcel),
215
+ wettable: (_, parcel) => parcels.wettable(parcel),
214
216
  phase: (_, parcel) => parcels.parcelPhaseOf(parcel),
215
217
  burning: (_, parcel) => parcels.burning(parcel),
216
218
  smouldering: (_, parcel) => parcels.smouldering(parcel),
@@ -127,7 +127,17 @@ export declare const SCENE_CAPABILITIES: readonly CapabilityDefinition[];
127
127
  * accessor.
128
128
  */
129
129
  export declare const PHYSICS_CAPABILITIES: readonly CapabilityDefinition[];
130
- /** `drift/input` — Track H, complete: action maps, rebinding, gamepad, pointer and touch. */
130
+ /**
131
+ * `drift/input` — what the player is doing, read through an action map or a touch screen, and the
132
+ * pad's motors.
133
+ *
134
+ * **This header said "action maps, rebinding, gamepad, pointer and touch" over an array that bound
135
+ * only the action reads and rumble**, the failure `docs/CAPABILITIES.md` §4 names for three other
136
+ * binding files. Touch is bound now, as a `Touch` handle beside `Actions`, because a scripted game
137
+ * was otherwise undriveable on a phone: `TouchControls` reports to nothing an action map reads.
138
+ * Rebinding and the pointer stay with the host. A controls screen is the game's own interface, and
139
+ * `ActionMap.rebind` changes what a script's `Actions` answer without the script knowing.
140
+ */
131
141
  export declare const INPUT_CAPABILITIES: readonly CapabilityDefinition[];
132
142
  /**
133
143
  * `drift/camera` — the cinematic cameras, and **only what they actually have**.
@@ -74,6 +74,11 @@ export const CORE_TYPES = [
74
74
  name: 'Actions',
75
75
  doc: 'A bound action map: what the player is pressing.',
76
76
  },
77
+ {
78
+ module: INPUT_MODULE,
79
+ name: 'Touch',
80
+ doc: 'Touch controls: a stick on one side of the screen, and taps, holds and flicks on the other.',
81
+ },
77
82
  { module: CAMERA_MODULE, name: 'Camera', doc: 'A cinematic camera.' },
78
83
  {
79
84
  module: EVENTS_MODULE,
@@ -431,7 +436,17 @@ export const PHYSICS_CAPABILITIES = [
431
436
  { name: 'z', type: 'f32' },
432
437
  ], 'void', ['physics.write'], false, "The world's gravity, in metres per second squared."),
433
438
  ];
434
- /** `drift/input` — Track H, complete: action maps, rebinding, gamepad, pointer and touch. */
439
+ /**
440
+ * `drift/input` — what the player is doing, read through an action map or a touch screen, and the
441
+ * pad's motors.
442
+ *
443
+ * **This header said "action maps, rebinding, gamepad, pointer and touch" over an array that bound
444
+ * only the action reads and rumble**, the failure `docs/CAPABILITIES.md` §4 names for three other
445
+ * binding files. Touch is bound now, as a `Touch` handle beside `Actions`, because a scripted game
446
+ * was otherwise undriveable on a phone: `TouchControls` reports to nothing an action map reads.
447
+ * Rebinding and the pointer stay with the host. A controls screen is the game's own interface, and
448
+ * `ActionMap.rebind` changes what a script's `Actions` answer without the script knowing.
449
+ */
435
450
  export const INPUT_CAPABILITIES = [
436
451
  define(INPUT_MODULE, 'down', [
437
452
  { name: 'actions', type: 'Actions' },
@@ -488,6 +503,11 @@ export const INPUT_CAPABILITIES = [
488
503
  { name: 'weak', type: 'f32' },
489
504
  ], 'bool', ['host'], false, 'Rumble for a duration in milliseconds, at two magnitudes in [0, 1]: `strong` is the low-frequency motor and `weak` the high-frequency one. Answers whether the platform took it; false is a real answer, not an error.'),
490
505
  define(INPUT_MODULE, 'stopRumble', [{ name: 'actions', type: 'Actions' }], 'bool', ['host'], false, 'Stop whatever that pad is playing. Answers whether the platform took it.'),
506
+ define(INPUT_MODULE, 'touchX', [{ name: 'touch', type: 'Touch' }], 'f32', ['input.read'], false, "The touch stick's horizontal, -1 to 1, 0 while no thumb is on it."),
507
+ define(INPUT_MODULE, 'touchY', [{ name: 'touch', type: 'Touch' }], 'f32', ['input.read'], false, "The touch stick's vertical, -1 to 1, up negative as a gamepad stick's is."),
508
+ define(INPUT_MODULE, 'touchHeld', [{ name: 'touch', type: 'Touch' }], 'bool', ['input.read'], false, 'Whether a thumb is held still on the action side of the screen: the held form of the primary.'),
509
+ define(INPUT_MODULE, 'touchTap', [{ name: 'touch', type: 'Touch' }], 'bool', ['input.read'], false, 'Whether the action side was tapped since this was last asked. True once per tap, so ask it once a tick.'),
510
+ define(INPUT_MODULE, 'touchSlide', [{ name: 'touch', type: 'Touch' }], 'bool', ['input.read'], false, 'Whether a downward flick on the action side is being held: the secondary, a slide or a crouch.'),
491
511
  ];
492
512
  /**
493
513
  * `drift/camera` — the cinematic cameras, and **only what they actually have**.
@@ -795,6 +815,11 @@ export function inputImplementation() {
795
815
  canRumble: (actions) => actions.canRumble,
796
816
  rumble: (actions, durationMs, strong, weak) => actions.rumble(durationMs, strong, weak),
797
817
  stopRumble: (actions) => actions.stopRumble(),
818
+ touchX: (touch) => f(touch.moveX),
819
+ touchY: (touch) => f(touch.moveY),
820
+ touchHeld: (touch) => touch.primaryHeld,
821
+ touchTap: (touch) => touch.consumePrimaryPress(),
822
+ touchSlide: (touch) => touch.secondaryHeld,
798
823
  };
799
824
  }
800
825
  export function cameraImplementation() {
@@ -1,8 +1,10 @@
1
1
  /**
2
- * `drift/ecs` and `drift/prefab` — the entity model as DriftScript reaches it.
2
+ * `drift/ecs` — the entity model as DriftScript reaches it, prefabs included.
3
3
  *
4
- * These are the two surfaces the linker has refused by name since Track N shipped, and this file is
5
- * what kills that refusal. Like every binding here, nothing in it is new engine code: a binding is a
4
+ * This and `drift/prefab` were the two surfaces the linker refused by name until Track M shipped,
5
+ * and this file is what killed that refusal. `drift/prefab` is gone again: its one function was
6
+ * `spawn`, a keyword, so no script could call it, and making an entity from a prefab is
7
+ * `ecs.instantiate`. Like every binding here, nothing in it is new engine code: a binding is a
6
8
  * description and a lookup.
7
9
  *
8
10
  * ---
@@ -26,7 +28,6 @@
26
28
  import { type ComponentType, type Prefab } from '@driftengine/entities';
27
29
  import { type CapabilityDefinition, type OpaqueType } from 'driftscript';
28
30
  export declare const ECS_MODULE = "drift/ecs";
29
- export declare const PREFAB_MODULE = "drift/prefab";
30
31
  export declare const ENTITY_TYPES: readonly OpaqueType[];
31
32
  /**
32
33
  * An entity is `Entity`, and it took the language until DriftScript 1.6.0 to have the word.
@@ -56,7 +57,6 @@ export declare const ECS_CAPABILITIES: readonly CapabilityDefinition[];
56
57
  * generated code reaches them exactly as it reaches everything else, through `__bind($host)`.
57
58
  */
58
59
  export declare const QUERY_CAPABILITIES: readonly CapabilityDefinition[];
59
- export declare const PREFAB_CAPABILITIES: readonly CapabilityDefinition[];
60
60
  /** What a host supplies so a name can become a component type or a prefab. */
61
61
  export interface EntityServices {
62
62
  /**
@@ -66,8 +66,7 @@ export interface EntityServices {
66
66
  * with two worlds — a simulation and a preview — would otherwise need two hosts.
67
67
  */
68
68
  readonly components: ReadonlyMap<string, ComponentType>;
69
- /** Prefabs by name. Absent when the host has none, which makes `drift/prefab` refuse in words. */
69
+ /** Prefabs by name. Absent when the host has none, which makes `instantiate` refuse in words. */
70
70
  readonly prefabs?: ReadonlyMap<string, Prefab>;
71
71
  }
72
72
  export declare function entitiesImplementation(services: EntityServices): Record<string, unknown>;
73
- export declare function prefabImplementation(services: EntityServices): Record<string, unknown>;
@@ -1,8 +1,10 @@
1
1
  /**
2
- * `drift/ecs` and `drift/prefab` — the entity model as DriftScript reaches it.
2
+ * `drift/ecs` — the entity model as DriftScript reaches it, prefabs included.
3
3
  *
4
- * These are the two surfaces the linker has refused by name since Track N shipped, and this file is
5
- * what kills that refusal. Like every binding here, nothing in it is new engine code: a binding is a
4
+ * This and `drift/prefab` were the two surfaces the linker refused by name until Track M shipped,
5
+ * and this file is what killed that refusal. `drift/prefab` is gone again: its one function was
6
+ * `spawn`, a keyword, so no script could call it, and making an entity from a prefab is
7
+ * `ecs.instantiate`. Like every binding here, nothing in it is new engine code: a binding is a
6
8
  * description and a lookup.
7
9
  *
8
10
  * ---
@@ -26,7 +28,6 @@
26
28
  import { ENTITY_INDEX_CEILING, instantiate, } from '@driftengine/entities';
27
29
  import { defineCapability } from 'driftscript';
28
30
  export const ECS_MODULE = 'drift/ecs';
29
- export const PREFAB_MODULE = 'drift/prefab';
30
31
  const define = (module, name, params, returns, effects, deterministic, doc) => defineCapability({
31
32
  module,
32
33
  name,
@@ -146,6 +147,16 @@ export const ECS_CAPABILITIES = [
146
147
  { name: 'radius', type: 'float' },
147
148
  ], 'bool', ['ecs.read'], true, "Whether any entity carrying a component has position fields putting it within a radius, and the search `nearest` then reads. **A linear scan of that component's column, not an index** — the entity model keeps no spatial structure, so this costs one pass over everything carrying the component. Position is read from three fields you name, so nothing here knows what a transform is called."),
148
149
  define(ECS_MODULE, 'nearest', [{ name: 'world', type: 'World' }], 'Entity', ['ecs.read'], true, 'The entity the last `findNearest` on this world found. **Valid only until the next one**, and meaningless when that answered false — the same lifetime a raycast hit has.'),
150
+ /*
151
+ * **Here and not in a `drift/prefab` of its own, because that one could not be called.** It was
152
+ * bound as `prefab.spawn`, and `prefab` and `spawn` are both keywords: `spawn` cannot even appear
153
+ * in an import list, so no script could reach it, and the target never provided the module
154
+ * either. A prefab is made into entities in a world, so the call belongs with the others that do.
155
+ */
156
+ define(ECS_MODULE, 'instantiate', [
157
+ { name: 'world', type: 'World' },
158
+ { name: 'prefab', type: 'String' },
159
+ ], 'Entity', ['ecs.write'], false, 'Make an entity from a prefab, with every component it names. Overrides are the host’s to apply; a script that wants a different value writes it after.'),
149
160
  ];
150
161
  /**
151
162
  * The four a query loop is built on.
@@ -174,14 +185,9 @@ export const QUERY_CAPABILITIES = [
174
185
  { name: 'forWriting', type: 'bool' },
175
186
  ], 'View', ['ecs.read'], true, 'A component’s live columns. `forWriting` decides which of a system’s declarations is checked — a loop that only reads takes a readable view, and asking for more than the system declared is refused.'),
176
187
  ];
177
- export const PREFAB_CAPABILITIES = [
178
- define(PREFAB_MODULE, 'spawn', [
179
- { name: 'world', type: 'World' },
180
- { name: 'prefab', type: 'String' },
181
- ], 'Entity', ['ecs.write'], false, 'Make one, with every component the prefab names. Overrides are the host’s to apply; a script that wants a different value writes it after.'),
182
- ];
183
188
  export function entitiesImplementation(services) {
184
189
  const { components } = services;
190
+ const prefabs = services.prefabs ?? new Map();
185
191
  /**
186
192
  * Resolve a component name, or refuse naming what the world has.
187
193
  *
@@ -243,11 +249,15 @@ export function entitiesImplementation(services) {
243
249
  return step.done ? -1 : step.value;
244
250
  },
245
251
  view: (w, component, forWriting) => w.view(resolve(component), forWriting),
246
- count: (w, component) => w.store(resolve(component)).size,
247
- at: (w, component, index) => {
248
- const store = w.store(resolve(component));
249
- return index < store.size ? store.dense[index] : 0;
250
- },
252
+ /*
253
+ * **Through the methods a system's view shares with a world, never a store.** Inside a system
254
+ * the value a script passes is the `SystemView` the schedule handed it, which has no `store`;
255
+ * reading `store.size` here threw the moment a system counted anything. The view checks the
256
+ * component against the system's declarations, which is the other thing a store would skip.
257
+ */
258
+ count: (w, component) => w.count(resolve(component)),
259
+ /* Zero past the end, as this has always answered; the walk's bound is `count`. */
260
+ at: (w, component, index) => Math.max(0, w.at(resolve(component), index)),
251
261
  /*
252
262
  * **A boolean rather than an option, and a second accessor for the handle.** `Sound?` is an
253
263
  * option because a slot is resolved once at load; this is written to be called every frame by
@@ -322,16 +332,11 @@ export function entitiesImplementation(services) {
322
332
  * is why the search answers one.
323
333
  */
324
334
  nearest: (w) => Math.max(0, found.get(w) ?? -1),
325
- };
326
- }
327
- export function prefabImplementation(services) {
328
- const prefabs = services.prefabs ?? new Map();
329
- return {
330
- spawn: (w, name) => {
335
+ instantiate: (w, name) => {
331
336
  const prefab = prefabs.get(name);
332
337
  if (prefab === undefined) {
333
- throw new Error(`no prefab is registered as \`${name}\`. This host has ` +
334
- `${[...prefabs.keys()].map((k) => `\`${k}\``).join(', ')}.`);
338
+ const known = [...prefabs.keys()].map((k) => `\`${k}\``).join(', ');
339
+ throw new Error(`no prefab is registered as \`${name}\`. This host has ${known}.`);
335
340
  }
336
341
  return instantiate(w, prefab);
337
342
  },
package/dist/host.d.ts CHANGED
@@ -35,7 +35,7 @@
35
35
  * A binding is cheap — a table entry and a lookup — and is the difference between a subsystem
36
36
  * a consumer can script and one they can only call from TypeScript.
37
37
  */
38
- import type { AudioGraph, SoundRegistry } from '@driftengine/audio';
38
+ import type { AudioGraph, KickDetector, SoundRegistry } from '@driftengine/audio';
39
39
  import { type BehaviorServices } from './bindings/behavior.ts';
40
40
  import { type NavigationServices } from './bindings/navigation.ts';
41
41
  import { type CapabilityRegistry, type DriftModule, type TargetManifest } from 'driftscript';
@@ -87,9 +87,14 @@ export interface HostServices extends CoreServices {
87
87
  * one of those is a scene that draws and the other is a scene nobody is in.
88
88
  */
89
89
  readonly xr?: XrRuntime;
90
+ /**
91
+ * The mix a script plays into, the sounds it can name, and, when the game runs one, the kick
92
+ * detector `pulse` reads. The host calls the detector's `update` each frame; a script only reads.
93
+ */
90
94
  readonly audio?: {
91
95
  readonly graph: AudioGraph;
92
96
  readonly registry: SoundRegistry;
97
+ readonly kick?: KickDetector;
93
98
  };
94
99
  /**
95
100
  * The component types, and any prefabs, a script may name.
package/dist/host.js CHANGED
@@ -13,7 +13,7 @@ import { XR_CAPABILITIES, XR_MODULE, XR_TYPES, xrImplementation, } from './bindi
13
13
  import { RENDER_CAPABILITIES, RENDER_MODULE, RENDER_TYPES, renderImplementation, } from './bindings/render.js';
14
14
  import { ANIMATION_CAPABILITIES, ANIMATION_MODULE, ANIMATION_TYPES, animationImplementation, } from './bindings/animation.js';
15
15
  import { CAMERA_CAPABILITIES, CAMERA_MODULE, CORE_TYPES, EVENTS_CAPABILITIES, EVENTS_MODULE, INPUT_CAPABILITIES, INPUT_MODULE, PERSISTENCE_CAPABILITIES, PERSISTENCE_MODULE, PHYSICS_CAPABILITIES, PHYSICS_MODULE, RANDOM_CAPABILITIES, RANDOM_MODULE, SCENE_CAPABILITIES, SCENE_MODULE, TIME_CAPABILITIES, TIME_MODULE, cameraImplementation, eventsImplementation, inputImplementation, persistenceImplementation, physicsImplementation, randomImplementation, sceneImplementation, timeImplementation, } from './bindings/core.js';
16
- import { ECS_CAPABILITIES, QUERY_CAPABILITIES, ECS_MODULE, ENTITY_TYPES, PREFAB_CAPABILITIES, PREFAB_MODULE, entitiesImplementation, prefabImplementation, } from './bindings/entities.js';
16
+ import { ECS_CAPABILITIES, QUERY_CAPABILITIES, ECS_MODULE, ENTITY_TYPES, entitiesImplementation, } from './bindings/entities.js';
17
17
  import { CHEMISTRY_CAPABILITIES, CHEMISTRY_MODULE, CHEMISTRY_TYPES, chemistryImplementation, } from './bindings/chemistry.js';
18
18
  import { registerStd, stdImplementations } from 'driftscript/std';
19
19
  /**
@@ -97,7 +97,6 @@ export function engineRegistry() {
97
97
  ...NAVIGATION_CAPABILITIES,
98
98
  ...PERSISTENCE_CAPABILITIES,
99
99
  ...PHYSICS_CAPABILITIES,
100
- ...PREFAB_CAPABILITIES,
101
100
  ...RANDOM_CAPABILITIES,
102
101
  ...RENDER_CAPABILITIES,
103
102
  ...XR_CAPABILITIES,
@@ -138,13 +137,12 @@ export function engineImplementations(services) {
138
137
  script that fails on `math.clamp` with the namespace undefined. */
139
138
  const map = { ...stdImplementations() };
140
139
  if (services.audio !== undefined) {
141
- map[AUDIO_MODULE] = audioImplementation(services.audio.graph, services.audio.registry);
140
+ map[AUDIO_MODULE] = audioImplementation(services.audio.graph, services.audio.registry, services.audio.kick ?? null);
142
141
  }
143
142
  if (services.clocks !== undefined)
144
143
  map[TIME_MODULE] = timeImplementation(services.clocks);
145
144
  if (services.entities !== undefined) {
146
145
  map[ECS_MODULE] = entitiesImplementation(services.entities);
147
- map[PREFAB_MODULE] = prefabImplementation(services.entities);
148
146
  }
149
147
  /*
150
148
  * These four take their handle as an argument rather than from the host, so they need nothing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/script",
3
- "version": "4.7.0",
3
+ "version": "4.7.1",
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",
@@ -31,17 +31,17 @@
31
31
  "driftscript": "1.13.0"
32
32
  },
33
33
  "peerDependencies": {
34
- "@driftengine/ai": "4.7.0",
35
- "@driftengine/animation": "4.7.0",
36
- "@driftengine/audio": "4.7.0",
37
- "@driftengine/chemistry": "4.7.0",
38
- "@driftengine/core": "4.7.0",
39
- "@driftengine/editor": "4.7.0",
40
- "@driftengine/entities": "4.7.0",
41
- "@driftengine/network": "4.7.0",
42
- "@driftengine/terrain": "4.7.0",
43
- "@driftengine/ui2d": "4.7.0",
44
- "@driftengine/xr": "4.7.0"
34
+ "@driftengine/ai": "4.7.1",
35
+ "@driftengine/animation": "4.7.1",
36
+ "@driftengine/audio": "4.7.1",
37
+ "@driftengine/chemistry": "4.7.1",
38
+ "@driftengine/core": "4.7.1",
39
+ "@driftengine/editor": "4.7.1",
40
+ "@driftengine/entities": "4.7.1",
41
+ "@driftengine/network": "4.7.1",
42
+ "@driftengine/terrain": "4.7.1",
43
+ "@driftengine/ui2d": "4.7.1",
44
+ "@driftengine/xr": "4.7.1"
45
45
  },
46
46
  "author": "Drift Technologies",
47
47
  "repository": {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * `drift/audio` — what this engine lets a script do to sound.
3
3
  *
4
- * **Five capabilities: a slot, two ways to play it, and the two cheap positional helpers.** This
4
+ * **Nine capabilities: a slot, two ways to play it, the two cheap positional helpers, three dials on
5
+ * the mix, and the kick the music is making.** The first five are the original binding. This
5
6
  * header read "playback, placement, the mix, and the rhythm the music is doing" until 2026-08-27,
6
- * and three of those four were never here — there is no mix verb, no rhythm verb, no HRTF-panned
7
- * source, no listener and no reverb zone. The sentence was written from *Track G's* deliverables
7
+ * and three of those four were not here then — no mix verb, no rhythm verb, no HRTF-panned source,
8
+ * no listener and no reverb zone. The sentence was written from *Track G's* deliverables
8
9
  * rather than from the list beneath it, which is the same failure `drift/physics` had one file
9
10
  * over, and the same one the first `drift/camera` binding had: a comment describing what a
10
11
  * subsystem ought to expose rather than what this array does.
@@ -13,12 +14,18 @@
13
14
  * entries describing capabilities the engine already has, plus a map of implementations the host
14
15
  * supplies at link time. **No new engine code.**
15
16
  *
16
- * **What is missing, and why it is not guessed at here.** `MixConsole`, `AudioListenerGraph`,
17
- * `SpatialSource` and `ReverbZone` are four stateful objects with lifetimes, and a script-side
18
- * surface for them is a design about handles and ownership rather than a table entry — who disposes
19
- * a source, what happens to its probe slot, whether a zone outlives the script that made it.
20
- * `docs/CAPABILITIES.md` §4 records it. Taking that badly would put a leak in front of every script
21
- * author, which is worse than a surface they can see is absent.
17
+ * **The mix is bound as dials, by name.** `duck`, `fade` and `recall` move a bus or a snapshot the
18
+ * host already made, named by the string it was made under, and `pulse` reads a kick detector the
19
+ * host already runs. None of them hands a script an object, so none of them raises the question of
20
+ * who disposes it; a name nothing answers to is a `false`, never a silent success. The same rule
21
+ * `drift/render` follows: a dial is bound, and anything built from a table waits for a design.
22
+ *
23
+ * **What is missing, and why it is not guessed at here.** `AudioListenerGraph`, `SpatialSource` and
24
+ * `ReverbZone` are stateful objects with lifetimes, and a script-side surface for them is a design
25
+ * about handles and ownership rather than a table entry — who disposes a source, what happens to
26
+ * its probe slot, whether a zone outlives the script that made it. `docs/CAPABILITIES.md` §4
27
+ * records it. Taking that badly would put a leak in front of every script author, which is worse
28
+ * than a surface they can see is absent.
22
29
  *
23
30
  * Three constraints this surface inherits, none of them negotiable:
24
31
  *
@@ -33,7 +40,8 @@
33
40
  * silence and reports success, which is the failure the language's no-implicit-null rule exists
34
41
  * to prevent — and this is the first place in the engine where that rule earns its keep.
35
42
  */
36
- import { AudioGraph, SoundRegistry, distanceGain, stereoPan } from '@driftengine/audio';
43
+ import { distanceGain, stereoPan } from '@driftengine/audio';
44
+ import type { AudioGraph, KickDetector, SoundRegistry } from '@driftengine/audio';
37
45
  import { type CapabilityDefinition, type OpaqueType, defineCapability } from 'driftscript';
38
46
 
39
47
  export const AUDIO_MODULE = 'drift/audio';
@@ -148,6 +156,62 @@ export const AUDIO_CAPABILITIES: readonly CapabilityDefinition[] = [
148
156
  'Where something sits in the stereo field, relative to a listener facing yaw.',
149
157
  'audio.stereoPan',
150
158
  ),
159
+ define(
160
+ 'duck',
161
+ [
162
+ { name: 'bus', type: 'String' },
163
+ { name: 'factor', type: 'f32' },
164
+ { name: 'seconds', type: 'f32' },
165
+ ],
166
+ 'bool',
167
+ ['audio.write'],
168
+ false,
169
+ 'Take a bus down to factor of its fader over seconds, 1 to bring it back, leaving the fader ' +
170
+ 'where the player set it. False when the mix has no bus of that name.',
171
+ 'MixBus.duck',
172
+ ),
173
+ define(
174
+ 'fade',
175
+ [
176
+ { name: 'bus', type: 'String' },
177
+ { name: 'level', type: 'f32' },
178
+ { name: 'seconds', type: 'f32' },
179
+ ],
180
+ 'bool',
181
+ ['audio.write'],
182
+ false,
183
+ "Move a bus's fader to level over seconds. For a bus the game made: the music and effects " +
184
+ "faders are the player's, and duck is how a script goes over them. False when the mix has " +
185
+ 'no bus of that name.',
186
+ 'MixBus.fadeLevel',
187
+ ),
188
+ define(
189
+ 'recall',
190
+ [
191
+ { name: 'snapshot', type: 'String' },
192
+ { name: 'seconds', type: 'f32' },
193
+ ],
194
+ 'bool',
195
+ ['audio.write'],
196
+ false,
197
+ 'Crossfade every level, mute and send to a snapshot the host captured, over seconds. False ' +
198
+ 'when nothing was captured under that name.',
199
+ 'MixConsole.recall',
200
+ ),
201
+ define(
202
+ 'pulse',
203
+ [],
204
+ 'f32',
205
+ /*
206
+ * Read from the music as it plays, so a replay hears whatever the speakers did: outside the
207
+ * determinism boundary for the same reason playing a sound is.
208
+ */
209
+ ['nondeterministic'],
210
+ false,
211
+ 'How hard the music is kicking, 0 to 1, spiking on each kick and falling back. 0 when the ' +
212
+ 'host runs no kick detector.',
213
+ 'KickDetector.pulse',
214
+ ),
151
215
  ];
152
216
 
153
217
  /** How the runtime represents an option. Matches what the compiler generates for `some`/`none`. */
@@ -167,7 +231,9 @@ const none: Option<never> = { tag: 'none' };
167
231
  export function audioImplementation(
168
232
  graph: AudioGraph,
169
233
  registry: SoundRegistry,
234
+ kick: KickDetector | null = null,
170
235
  ): Record<string, unknown> {
236
+ const mix = graph.console;
171
237
  return {
172
238
  sound(slot: string): Option<AudioBuffer> {
173
239
  const buffer = registry.get(slot);
@@ -181,5 +247,21 @@ export function audioImplementation(
181
247
  },
182
248
  distanceGain,
183
249
  stereoPan,
250
+ duck(bus: string, factor: number, seconds: number): boolean {
251
+ const found = mix.find(bus);
252
+ found?.duck(factor, seconds);
253
+ return found !== undefined;
254
+ },
255
+ fade(bus: string, level: number, seconds: number): boolean {
256
+ const found = mix.find(bus);
257
+ found?.fadeLevel(level, seconds);
258
+ return found !== undefined;
259
+ },
260
+ recall(snapshot: string, seconds: number): boolean {
261
+ return mix.recall(snapshot, seconds);
262
+ },
263
+ pulse(): number {
264
+ return kick?.pulse ?? 0;
265
+ },
184
266
  };
185
267
  }
@@ -189,6 +189,13 @@ export const CHEMISTRY_CAPABILITIES: readonly CapabilityDefinition[] = [
189
189
  READ,
190
190
  'kg of liquid water on the surface, per square metre of it.',
191
191
  ),
192
+ define(
193
+ 'wettable',
194
+ [P, PARCEL],
195
+ 'bool',
196
+ READ,
197
+ 'Whether this material can hold liquid water, which is what `wet` asks. Rain over everything asks first, since `wet` refuses an iron nail.',
198
+ ),
192
199
  define(
193
200
  'phase',
194
201
  [P, PARCEL],
@@ -488,6 +495,7 @@ export function chemistryImplementation(services: ChemistryServices): Record<str
488
495
  charFraction: (_: unknown, parcel: number) => parcels.charFractionOf(parcel),
489
496
  charDepth: (_: unknown, parcel: number) => parcels.charDepthOf(parcel),
490
497
  wetness: (_: unknown, parcel: number) => parcels.wetnessOf(parcel),
498
+ wettable: (_: unknown, parcel: number) => parcels.wettable(parcel),
491
499
  phase: (_: unknown, parcel: number) => parcels.parcelPhaseOf(parcel),
492
500
  burning: (_: unknown, parcel: number) => parcels.burning(parcel),
493
501
  smouldering: (_: unknown, parcel: number) => parcels.smouldering(parcel),
@@ -31,6 +31,7 @@ import {
31
31
  type QueuedMessage,
32
32
  type RayHit,
33
33
  SceneNode,
34
+ type TouchControls,
34
35
  type WindState,
35
36
  angleDelta,
36
37
  boxCollider,
@@ -106,6 +107,11 @@ export const CORE_TYPES: readonly OpaqueType[] = [
106
107
  name: 'Actions',
107
108
  doc: 'A bound action map: what the player is pressing.',
108
109
  },
110
+ {
111
+ module: INPUT_MODULE,
112
+ name: 'Touch',
113
+ doc: 'Touch controls: a stick on one side of the screen, and taps, holds and flicks on the other.',
114
+ },
109
115
  { module: CAMERA_MODULE, name: 'Camera', doc: 'A cinematic camera.' },
110
116
  {
111
117
  module: EVENTS_MODULE,
@@ -1018,7 +1024,17 @@ export const PHYSICS_CAPABILITIES: readonly CapabilityDefinition[] = [
1018
1024
  ),
1019
1025
  ];
1020
1026
 
1021
- /** `drift/input` — Track H, complete: action maps, rebinding, gamepad, pointer and touch. */
1027
+ /**
1028
+ * `drift/input` — what the player is doing, read through an action map or a touch screen, and the
1029
+ * pad's motors.
1030
+ *
1031
+ * **This header said "action maps, rebinding, gamepad, pointer and touch" over an array that bound
1032
+ * only the action reads and rumble**, the failure `docs/CAPABILITIES.md` §4 names for three other
1033
+ * binding files. Touch is bound now, as a `Touch` handle beside `Actions`, because a scripted game
1034
+ * was otherwise undriveable on a phone: `TouchControls` reports to nothing an action map reads.
1035
+ * Rebinding and the pointer stay with the host. A controls screen is the game's own interface, and
1036
+ * `ActionMap.rebind` changes what a script's `Actions` answer without the script knowing.
1037
+ */
1022
1038
  export const INPUT_CAPABILITIES: readonly CapabilityDefinition[] = [
1023
1039
  define(
1024
1040
  INPUT_MODULE,
@@ -1147,6 +1163,51 @@ export const INPUT_CAPABILITIES: readonly CapabilityDefinition[] = [
1147
1163
  false,
1148
1164
  'Stop whatever that pad is playing. Answers whether the platform took it.',
1149
1165
  ),
1166
+ define(
1167
+ INPUT_MODULE,
1168
+ 'touchX',
1169
+ [{ name: 'touch', type: 'Touch' }],
1170
+ 'f32',
1171
+ ['input.read'],
1172
+ false,
1173
+ "The touch stick's horizontal, -1 to 1, 0 while no thumb is on it.",
1174
+ ),
1175
+ define(
1176
+ INPUT_MODULE,
1177
+ 'touchY',
1178
+ [{ name: 'touch', type: 'Touch' }],
1179
+ 'f32',
1180
+ ['input.read'],
1181
+ false,
1182
+ "The touch stick's vertical, -1 to 1, up negative as a gamepad stick's is.",
1183
+ ),
1184
+ define(
1185
+ INPUT_MODULE,
1186
+ 'touchHeld',
1187
+ [{ name: 'touch', type: 'Touch' }],
1188
+ 'bool',
1189
+ ['input.read'],
1190
+ false,
1191
+ 'Whether a thumb is held still on the action side of the screen: the held form of the primary.',
1192
+ ),
1193
+ define(
1194
+ INPUT_MODULE,
1195
+ 'touchTap',
1196
+ [{ name: 'touch', type: 'Touch' }],
1197
+ 'bool',
1198
+ ['input.read'],
1199
+ false,
1200
+ 'Whether the action side was tapped since this was last asked. True once per tap, so ask it once a tick.',
1201
+ ),
1202
+ define(
1203
+ INPUT_MODULE,
1204
+ 'touchSlide',
1205
+ [{ name: 'touch', type: 'Touch' }],
1206
+ 'bool',
1207
+ ['input.read'],
1208
+ false,
1209
+ 'Whether a downward flick on the action side is being held: the secondary, a slide or a crouch.',
1210
+ ),
1150
1211
  ];
1151
1212
 
1152
1213
  /**
@@ -1583,6 +1644,11 @@ export function inputImplementation(): Record<string, unknown> {
1583
1644
  rumble: (actions: ActionMap, durationMs: number, strong: number, weak: number) =>
1584
1645
  actions.rumble(durationMs, strong, weak),
1585
1646
  stopRumble: (actions: ActionMap) => actions.stopRumble(),
1647
+ touchX: (touch: TouchControls) => f(touch.moveX),
1648
+ touchY: (touch: TouchControls) => f(touch.moveY),
1649
+ touchHeld: (touch: TouchControls) => touch.primaryHeld,
1650
+ touchTap: (touch: TouchControls) => touch.consumePrimaryPress(),
1651
+ touchSlide: (touch: TouchControls) => touch.secondaryHeld,
1586
1652
  };
1587
1653
  }
1588
1654
 
@@ -1,8 +1,10 @@
1
1
  /**
2
- * `drift/ecs` and `drift/prefab` — the entity model as DriftScript reaches it.
2
+ * `drift/ecs` — the entity model as DriftScript reaches it, prefabs included.
3
3
  *
4
- * These are the two surfaces the linker has refused by name since Track N shipped, and this file is
5
- * what kills that refusal. Like every binding here, nothing in it is new engine code: a binding is a
4
+ * This and `drift/prefab` were the two surfaces the linker refused by name until Track M shipped,
5
+ * and this file is what killed that refusal. `drift/prefab` is gone again: its one function was
6
+ * `spawn`, a keyword, so no script could call it, and making an entity from a prefab is
7
+ * `ecs.instantiate`. Like every binding here, nothing in it is new engine code: a binding is a
6
8
  * description and a lookup.
7
9
  *
8
10
  * ---
@@ -30,13 +32,13 @@ import {
30
32
  type Entity,
31
33
  type Prefab,
32
34
  type QueryCursor,
35
+ type SystemView,
33
36
  type World,
34
37
  instantiate,
35
38
  } from '@driftengine/entities';
36
39
  import { type CapabilityDefinition, type OpaqueType, defineCapability } from 'driftscript';
37
40
 
38
41
  export const ECS_MODULE = 'drift/ecs';
39
- export const PREFAB_MODULE = 'drift/prefab';
40
42
 
41
43
  const define = (
42
44
  module: string,
@@ -264,6 +266,24 @@ export const ECS_CAPABILITIES: readonly CapabilityDefinition[] = [
264
266
  true,
265
267
  'The entity the last `findNearest` on this world found. **Valid only until the next one**, and meaningless when that answered false — the same lifetime a raycast hit has.',
266
268
  ),
269
+ /*
270
+ * **Here and not in a `drift/prefab` of its own, because that one could not be called.** It was
271
+ * bound as `prefab.spawn`, and `prefab` and `spawn` are both keywords: `spawn` cannot even appear
272
+ * in an import list, so no script could reach it, and the target never provided the module
273
+ * either. A prefab is made into entities in a world, so the call belongs with the others that do.
274
+ */
275
+ define(
276
+ ECS_MODULE,
277
+ 'instantiate',
278
+ [
279
+ { name: 'world', type: 'World' },
280
+ { name: 'prefab', type: 'String' },
281
+ ],
282
+ 'Entity',
283
+ ['ecs.write'],
284
+ false,
285
+ 'Make an entity from a prefab, with every component it names. Overrides are the host’s to apply; a script that wants a different value writes it after.',
286
+ ),
267
287
  ];
268
288
 
269
289
  /**
@@ -326,21 +346,6 @@ export const QUERY_CAPABILITIES: readonly CapabilityDefinition[] = [
326
346
  ),
327
347
  ];
328
348
 
329
- export const PREFAB_CAPABILITIES: readonly CapabilityDefinition[] = [
330
- define(
331
- PREFAB_MODULE,
332
- 'spawn',
333
- [
334
- { name: 'world', type: 'World' },
335
- { name: 'prefab', type: 'String' },
336
- ],
337
- 'Entity',
338
- ['ecs.write'],
339
- false,
340
- 'Make one, with every component the prefab names. Overrides are the host’s to apply; a script that wants a different value writes it after.',
341
- ),
342
- ];
343
-
344
349
  /** What a host supplies so a name can become a component type or a prefab. */
345
350
  export interface EntityServices {
346
351
  /**
@@ -350,12 +355,13 @@ export interface EntityServices {
350
355
  * with two worlds — a simulation and a preview — would otherwise need two hosts.
351
356
  */
352
357
  readonly components: ReadonlyMap<string, ComponentType>;
353
- /** Prefabs by name. Absent when the host has none, which makes `drift/prefab` refuse in words. */
358
+ /** Prefabs by name. Absent when the host has none, which makes `instantiate` refuse in words. */
354
359
  readonly prefabs?: ReadonlyMap<string, Prefab>;
355
360
  }
356
361
 
357
362
  export function entitiesImplementation(services: EntityServices): Record<string, unknown> {
358
363
  const { components } = services;
364
+ const prefabs = services.prefabs ?? new Map<string, Prefab>();
359
365
 
360
366
  /**
361
367
  * Resolve a component name, or refuse naming what the world has.
@@ -388,7 +394,7 @@ export function entitiesImplementation(services: EntityServices): Record<string,
388
394
  return {
389
395
  create: (w: World) => w.create(),
390
396
  destroy: (w: World, entity: Entity) => w.destroy(entity),
391
- alive: (w: World, entity: Entity) => w.alive(entity),
397
+ alive: (w: SystemView, entity: Entity) => w.alive(entity),
392
398
  has: (w: World, entity: Entity, component: string) => w.has(entity, resolve(component)),
393
399
  attach: (w: World, entity: Entity, component: string) => w.add(entity, resolve(component)),
394
400
  detach: (w: World, entity: Entity, component: string) => w.remove(entity, resolve(component)),
@@ -429,11 +435,16 @@ export function entitiesImplementation(services: EntityServices): Record<string,
429
435
  },
430
436
  view: (w: QuerySource, component: string, forWriting: boolean) =>
431
437
  w.view(resolve(component), forWriting),
432
- count: (w: World, component: string) => w.store(resolve(component)).size,
433
- at: (w: World, component: string, index: number) => {
434
- const store = w.store(resolve(component));
435
- return index < store.size ? (store.dense[index] as number) : 0;
436
- },
438
+ /*
439
+ * **Through the methods a system's view shares with a world, never a store.** Inside a system
440
+ * the value a script passes is the `SystemView` the schedule handed it, which has no `store`;
441
+ * reading `store.size` here threw the moment a system counted anything. The view checks the
442
+ * component against the system's declarations, which is the other thing a store would skip.
443
+ */
444
+ count: (w: SystemView, component: string) => w.count(resolve(component)),
445
+ /* Zero past the end, as this has always answered; the walk's bound is `count`. */
446
+ at: (w: SystemView, component: string, index: number) =>
447
+ Math.max(0, w.at(resolve(component), index)),
437
448
  /*
438
449
  * **A boolean rather than an option, and a second accessor for the handle.** `Sound?` is an
439
450
  * option because a slot is resolved once at load; this is written to be called every frame by
@@ -517,6 +528,14 @@ export function entitiesImplementation(services: EntityServices): Record<string,
517
528
  * is why the search answers one.
518
529
  */
519
530
  nearest: (w: QuerySource) => Math.max(0, found.get(w) ?? -1),
531
+ instantiate: (w: World, name: string) => {
532
+ const prefab = prefabs.get(name);
533
+ if (prefab === undefined) {
534
+ const known = [...prefabs.keys()].map((k) => `\`${k}\``).join(', ');
535
+ throw new Error(`no prefab is registered as \`${name}\`. This host has ${known}.`);
536
+ }
537
+ return instantiate(w, prefab);
538
+ },
520
539
  };
521
540
  }
522
541
 
@@ -532,19 +551,3 @@ interface QuerySource {
532
551
  query(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): QueryCursor;
533
552
  view(type: ComponentType, forWriting: boolean): ComponentView;
534
553
  }
535
-
536
- export function prefabImplementation(services: EntityServices): Record<string, unknown> {
537
- const prefabs = services.prefabs ?? new Map<string, Prefab>();
538
- return {
539
- spawn: (w: World, name: string) => {
540
- const prefab = prefabs.get(name);
541
- if (prefab === undefined) {
542
- throw new Error(
543
- `no prefab is registered as \`${name}\`. This host has ` +
544
- `${[...prefabs.keys()].map((k) => `\`${k}\``).join(', ')}.`,
545
- );
546
- }
547
- return instantiate(w, prefab);
548
- },
549
- };
550
- }
package/src/host.ts CHANGED
@@ -35,7 +35,7 @@
35
35
  * A binding is cheap — a table entry and a lookup — and is the difference between a subsystem
36
36
  * a consumer can script and one they can only call from TypeScript.
37
37
  */
38
- import type { AudioGraph, SoundRegistry } from '@driftengine/audio';
38
+ import type { AudioGraph, KickDetector, SoundRegistry } from '@driftengine/audio';
39
39
  import {
40
40
  BEHAVIOR_CAPABILITIES,
41
41
  BEHAVIOR_MODULE,
@@ -161,10 +161,7 @@ import {
161
161
  ECS_MODULE,
162
162
  ENTITY_TYPES,
163
163
  type EntityServices,
164
- PREFAB_CAPABILITIES,
165
- PREFAB_MODULE,
166
164
  entitiesImplementation,
167
- prefabImplementation,
168
165
  } from './bindings/entities.ts';
169
166
  import {
170
167
  CHEMISTRY_CAPABILITIES,
@@ -259,7 +256,6 @@ export function engineRegistry(): CapabilityRegistry {
259
256
  ...NAVIGATION_CAPABILITIES,
260
257
  ...PERSISTENCE_CAPABILITIES,
261
258
  ...PHYSICS_CAPABILITIES,
262
- ...PREFAB_CAPABILITIES,
263
259
  ...RANDOM_CAPABILITIES,
264
260
  ...RENDER_CAPABILITIES,
265
261
  ...XR_CAPABILITIES,
@@ -304,7 +300,15 @@ export interface HostServices extends CoreServices {
304
300
  * one of those is a scene that draws and the other is a scene nobody is in.
305
301
  */
306
302
  readonly xr?: XrRuntime;
307
- readonly audio?: { readonly graph: AudioGraph; readonly registry: SoundRegistry };
303
+ /**
304
+ * The mix a script plays into, the sounds it can name, and, when the game runs one, the kick
305
+ * detector `pulse` reads. The host calls the detector's `update` each frame; a script only reads.
306
+ */
307
+ readonly audio?: {
308
+ readonly graph: AudioGraph;
309
+ readonly registry: SoundRegistry;
310
+ readonly kick?: KickDetector;
311
+ };
308
312
  /**
309
313
  * The component types, and any prefabs, a script may name.
310
314
  *
@@ -358,12 +362,15 @@ export function engineImplementations(services: HostServices): Record<string, un
358
362
  const map: Record<string, unknown> = { ...stdImplementations() };
359
363
 
360
364
  if (services.audio !== undefined) {
361
- map[AUDIO_MODULE] = audioImplementation(services.audio.graph, services.audio.registry);
365
+ map[AUDIO_MODULE] = audioImplementation(
366
+ services.audio.graph,
367
+ services.audio.registry,
368
+ services.audio.kick ?? null,
369
+ );
362
370
  }
363
371
  if (services.clocks !== undefined) map[TIME_MODULE] = timeImplementation(services.clocks);
364
372
  if (services.entities !== undefined) {
365
373
  map[ECS_MODULE] = entitiesImplementation(services.entities);
366
- map[PREFAB_MODULE] = prefabImplementation(services.entities);
367
374
  }
368
375
 
369
376
  /*