@driftengine/script 3.61.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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +81 -0
- package/dist/bindings/ai.d.ts +66 -0
- package/dist/bindings/ai.js +173 -0
- package/dist/bindings/animation.d.ts +13 -0
- package/dist/bindings/animation.js +177 -0
- package/dist/bindings/audio.d.ts +56 -0
- package/dist/bindings/audio.js +126 -0
- package/dist/bindings/behavior.d.ts +36 -0
- package/dist/bindings/behavior.js +101 -0
- package/dist/bindings/chemistry.d.ts +72 -0
- package/dist/bindings/chemistry.js +253 -0
- package/dist/bindings/core.d.ts +172 -0
- package/dist/bindings/core.js +814 -0
- package/dist/bindings/editor.d.ts +53 -0
- package/dist/bindings/editor.js +248 -0
- package/dist/bindings/entities.d.ts +73 -0
- package/dist/bindings/entities.js +339 -0
- package/dist/bindings/interface.d.ts +55 -0
- package/dist/bindings/interface.js +200 -0
- package/dist/bindings/navigation.d.ts +36 -0
- package/dist/bindings/navigation.js +255 -0
- package/dist/bindings/network.d.ts +51 -0
- package/dist/bindings/network.js +96 -0
- package/dist/bindings/render.d.ts +71 -0
- package/dist/bindings/render.js +143 -0
- package/dist/bindings/rollback.d.ts +41 -0
- package/dist/bindings/rollback.js +74 -0
- package/dist/bindings/sprites.d.ts +53 -0
- package/dist/bindings/sprites.js +186 -0
- package/dist/bindings/terrain.d.ts +54 -0
- package/dist/bindings/terrain.js +138 -0
- package/dist/bindings/xr.d.ts +56 -0
- package/dist/bindings/xr.js +155 -0
- package/dist/entityHost.d.ts +80 -0
- package/dist/entityHost.js +159 -0
- package/dist/host.d.ts +153 -0
- package/dist/host.js +233 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +18 -0
- package/package.json +68 -0
- package/src/bindings/ai.ts +306 -0
- package/src/bindings/animation.ts +346 -0
- package/src/bindings/audio.ts +185 -0
- package/src/bindings/behavior.ts +181 -0
- package/src/bindings/chemistry.ts +548 -0
- package/src/bindings/core.ts +1604 -0
- package/src/bindings/editor.ts +393 -0
- package/src/bindings/entities.ts +550 -0
- package/src/bindings/interface.ts +300 -0
- package/src/bindings/navigation.ts +367 -0
- package/src/bindings/network.ts +178 -0
- package/src/bindings/render.ts +191 -0
- package/src/bindings/rollback.ts +110 -0
- package/src/bindings/sprites.ts +310 -0
- package/src/bindings/terrain.ts +180 -0
- package/src/bindings/xr.ts +272 -0
- package/src/entityHost.ts +219 -0
- package/src/host.ts +465 -0
- package/src/index.ts +41 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `drift/animation` — Track A, complete, and the one binding with a trap to disarm.
|
|
3
|
+
*
|
|
4
|
+
* **`BlendTree` allocates its own scratch poses, and without a bind pose they start at zero
|
|
5
|
+
* translation.** `sampleClip` leaves a channel no track mentions exactly as it found it, so a
|
|
6
|
+
* rotation-only clip — which is most of them — preserves whatever translations the pose already
|
|
7
|
+
* held. `blendPoses` cannot do that: it interpolates *every* channel of two poses. So a tree or a
|
|
8
|
+
* state machine built without a bind pose collapses every joint onto its parent's origin, and the
|
|
9
|
+
* symptom is a figure folded in on itself rather than an error.
|
|
10
|
+
*
|
|
11
|
+
* The TypeScript constructor makes `bind` optional, and that is right there: its caller is holding
|
|
12
|
+
* the skeleton and knows what a rest pose is. **A script never meets that argument**, because a
|
|
13
|
+
* script does not build a tree — it drives one its host built and handed it, which is what
|
|
14
|
+
* `blendSet` and `blendAt` are for and what the block above them explains. `reach` is this file's
|
|
15
|
+
* worked example of the rule that used to live here: its pole hint is required at the boundary
|
|
16
|
+
* because a chain that is already straight has no plane to bend in, and a script author meets that
|
|
17
|
+
* trap first.
|
|
18
|
+
*
|
|
19
|
+
* **`blendPoses` does not take one and must not be given one**, which the paraphrase this binding
|
|
20
|
+
* was first written from got wrong. It writes into a caller-owned `out`, so the caller already
|
|
21
|
+
* controls what the untouched channels start as; adding a bind-pose parameter here would be
|
|
22
|
+
* inventing an argument the engine does not have. The rule belongs to the thing that allocates.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
type AnimationClip,
|
|
26
|
+
type BlendTree,
|
|
27
|
+
type Pose,
|
|
28
|
+
type RootMotion,
|
|
29
|
+
Skeleton,
|
|
30
|
+
blendPoses,
|
|
31
|
+
createPose,
|
|
32
|
+
createRootMotion,
|
|
33
|
+
extractRootMotion,
|
|
34
|
+
restPose,
|
|
35
|
+
sampleClip,
|
|
36
|
+
solveTwoBone,
|
|
37
|
+
stripRootMotion,
|
|
38
|
+
} from '@driftengine/animation';
|
|
39
|
+
import { type CapabilityDefinition, type OpaqueType, defineCapability } from 'driftscript';
|
|
40
|
+
|
|
41
|
+
export const ANIMATION_MODULE = 'drift/animation';
|
|
42
|
+
|
|
43
|
+
export const ANIMATION_TYPES: readonly OpaqueType[] = [
|
|
44
|
+
{
|
|
45
|
+
module: ANIMATION_MODULE,
|
|
46
|
+
name: 'Skeleton',
|
|
47
|
+
doc: 'A joint hierarchy, parents before children.',
|
|
48
|
+
},
|
|
49
|
+
{ module: ANIMATION_MODULE, name: 'Pose', doc: 'One set of joint transforms.' },
|
|
50
|
+
{ module: ANIMATION_MODULE, name: 'Clip', doc: 'Keyframed tracks over time.' },
|
|
51
|
+
{ module: ANIMATION_MODULE, name: 'Blend', doc: 'A blend tree over clips.' },
|
|
52
|
+
{ module: ANIMATION_MODULE, name: 'Motion', doc: "A root's displacement over an interval." },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const define = (
|
|
56
|
+
name: string,
|
|
57
|
+
params: readonly { name: string; type: string }[],
|
|
58
|
+
returns: string,
|
|
59
|
+
effects: CapabilityDefinition['effects'],
|
|
60
|
+
deterministic: boolean,
|
|
61
|
+
doc: string,
|
|
62
|
+
): CapabilityDefinition =>
|
|
63
|
+
defineCapability({
|
|
64
|
+
module: ANIMATION_MODULE,
|
|
65
|
+
name,
|
|
66
|
+
signature: `fn(${params.map((p) => `${p.name}: ${p.type}`).join(', ')}) -> ${returns}`,
|
|
67
|
+
params: [...params],
|
|
68
|
+
returns,
|
|
69
|
+
effects,
|
|
70
|
+
deterministic,
|
|
71
|
+
doc,
|
|
72
|
+
implementation: `${ANIMATION_MODULE}.${name}`,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const ANIMATION_CAPABILITIES: readonly CapabilityDefinition[] = [
|
|
76
|
+
define(
|
|
77
|
+
'pose',
|
|
78
|
+
[{ name: 'jointCount', type: 'u32' }],
|
|
79
|
+
'Pose',
|
|
80
|
+
['pure'],
|
|
81
|
+
true,
|
|
82
|
+
'A new pose at rest. This is where a bind pose comes from; take one and keep it.',
|
|
83
|
+
),
|
|
84
|
+
define(
|
|
85
|
+
'toRest',
|
|
86
|
+
[
|
|
87
|
+
{ name: 'pose', type: 'Pose' },
|
|
88
|
+
{ name: 'jointCount', type: 'u32' },
|
|
89
|
+
],
|
|
90
|
+
'void',
|
|
91
|
+
['animation.write'],
|
|
92
|
+
false,
|
|
93
|
+
'Return a pose to rest without allocating another, which is what a state machine does on re-entry.',
|
|
94
|
+
),
|
|
95
|
+
define(
|
|
96
|
+
'sample',
|
|
97
|
+
[
|
|
98
|
+
{ name: 'clip', type: 'Clip' },
|
|
99
|
+
{ name: 'at', type: 'f32' },
|
|
100
|
+
{ name: 'into', type: 'Pose' },
|
|
101
|
+
],
|
|
102
|
+
'void',
|
|
103
|
+
['animation.write'],
|
|
104
|
+
false,
|
|
105
|
+
'Sample a clip at a time. A channel no track mentions is left as it was, so a rotation-only clip preserves the rest of the pose.',
|
|
106
|
+
),
|
|
107
|
+
define(
|
|
108
|
+
'blend',
|
|
109
|
+
[
|
|
110
|
+
{ name: 'a', type: 'Pose' },
|
|
111
|
+
{ name: 'b', type: 'Pose' },
|
|
112
|
+
{ name: 'amount', type: 'f32' },
|
|
113
|
+
{ name: 'into', type: 'Pose' },
|
|
114
|
+
],
|
|
115
|
+
'void',
|
|
116
|
+
['animation.write'],
|
|
117
|
+
false,
|
|
118
|
+
'Blend two poses into a third, clamped at the ends. Safe when `into` is also `a` or `b`. It interpolates every channel, so `into` decides what an untouched joint blends from.',
|
|
119
|
+
),
|
|
120
|
+
/*
|
|
121
|
+
* **A script drives a tree; it does not build one. And `blendTree` used to say otherwise.**
|
|
122
|
+
*
|
|
123
|
+
* That capability took a `Skeleton` and handed it to `new BlendTree(skeleton as never, …)`,
|
|
124
|
+
* whose first parameter is the *root node* of the graph. Run, it threw `Cannot read properties
|
|
125
|
+
* of undefined (reading 'length')` — measured 2026-08-28, not reasoned about — because `prepare`
|
|
126
|
+
* asked a skeleton for its children. Nothing caught it: `host.test.ts` asserted that the
|
|
127
|
+
* *compiler* required three arguments, which it did, and no test ever called the implementation.
|
|
128
|
+
* A capability nobody could use, with a message naming nothing a script author could act on.
|
|
129
|
+
*
|
|
130
|
+
* **Why building one is a design and not an oversight.** A tree is a graph of nodes, and a
|
|
131
|
+
* script has no node value: it would need a `clip` node, a `lerp`, a set with a list of stops
|
|
132
|
+
* and a weight per stop, and then a name for the whole shape. That is a surface to design, and
|
|
133
|
+
* `docs/IMPROVEMENTS.md` carries it with what it needs.
|
|
134
|
+
*
|
|
135
|
+
* **What works today is the shape DriftScript 1.8.1 opened.** A host builds the tree in
|
|
136
|
+
* TypeScript, where the skeleton and the clips already are, and hands it to a system through
|
|
137
|
+
* `uses`; the script then sets its parameters and evaluates it. So these two are the whole
|
|
138
|
+
* script-side surface of a blend tree, and they are enough for the case the language was given
|
|
139
|
+
* `uses` for.
|
|
140
|
+
*/
|
|
141
|
+
define(
|
|
142
|
+
'blendSet',
|
|
143
|
+
[
|
|
144
|
+
{ name: 'tree', type: 'Blend' },
|
|
145
|
+
{ name: 'parameter', type: 'String' },
|
|
146
|
+
{ name: 'value', type: 'f32' },
|
|
147
|
+
],
|
|
148
|
+
'void',
|
|
149
|
+
['animation.write'],
|
|
150
|
+
false,
|
|
151
|
+
"Set one of a tree's parameters. A name no node in that tree declares is refused by name, because a typo that silently did nothing would present as an animation that will not respond. Both kinds of parameter are set this way: a blend weight, and a clock — the time a `clip` node named for its own sampling.",
|
|
152
|
+
),
|
|
153
|
+
define(
|
|
154
|
+
'blendAt',
|
|
155
|
+
[
|
|
156
|
+
{ name: 'tree', type: 'Blend' },
|
|
157
|
+
{ name: 'at', type: 'f32' },
|
|
158
|
+
{ name: 'into', type: 'Pose' },
|
|
159
|
+
],
|
|
160
|
+
'void',
|
|
161
|
+
['animation.write'],
|
|
162
|
+
false,
|
|
163
|
+
'Evaluate a whole tree into a pose. `at` is the clock for every node that did not name one of its own, so a tree of ordinary clips is sampled at one time and a node with a clock reads that parameter instead.',
|
|
164
|
+
),
|
|
165
|
+
define(
|
|
166
|
+
'reach',
|
|
167
|
+
[
|
|
168
|
+
{ name: 'skeleton', type: 'Skeleton' },
|
|
169
|
+
{ name: 'pose', type: 'Pose' },
|
|
170
|
+
{ name: 'root', type: 'u32' },
|
|
171
|
+
{ name: 'mid', type: 'u32' },
|
|
172
|
+
{ name: 'tip', type: 'u32' },
|
|
173
|
+
{ name: 'targetX', type: 'f32' },
|
|
174
|
+
{ name: 'targetY', type: 'f32' },
|
|
175
|
+
{ name: 'targetZ', type: 'f32' },
|
|
176
|
+
/*
|
|
177
|
+
* The pole hint decides the one thing a target cannot: which way the joint bends. Required
|
|
178
|
+
* for the same reason as the bind pose — a chain that is already straight has no plane to
|
|
179
|
+
* bend in, and without a hint the elbow flips unpredictably as a character turns. The engine
|
|
180
|
+
* requires it too; it is listed here so a reader of the signature knows what it is for.
|
|
181
|
+
*/
|
|
182
|
+
{ name: 'poleX', type: 'f32' },
|
|
183
|
+
{ name: 'poleY', type: 'f32' },
|
|
184
|
+
{ name: 'poleZ', type: 'f32' },
|
|
185
|
+
],
|
|
186
|
+
'bool',
|
|
187
|
+
['animation.write'],
|
|
188
|
+
false,
|
|
189
|
+
'Bend a two-bone chain so its tip reaches a target. Answers whether it was reachable; an unreachable target straightens the chain toward it rather than giving up.',
|
|
190
|
+
),
|
|
191
|
+
/*
|
|
192
|
+
* **Root motion is two calls and it is a mistake to make only one of them.** `rootMotion` hands
|
|
193
|
+
* the caller the displacement and `stripRoot` takes it out of the pose; a script that extracts
|
|
194
|
+
* without stripping moves its character twice, at exactly double speed, with nothing failing.
|
|
195
|
+
* Both descriptions say so, because a script author has no signature to read.
|
|
196
|
+
*
|
|
197
|
+
* The displacement is read back through separate accessors, the shape `hitX` and `input.axisX`
|
|
198
|
+
* already use — a script has no way to receive an engine record. Here the accessors take the
|
|
199
|
+
* `Motion` rather than reading a scratch the last call wrote, so two characters extracted in one
|
|
200
|
+
* frame do not clobber each other; the raycast surface keys its scratch on the world for the
|
|
201
|
+
* same reason, and animation has no equivalent handle to key on.
|
|
202
|
+
*/
|
|
203
|
+
define(
|
|
204
|
+
'motion',
|
|
205
|
+
[],
|
|
206
|
+
'Motion',
|
|
207
|
+
['pure'],
|
|
208
|
+
true,
|
|
209
|
+
'A new root displacement, at zero. Take one and keep it; nothing here allocates per frame.',
|
|
210
|
+
),
|
|
211
|
+
define(
|
|
212
|
+
'rootMotion',
|
|
213
|
+
[
|
|
214
|
+
{ name: 'clip', type: 'Clip' },
|
|
215
|
+
{ name: 'joint', type: 'u32' },
|
|
216
|
+
{ name: 'from', type: 'f32' },
|
|
217
|
+
{ name: 'to', type: 'f32' },
|
|
218
|
+
{ name: 'into', type: 'Motion' },
|
|
219
|
+
],
|
|
220
|
+
'void',
|
|
221
|
+
['animation.write'],
|
|
222
|
+
false,
|
|
223
|
+
'How far the root travelled between two times, in its own frame at `from`. Loops are accumulated, so a clip that wraps between the two times still answers a step forward. Pair every call with `stripRoot` or the character moves twice.',
|
|
224
|
+
),
|
|
225
|
+
define(
|
|
226
|
+
'stripRoot',
|
|
227
|
+
[
|
|
228
|
+
{ name: 'clip', type: 'Clip' },
|
|
229
|
+
{ name: 'joint', type: 'u32' },
|
|
230
|
+
{ name: 'pose', type: 'Pose' },
|
|
231
|
+
],
|
|
232
|
+
'void',
|
|
233
|
+
['animation.write'],
|
|
234
|
+
false,
|
|
235
|
+
"Pin a sampled pose's root to the clip's value at time zero, so the displacement `rootMotion` handed you is not also in the pose. Every other joint is left as it was.",
|
|
236
|
+
),
|
|
237
|
+
define(
|
|
238
|
+
'motionX',
|
|
239
|
+
[{ name: 'motion', type: 'Motion' }],
|
|
240
|
+
'f32',
|
|
241
|
+
['pure'],
|
|
242
|
+
true,
|
|
243
|
+
"The displacement along the root's own x axis at the earlier time.",
|
|
244
|
+
),
|
|
245
|
+
define(
|
|
246
|
+
'motionY',
|
|
247
|
+
[{ name: 'motion', type: 'Motion' }],
|
|
248
|
+
'f32',
|
|
249
|
+
['pure'],
|
|
250
|
+
true,
|
|
251
|
+
"The displacement along the root's own y axis at the earlier time.",
|
|
252
|
+
),
|
|
253
|
+
define(
|
|
254
|
+
'motionZ',
|
|
255
|
+
[{ name: 'motion', type: 'Motion' }],
|
|
256
|
+
'f32',
|
|
257
|
+
['pure'],
|
|
258
|
+
true,
|
|
259
|
+
"The displacement along the root's own z axis at the earlier time.",
|
|
260
|
+
),
|
|
261
|
+
define(
|
|
262
|
+
'motionTurnX',
|
|
263
|
+
[{ name: 'motion', type: 'Motion' }],
|
|
264
|
+
'f32',
|
|
265
|
+
['pure'],
|
|
266
|
+
true,
|
|
267
|
+
'The x part of the rotation the root turned through, as a quaternion.',
|
|
268
|
+
),
|
|
269
|
+
define(
|
|
270
|
+
'motionTurnY',
|
|
271
|
+
[{ name: 'motion', type: 'Motion' }],
|
|
272
|
+
'f32',
|
|
273
|
+
['pure'],
|
|
274
|
+
true,
|
|
275
|
+
'The y part of the rotation the root turned through, as a quaternion.',
|
|
276
|
+
),
|
|
277
|
+
define(
|
|
278
|
+
'motionTurnZ',
|
|
279
|
+
[{ name: 'motion', type: 'Motion' }],
|
|
280
|
+
'f32',
|
|
281
|
+
['pure'],
|
|
282
|
+
true,
|
|
283
|
+
'The z part of the rotation the root turned through, as a quaternion.',
|
|
284
|
+
),
|
|
285
|
+
define(
|
|
286
|
+
'motionTurnW',
|
|
287
|
+
[{ name: 'motion', type: 'Motion' }],
|
|
288
|
+
'f32',
|
|
289
|
+
['pure'],
|
|
290
|
+
true,
|
|
291
|
+
'The w part of the rotation the root turned through, as a quaternion. One means it did not turn.',
|
|
292
|
+
),
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The implementations.
|
|
297
|
+
*
|
|
298
|
+
* `blendTree` passes the bind pose straight through to the engine's optional parameter — the
|
|
299
|
+
* requirement lives entirely in the *description*, which is exactly where a capability boundary
|
|
300
|
+
* should hold a rule. The engine's own callers keep their option; script authors get the safe
|
|
301
|
+
* behaviour without the engine changing.
|
|
302
|
+
*/
|
|
303
|
+
export function animationImplementation(): Record<string, unknown> {
|
|
304
|
+
return {
|
|
305
|
+
pose: (jointCount: number) => createPose(jointCount),
|
|
306
|
+
toRest: (pose: Pose, jointCount: number) => restPose(jointCount, pose),
|
|
307
|
+
sample: (clip: AnimationClip, at: number, into: Pose) => sampleClip(clip, at, into),
|
|
308
|
+
blend: (a: Pose, b: Pose, amount: number, into: Pose) => blendPoses(a, b, amount, into),
|
|
309
|
+
blendSet: (tree: BlendTree, parameter: string, value: number) => tree.set(parameter, value),
|
|
310
|
+
blendAt: (tree: BlendTree, at: number, into: Pose) => tree.evaluate(at, into),
|
|
311
|
+
motion: () => createRootMotion(),
|
|
312
|
+
rootMotion: (clip: AnimationClip, joint: number, from: number, to: number, into: RootMotion) =>
|
|
313
|
+
extractRootMotion(clip, joint, from, to, into),
|
|
314
|
+
stripRoot: (clip: AnimationClip, joint: number, pose: Pose) =>
|
|
315
|
+
stripRootMotion(clip, joint, pose),
|
|
316
|
+
motionX: (motion: RootMotion) => motion.translation[0] as number,
|
|
317
|
+
motionY: (motion: RootMotion) => motion.translation[1] as number,
|
|
318
|
+
motionZ: (motion: RootMotion) => motion.translation[2] as number,
|
|
319
|
+
motionTurnX: (motion: RootMotion) => motion.rotation[0] as number,
|
|
320
|
+
motionTurnY: (motion: RootMotion) => motion.rotation[1] as number,
|
|
321
|
+
motionTurnZ: (motion: RootMotion) => motion.rotation[2] as number,
|
|
322
|
+
motionTurnW: (motion: RootMotion) => motion.rotation[3] as number,
|
|
323
|
+
reach: (
|
|
324
|
+
skeleton: Skeleton,
|
|
325
|
+
pose: Pose,
|
|
326
|
+
root: number,
|
|
327
|
+
mid: number,
|
|
328
|
+
tip: number,
|
|
329
|
+
targetX: number,
|
|
330
|
+
targetY: number,
|
|
331
|
+
targetZ: number,
|
|
332
|
+
poleX: number,
|
|
333
|
+
poleY: number,
|
|
334
|
+
poleZ: number,
|
|
335
|
+
) =>
|
|
336
|
+
solveTwoBone(
|
|
337
|
+
skeleton,
|
|
338
|
+
pose,
|
|
339
|
+
root,
|
|
340
|
+
mid,
|
|
341
|
+
tip,
|
|
342
|
+
[targetX, targetY, targetZ],
|
|
343
|
+
[poleX, poleY, poleZ],
|
|
344
|
+
),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
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, distanceGain, stereoPan } from '@driftengine/audio';
|
|
37
|
+
import { type CapabilityDefinition, type OpaqueType, defineCapability } from 'driftscript';
|
|
38
|
+
|
|
39
|
+
export const AUDIO_MODULE = 'drift/audio';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A decoded sound, held by a script and passed back to the engine.
|
|
43
|
+
*
|
|
44
|
+
* Opaque on purpose: it is an `AudioBuffer`, and a script that could read its fields would be a
|
|
45
|
+
* script depending on a representation the engine cannot then change. The design calls these safe
|
|
46
|
+
* handles, and this is one.
|
|
47
|
+
*/
|
|
48
|
+
export const AUDIO_TYPES: readonly OpaqueType[] = [
|
|
49
|
+
{
|
|
50
|
+
module: AUDIO_MODULE,
|
|
51
|
+
name: 'Sound',
|
|
52
|
+
doc: 'A decoded sound, resolved from a slot. Held and passed back; never read into.',
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const define = (
|
|
57
|
+
name: string,
|
|
58
|
+
params: readonly { name: string; type: string }[],
|
|
59
|
+
returns: string,
|
|
60
|
+
effects: CapabilityDefinition['effects'],
|
|
61
|
+
deterministic: boolean,
|
|
62
|
+
doc: string,
|
|
63
|
+
implementation: string,
|
|
64
|
+
): CapabilityDefinition =>
|
|
65
|
+
defineCapability({
|
|
66
|
+
module: AUDIO_MODULE,
|
|
67
|
+
name,
|
|
68
|
+
signature: `fn(${params.map((p) => `${p.name}: ${p.type}`).join(', ')}) -> ${returns}`,
|
|
69
|
+
params: [...params],
|
|
70
|
+
returns,
|
|
71
|
+
effects,
|
|
72
|
+
deterministic,
|
|
73
|
+
doc,
|
|
74
|
+
implementation,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const AUDIO_CAPABILITIES: readonly CapabilityDefinition[] = [
|
|
78
|
+
define(
|
|
79
|
+
'sound',
|
|
80
|
+
[{ name: 'slot', type: 'String' }],
|
|
81
|
+
/*
|
|
82
|
+
* `Sound?`, and the `?` is the whole point.
|
|
83
|
+
*
|
|
84
|
+
* The registry resolves a slot by probing format candidates, and a missing file is ordinary
|
|
85
|
+
* rather than exceptional. Returning a bare `Sound` would make every script that forgot to
|
|
86
|
+
* check play silence and report success.
|
|
87
|
+
*/
|
|
88
|
+
'Sound?',
|
|
89
|
+
['pure'],
|
|
90
|
+
true,
|
|
91
|
+
'Resolve a sound slot. Absent when nothing was registered or nothing decoded.',
|
|
92
|
+
'SoundRegistry.get',
|
|
93
|
+
),
|
|
94
|
+
define(
|
|
95
|
+
'play',
|
|
96
|
+
[
|
|
97
|
+
{ name: 'sound', type: 'Sound' },
|
|
98
|
+
{ name: 'gain', type: 'f32' },
|
|
99
|
+
],
|
|
100
|
+
'void',
|
|
101
|
+
['audio.write'],
|
|
102
|
+
false,
|
|
103
|
+
'Play a resolved sound through the mix.',
|
|
104
|
+
'AudioGraph.play',
|
|
105
|
+
),
|
|
106
|
+
define(
|
|
107
|
+
'playPanned',
|
|
108
|
+
[
|
|
109
|
+
{ name: 'sound', type: 'Sound' },
|
|
110
|
+
{ name: 'gain', type: 'f32' },
|
|
111
|
+
{ name: 'pan', type: 'f32' },
|
|
112
|
+
],
|
|
113
|
+
'void',
|
|
114
|
+
['audio.write'],
|
|
115
|
+
false,
|
|
116
|
+
'Play a resolved sound at a stereo position, -1 left to 1 right.',
|
|
117
|
+
'AudioGraph.play',
|
|
118
|
+
),
|
|
119
|
+
define(
|
|
120
|
+
'distanceGain',
|
|
121
|
+
[
|
|
122
|
+
{ name: 'distance', type: 'f32' },
|
|
123
|
+
{ name: 'radius', type: 'f32' },
|
|
124
|
+
],
|
|
125
|
+
'f32',
|
|
126
|
+
/*
|
|
127
|
+
* `pure`, and therefore callable from a `@deterministic` function.
|
|
128
|
+
*
|
|
129
|
+
* It is arithmetic over two numbers — the cheap positional function, not the HRTF path. That a
|
|
130
|
+
* capability in an otherwise effectful module is deterministic is the reason effects are
|
|
131
|
+
* declared per capability rather than per module.
|
|
132
|
+
*/
|
|
133
|
+
['pure'],
|
|
134
|
+
true,
|
|
135
|
+
'How loud something is at a distance, falling off to nothing at the radius.',
|
|
136
|
+
'audio.distanceGain',
|
|
137
|
+
),
|
|
138
|
+
define(
|
|
139
|
+
'stereoPan',
|
|
140
|
+
[
|
|
141
|
+
{ name: 'dx', type: 'f32' },
|
|
142
|
+
{ name: 'dz', type: 'f32' },
|
|
143
|
+
{ name: 'yaw', type: 'f32' },
|
|
144
|
+
],
|
|
145
|
+
'f32',
|
|
146
|
+
['pure'],
|
|
147
|
+
true,
|
|
148
|
+
'Where something sits in the stereo field, relative to a listener facing yaw.',
|
|
149
|
+
'audio.stereoPan',
|
|
150
|
+
),
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/** How the runtime represents an option. Matches what the compiler generates for `some`/`none`. */
|
|
154
|
+
type Option<T> = { readonly tag: 'some'; readonly value: T } | { readonly tag: 'none' };
|
|
155
|
+
|
|
156
|
+
const some = <T>(value: T): Option<T> => ({ tag: 'some', value });
|
|
157
|
+
const none: Option<never> = { tag: 'none' };
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The implementations, which the host hands to a module's `__bind`.
|
|
161
|
+
*
|
|
162
|
+
* A plain object rather than anything the registry holds — R2: the registry describes and never
|
|
163
|
+
* becomes the call path, so this is a separate thing that happens to have matching keys. The
|
|
164
|
+
* registry's `implementation` strings name these; nothing enforces that mechanically, which is why
|
|
165
|
+
* a test asserts every definition has an implementation here.
|
|
166
|
+
*/
|
|
167
|
+
export function audioImplementation(
|
|
168
|
+
graph: AudioGraph,
|
|
169
|
+
registry: SoundRegistry,
|
|
170
|
+
): Record<string, unknown> {
|
|
171
|
+
return {
|
|
172
|
+
sound(slot: string): Option<AudioBuffer> {
|
|
173
|
+
const buffer = registry.get(slot);
|
|
174
|
+
return buffer === undefined ? none : some(buffer);
|
|
175
|
+
},
|
|
176
|
+
play(sound: AudioBuffer, gain: number): void {
|
|
177
|
+
graph.play(sound, gain);
|
|
178
|
+
},
|
|
179
|
+
playPanned(sound: AudioBuffer, gain: number, pan: number): void {
|
|
180
|
+
graph.play(sound, gain, pan);
|
|
181
|
+
},
|
|
182
|
+
distanceGain,
|
|
183
|
+
stereoPan,
|
|
184
|
+
};
|
|
185
|
+
}
|