@energy8platform/game-engine 0.33.2 → 0.33.4

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.
@@ -0,0 +1,770 @@
1
+ 'use strict';
2
+
3
+ var pixi_js = require('pixi.js');
4
+
5
+ // Flow step registry — the same contribution shape as scene node types (§6.2): kind +
6
+ // runtime executor + schema/agentDoc for tooling. Core steps are built-ins registered
7
+ // through it; games and plugins add their own `do` kinds without touching the runner.
8
+ function createFlowStepRegistry(plugins = [], builtins = []) {
9
+ const steps = new Map();
10
+ for (const contribution of builtins)
11
+ steps.set(contribution.kind, contribution);
12
+ for (const plugin of plugins) {
13
+ for (const contribution of plugin.steps ?? [])
14
+ steps.set(contribution.kind, contribution);
15
+ }
16
+ return {
17
+ step: (kind) => steps.get(kind),
18
+ kinds: () => [...steps.keys()],
19
+ };
20
+ }
21
+
22
+ // Pure layout core for scene-IR.
23
+ //
24
+ // `resolveLayoutRule` turns one LayoutRule into a Placement given a context of already
25
+ // laid-out rects. It is renderer-free and side-effect-free so every positioning idiom the
26
+ // games hand-rolled (viewport fractions, frame-hollow binding, cell badges, pins) is unit
27
+ // tested without Pixi. The interpreter (engine.ts) owns pass ordering; this module owns math.
28
+ /**
29
+ * Evaluate a `visibleWhen` micro-expression: `<var> <op> <literal>` with
30
+ * `=== !== >= <= > <`. Unknown shapes evaluate to visible (with the engine warning once) —
31
+ * richer conditions belong in flow or code, not in the scene doc.
32
+ */
33
+ function evalVisibleWhen(expr, vars) {
34
+ const m = /^\s*([A-Za-z_$][\w$]*)\s*(===|!==|>=|<=|>|<)\s*(.+?)\s*$/.exec(expr);
35
+ if (!m)
36
+ return undefined;
37
+ const [, name, op, rawLit] = m;
38
+ let lit;
39
+ try {
40
+ lit = JSON.parse(rawLit.replace(/^'(.*)'$/s, '"$1"'));
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ const value = vars[name];
46
+ switch (op) {
47
+ case '===':
48
+ return value === lit;
49
+ case '!==':
50
+ return value !== lit;
51
+ case '>=':
52
+ return Number(value) >= Number(lit);
53
+ case '<=':
54
+ return Number(value) <= Number(lit);
55
+ case '>':
56
+ return Number(value) > Number(lit);
57
+ case '<':
58
+ return Number(value) < Number(lit);
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ /**
64
+ * Collection of easing functions for use with Tween and Timeline.
65
+ *
66
+ * All functions take a progress value t (0..1) and return the eased value.
67
+ */
68
+ const Easing = {
69
+ linear: (t) => t,
70
+ easeInQuad: (t) => t * t,
71
+ easeOutQuad: (t) => t * (2 - t),
72
+ easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
73
+ easeInCubic: (t) => t * t * t,
74
+ easeOutCubic: (t) => --t * t * t + 1,
75
+ easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
76
+ easeInQuart: (t) => t * t * t * t,
77
+ easeOutQuart: (t) => 1 - --t * t * t * t,
78
+ easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
79
+ easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
80
+ easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
81
+ easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
82
+ easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
83
+ easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
84
+ easeInOutExpo: (t) => t === 0
85
+ ? 0
86
+ : t === 1
87
+ ? 1
88
+ : t < 0.5
89
+ ? Math.pow(2, 20 * t - 10) / 2
90
+ : (2 - Math.pow(2, -20 * t + 10)) / 2,
91
+ easeInBack: (t) => {
92
+ const c1 = 1.70158;
93
+ const c3 = c1 + 1;
94
+ return c3 * t * t * t - c1 * t * t;
95
+ },
96
+ easeOutBack: (t) => {
97
+ const c1 = 1.70158;
98
+ const c3 = c1 + 1;
99
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
100
+ },
101
+ easeInOutBack: (t) => {
102
+ const c1 = 1.70158;
103
+ const c2 = c1 * 1.525;
104
+ return t < 0.5
105
+ ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
106
+ : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
107
+ },
108
+ easeOutBounce: (t) => {
109
+ const n1 = 7.5625;
110
+ const d1 = 2.75;
111
+ if (t < 1 / d1)
112
+ return n1 * t * t;
113
+ if (t < 2 / d1)
114
+ return n1 * (t -= 1.5 / d1) * t + 0.75;
115
+ if (t < 2.5 / d1)
116
+ return n1 * (t -= 2.25 / d1) * t + 0.9375;
117
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
118
+ },
119
+ easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
120
+ easeInOutBounce: (t) => t < 0.5
121
+ ? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
122
+ : (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
123
+ easeOutElastic: (t) => {
124
+ const c4 = (2 * Math.PI) / 3;
125
+ return t === 0
126
+ ? 0
127
+ : t === 1
128
+ ? 1
129
+ : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
130
+ },
131
+ easeInElastic: (t) => {
132
+ const c4 = (2 * Math.PI) / 3;
133
+ return t === 0
134
+ ? 0
135
+ : t === 1
136
+ ? 1
137
+ : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
138
+ },
139
+ };
140
+
141
+ /**
142
+ * Lightweight tween system integrated with PixiJS Ticker.
143
+ * Zero external dependencies — no GSAP required.
144
+ *
145
+ * All tweens return a Promise that resolves on completion.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * // Fade in a sprite
150
+ * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
151
+ *
152
+ * // Move and wait
153
+ * await Tween.to(sprite, { x: 500 }, 300);
154
+ *
155
+ * // From a starting value
156
+ * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
157
+ * ```
158
+ */
159
+ class Tween {
160
+ static _tweens = [];
161
+ static _tickerAdded = false;
162
+ /**
163
+ * Animate properties from current values to target values.
164
+ *
165
+ * @param target - Object to animate (Sprite, Container, etc.)
166
+ * @param props - Target property values
167
+ * @param duration - Duration in milliseconds
168
+ * @param easing - Easing function (default: easeOutQuad)
169
+ * @param onUpdate - Progress callback (0..1)
170
+ */
171
+ static to(target, props, duration, easing, onUpdate) {
172
+ // A destroyed (Pixi) target has null transform fields — skip rather than throw. This guards
173
+ // animations whose target is torn down mid-flight (e.g. a reel grid rebuilt during a spin).
174
+ if (target == null || target.destroyed)
175
+ return Promise.resolve();
176
+ return new Promise((resolve) => {
177
+ // Capture starting values
178
+ const from = {};
179
+ for (const key of Object.keys(props)) {
180
+ from[key] = Tween.getProperty(target, key);
181
+ }
182
+ const tween = {
183
+ target,
184
+ from,
185
+ to: { ...props },
186
+ duration: Math.max(1, duration),
187
+ easing: easing ?? Easing.easeOutQuad,
188
+ elapsed: 0,
189
+ delay: 0,
190
+ resolve,
191
+ onUpdate,
192
+ };
193
+ Tween._tweens.push(tween);
194
+ Tween.ensureTicker();
195
+ });
196
+ }
197
+ /**
198
+ * Animate properties from given values to current values.
199
+ */
200
+ static from(target, props, duration, easing, onUpdate) {
201
+ if (target == null || target.destroyed)
202
+ return Promise.resolve();
203
+ // Capture current values as "to"
204
+ const to = {};
205
+ for (const key of Object.keys(props)) {
206
+ to[key] = Tween.getProperty(target, key);
207
+ Tween.setProperty(target, key, props[key]);
208
+ }
209
+ return Tween.to(target, to, duration, easing, onUpdate);
210
+ }
211
+ /**
212
+ * Animate from one set of values to another.
213
+ */
214
+ static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
215
+ if (target == null || target.destroyed)
216
+ return Promise.resolve();
217
+ // Set starting values
218
+ for (const key of Object.keys(fromProps)) {
219
+ Tween.setProperty(target, key, fromProps[key]);
220
+ }
221
+ return Tween.to(target, toProps, duration, easing, onUpdate);
222
+ }
223
+ /**
224
+ * Wait for a given duration (useful in timelines).
225
+ * Uses PixiJS Ticker for consistent timing with other tweens.
226
+ */
227
+ static delay(ms) {
228
+ return new Promise((resolve) => {
229
+ let elapsed = 0;
230
+ const onTick = (ticker) => {
231
+ elapsed += ticker.deltaMS;
232
+ if (elapsed >= ms) {
233
+ pixi_js.Ticker.shared.remove(onTick);
234
+ resolve();
235
+ }
236
+ };
237
+ pixi_js.Ticker.shared.add(onTick);
238
+ });
239
+ }
240
+ /**
241
+ * Kill all tweens on a target.
242
+ */
243
+ static killTweensOf(target) {
244
+ Tween._tweens = Tween._tweens.filter((tw) => {
245
+ if (tw.target === target) {
246
+ tw.resolve();
247
+ return false;
248
+ }
249
+ return true;
250
+ });
251
+ }
252
+ /**
253
+ * Kill all active tweens.
254
+ */
255
+ static killAll() {
256
+ for (const tw of Tween._tweens) {
257
+ tw.resolve();
258
+ }
259
+ Tween._tweens.length = 0;
260
+ }
261
+ /** Number of active tweens */
262
+ static get activeTweens() {
263
+ return Tween._tweens.length;
264
+ }
265
+ /**
266
+ * Reset the tween system — kill all tweens and remove the ticker.
267
+ * Useful for cleanup between game instances, tests, or hot-reload.
268
+ */
269
+ static reset() {
270
+ for (const tw of Tween._tweens) {
271
+ tw.resolve();
272
+ }
273
+ Tween._tweens.length = 0;
274
+ if (Tween._tickerAdded) {
275
+ pixi_js.Ticker.shared.remove(Tween.tick);
276
+ Tween._tickerAdded = false;
277
+ }
278
+ }
279
+ // ─── Internal ──────────────────────────────────────────
280
+ static ensureTicker() {
281
+ if (Tween._tickerAdded)
282
+ return;
283
+ Tween._tickerAdded = true;
284
+ pixi_js.Ticker.shared.add(Tween.tick);
285
+ }
286
+ static tick = (ticker) => {
287
+ const dt = ticker.deltaMS;
288
+ const completed = [];
289
+ for (const tw of Tween._tweens) {
290
+ // target torn down mid-tween → finish it quietly
291
+ if (tw.target?.destroyed) {
292
+ completed.push(tw);
293
+ continue;
294
+ }
295
+ tw.elapsed += dt;
296
+ if (tw.elapsed < tw.delay)
297
+ continue;
298
+ const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
299
+ const t = tw.easing(raw);
300
+ // Interpolate each property
301
+ for (const key of Object.keys(tw.to)) {
302
+ const start = tw.from[key];
303
+ const end = tw.to[key];
304
+ const value = start + (end - start) * t;
305
+ Tween.setProperty(tw.target, key, value);
306
+ }
307
+ tw.onUpdate?.(raw);
308
+ if (raw >= 1) {
309
+ completed.push(tw);
310
+ }
311
+ }
312
+ // Remove completed tweens
313
+ for (const tw of completed) {
314
+ const idx = Tween._tweens.indexOf(tw);
315
+ if (idx !== -1)
316
+ Tween._tweens.splice(idx, 1);
317
+ tw.resolve();
318
+ }
319
+ // Remove ticker when no active tweens
320
+ if (Tween._tweens.length === 0 && Tween._tickerAdded) {
321
+ pixi_js.Ticker.shared.remove(Tween.tick);
322
+ Tween._tickerAdded = false;
323
+ }
324
+ };
325
+ /**
326
+ * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
327
+ */
328
+ static getProperty(target, key) {
329
+ const parts = key.split('.');
330
+ let obj = target;
331
+ for (let i = 0; i < parts.length - 1; i++) {
332
+ obj = obj?.[parts[i]];
333
+ }
334
+ return obj?.[parts[parts.length - 1]] ?? 0;
335
+ }
336
+ /**
337
+ * Set a potentially nested property.
338
+ */
339
+ static setProperty(target, key, value) {
340
+ const parts = key.split('.');
341
+ let obj = target;
342
+ for (let i = 0; i < parts.length - 1; i++) {
343
+ obj = obj?.[parts[i]];
344
+ }
345
+ if (obj == null)
346
+ return;
347
+ obj[parts[parts.length - 1]] = value;
348
+ }
349
+ }
350
+
351
+ // packages/game-engine/src/slot/anim/easing-map.ts
352
+ /** Resolve a descriptor's string easing name to the engine's easing function. */
353
+ const EASING_BY_NAME = {
354
+ linear: Easing.linear,
355
+ easeInQuad: Easing.easeInQuad,
356
+ easeOutQuad: Easing.easeOutQuad,
357
+ easeInOutQuad: Easing.easeInOutQuad,
358
+ easeInCubic: Easing.easeInCubic,
359
+ easeOutCubic: Easing.easeOutCubic,
360
+ easeInOutCubic: Easing.easeInOutCubic,
361
+ easeInBack: Easing.easeInBack,
362
+ easeOutBack: Easing.easeOutBack,
363
+ easeInOutBack: Easing.easeInOutBack,
364
+ easeOutBounce: Easing.easeOutBounce,
365
+ easeInBounce: Easing.easeInBounce,
366
+ easeOutElastic: Easing.easeOutElastic,
367
+ easeInSine: Easing.easeInSine,
368
+ easeOutSine: Easing.easeOutSine,
369
+ easeInOutSine: Easing.easeInOutSine,
370
+ };
371
+ /** Resolve an easing name to a function, falling back to easeOutQuad. */
372
+ function easingByName(name) {
373
+ return (name && EASING_BY_NAME[name]) || Easing.easeOutQuad;
374
+ }
375
+
376
+ // Built-in flow steps. Presentation writes (tween/setProps/countUp) target live
377
+ // instances/views — never the scene doc (the doc stays the persistable SSOT); runtime
378
+ // state changes (setVar/setState) go through the scene handle like any agent patch.
379
+ const num = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
380
+ function formatValue(value, format) {
381
+ const rounded = Math.round(value);
382
+ if (format === 'space')
383
+ return String(rounded).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
384
+ return String(rounded);
385
+ }
386
+ const tweenStep = {
387
+ kind: 'tween',
388
+ agentDoc: 'Animate numeric view properties of a scene node (alpha, x, y, rotation; scale animates both axes). Transient — the doc is untouched, and any relayout (setVar/setState/resize) re-asserts layout-owned props (x/y/scale/rotation) from the doc. Persist a move via a set-layout patch, not a tween. Turbo divides ms; skip jumps to the end values.',
389
+ async run(raw, rt) {
390
+ const step = raw;
391
+ const view = rt.view(step.node);
392
+ if (!view)
393
+ return rt.log(`tween: unknown node "${step.node}"`);
394
+ const to = {};
395
+ for (const [key, value] of Object.entries(step.to))
396
+ to[key] = num(rt.resolve(value), 0);
397
+ const applyFinal = () => {
398
+ for (const [key, value] of Object.entries(to)) {
399
+ if (key === 'scale')
400
+ view.scale.set(value);
401
+ else
402
+ view[key] = value;
403
+ }
404
+ };
405
+ if (rt.skipped)
406
+ return applyFinal();
407
+ const { scale, ...rest } = to;
408
+ const jobs = [];
409
+ if (Object.keys(rest).length > 0) {
410
+ jobs.push(Tween.to(view, rest, step.ms / rt.turbo, easingByName(step.ease)));
411
+ }
412
+ if (scale !== undefined) {
413
+ jobs.push(Tween.to(view.scale, { x: scale, y: scale }, step.ms / rt.turbo, easingByName(step.ease)));
414
+ }
415
+ // A skip during flight must land on the end state: race the tween against skip-release.
416
+ await Promise.race([Promise.all(jobs), rt.wait(step.ms + 50)]);
417
+ if (rt.skipped) {
418
+ Tween.killTweensOf(view);
419
+ Tween.killTweensOf(view.scale);
420
+ applyFinal();
421
+ }
422
+ },
423
+ };
424
+ const soundStep = {
425
+ kind: 'sound',
426
+ agentDoc: 'Play/stop a cue from flow.cues (rotation/jitter handled by the runner). Never reference audio files directly.',
427
+ run(raw, rt) {
428
+ const step = raw;
429
+ rt.playCue(step.cue, step.action ?? 'play');
430
+ },
431
+ };
432
+ const setStateStep = {
433
+ kind: 'setState',
434
+ agentDoc: "Switch a node's named state (frame anticipation/bonus look). Same operation the inspector and agent use.",
435
+ run(raw, rt) {
436
+ const step = raw;
437
+ rt.scene.setState(step.node, step.state);
438
+ },
439
+ };
440
+ const setVarStep = {
441
+ kind: 'setVar',
442
+ agentDoc: "Set a runtime var driving visibleWhen (e.g. mode). Use for mode transitions ('setVar mode free_spins').",
443
+ run(raw, rt) {
444
+ const step = raw;
445
+ rt.scene.setVar(step.name, rt.resolve(step.value));
446
+ },
447
+ };
448
+ const setPropsStep = {
449
+ kind: 'setProps',
450
+ agentDoc: 'Transient prop write on the live instance (badge values, board swaps during presentation). The scene doc is NOT modified.',
451
+ run(raw, rt) {
452
+ const step = raw;
453
+ const instance = rt.scene.instance(step.node);
454
+ if (!instance?.applyProps)
455
+ return rt.log(`setProps: node "${step.node}" has no applyProps`);
456
+ const props = {};
457
+ for (const [key, value] of Object.entries(step.props))
458
+ props[key] = rt.resolve(value);
459
+ const node = findDocNode(rt, step.node);
460
+ instance.applyProps({ ...(node?.props ?? {}), ...props });
461
+ },
462
+ };
463
+ function findDocNode(rt, id) {
464
+ const walk = (n) => {
465
+ if (n.id === id)
466
+ return n;
467
+ for (const child of n.children ?? []) {
468
+ const hit = walk(child);
469
+ if (hit)
470
+ return hit;
471
+ }
472
+ return undefined;
473
+ };
474
+ return walk(rt.scene.doc().root);
475
+ }
476
+ const countUpStep = {
477
+ kind: 'countUp',
478
+ agentDoc: "Animated number roll on an instance prop (default 'value' — badge prefabs). `to` may be '$win' (ctx ref). Turbo shortens, skip jumps to the final value.",
479
+ async run(raw, rt) {
480
+ const step = raw;
481
+ const instance = rt.scene.instance(step.node);
482
+ if (!instance?.applyProps)
483
+ return rt.log(`countUp: node "${step.node}" has no applyProps`);
484
+ const node = findDocNode(rt, step.node);
485
+ const prop = step.prop ?? 'value';
486
+ const from = num(rt.resolve(step.from), 0);
487
+ const to = num(rt.resolve(step.to), 0);
488
+ const ms = num(step.ms, 1000) / rt.turbo;
489
+ const apply = (value) => instance.applyProps({ ...(node?.props ?? {}), [prop]: formatValue(value, step.format) });
490
+ if (rt.skipped || ms <= 0)
491
+ return apply(to);
492
+ const start = performance.now();
493
+ while (!rt.skipped) {
494
+ const k = Math.min(1, (performance.now() - start) / ms);
495
+ apply(from + (to - from) * (1 - Math.pow(1 - k, 2)));
496
+ if (k >= 1)
497
+ return;
498
+ await rt.wait(16);
499
+ }
500
+ apply(to);
501
+ },
502
+ };
503
+ const waitStep = {
504
+ kind: 'wait',
505
+ agentDoc: "Pause: {ms} (turbo-scaled) or {until:'tap'} (released by tap or skip). Prefer explicit waits over baking delays into tweens.",
506
+ async run(raw, rt) {
507
+ const step = raw;
508
+ if (step.until === 'tap')
509
+ return rt.waitTap();
510
+ return rt.wait(num(step.ms, 0));
511
+ },
512
+ };
513
+ const ifStep = {
514
+ kind: 'if',
515
+ agentDoc: "Branch on the fire() ctx: {when:'win >= 100', then:[…], else:[…]}. Bare name = truthy check.",
516
+ async run(raw, rt) {
517
+ const step = raw;
518
+ await rt.run(rt.when(step.when) ? step.then : (step.else ?? []));
519
+ },
520
+ };
521
+ const parallelStep = {
522
+ kind: 'parallel',
523
+ agentDoc: 'Run tracks concurrently and await them all: {steps: [[…], […]]}. Each track is an independent sequence.',
524
+ async run(raw, rt) {
525
+ const step = raw;
526
+ await Promise.all(step.steps.map((track) => rt.run(track)));
527
+ },
528
+ };
529
+ const seqStep = {
530
+ kind: 'seq',
531
+ agentDoc: 'Nested sequence (grouping inside parallel tracks).',
532
+ async run(raw, rt) {
533
+ const step = raw;
534
+ await rt.run(step.steps);
535
+ },
536
+ };
537
+ const codeStep = {
538
+ kind: 'code',
539
+ agentDoc: 'Escape hatch: named choreography registered in createFlowRunner({code}). Use when the step vocabulary genuinely cannot express it — then consider a plugin step.',
540
+ async run(raw, rt) {
541
+ const step = raw;
542
+ const handler = rt.codeRef(step.ref);
543
+ if (!handler)
544
+ return rt.log(`code: unknown ref "${step.ref}"`);
545
+ await handler(rt, step.args ?? {});
546
+ },
547
+ };
548
+ const BUILTIN_FLOW_STEPS = [
549
+ tweenStep,
550
+ soundStep,
551
+ setStateStep,
552
+ setVarStep,
553
+ setPropsStep,
554
+ countUpStep,
555
+ waitStep,
556
+ ifStep,
557
+ parallelStep,
558
+ seqStep,
559
+ codeStep,
560
+ ];
561
+
562
+ // Flow-IR interpreter: createFlowRunner(flowDoc, { scene, … }).fire(event, ctx) executes
563
+ // the event's steps and returns a Trace.
564
+ //
565
+ // Skip semantics (first-class, docs/slot-ide.md §1.7): skip() does NOT cancel a run — it
566
+ // makes the remaining steps complete in zero time. Waits resolve instantly, tweens and
567
+ // count-ups jump to their end values, taps release. The flow always reaches its settled
568
+ // state; three of the studied games lacked exactly this. Turbo divides every duration.
569
+ const noopAudio = { play: () => { }, stop: () => { } };
570
+ function validateFlowDoc(doc, registry) {
571
+ const errors = [];
572
+ if (doc.version !== 1)
573
+ errors.push(`unsupported flow doc version ${String(doc.version)}`);
574
+ const visit = (step, path) => {
575
+ if (!registry.step(step.do)) {
576
+ errors.push(`${path}: unknown step "${step.do}" — no plugin contributes it. Registered: ${registry.kinds().join(', ')}`);
577
+ return;
578
+ }
579
+ if (step.do === 'if') {
580
+ step.then.forEach((s, i) => visit(s, `${path}.then[${i}]`));
581
+ (step.else ?? []).forEach((s, i) => visit(s, `${path}.else[${i}]`));
582
+ }
583
+ else if (step.do === 'parallel') {
584
+ step.steps.forEach((track, ti) => track.forEach((s, i) => visit(s, `${path}[${ti}][${i}]`)));
585
+ }
586
+ else if (step.do === 'seq') {
587
+ step.steps.forEach((s, i) => visit(s, `${path}.steps[${i}]`));
588
+ }
589
+ else if (step.do === 'sound') {
590
+ const cue = step.cue;
591
+ if (!doc.cues?.[cue])
592
+ errors.push(`${path}: sound cue "${cue}" is not declared in flow.cues`);
593
+ }
594
+ };
595
+ for (const [event, steps] of Object.entries(doc.on ?? {})) {
596
+ steps.forEach((step, i) => visit(step, `on.${event}[${i}]`));
597
+ }
598
+ return errors;
599
+ }
600
+ function createFlowRunner(doc, opts) {
601
+ const log = opts.log ?? ((msg) => console.warn(`[flow] ${msg}`));
602
+ const registry = createFlowStepRegistry(opts.plugins ?? [], BUILTIN_FLOW_STEPS);
603
+ const errors = validateFlowDoc(doc, registry);
604
+ if (errors.length > 0)
605
+ throw new Error(`flow doc "${doc.id}" is invalid:\n ${errors.join('\n ')}`);
606
+ const audio = opts.audio ?? noopAudio;
607
+ let turbo = 1;
608
+ const rotation = new Map();
609
+ const tapWaiters = new Set();
610
+ const active = new Set();
611
+ const resolveCue = (name) => {
612
+ const def = doc.cues?.[name];
613
+ if (!def)
614
+ return undefined;
615
+ const sources = Array.isArray(def.src) ? def.src : [def.src];
616
+ let index = 0;
617
+ if (def.rotate && sources.length > 1) {
618
+ index = (rotation.get(name) ?? 0) % sources.length;
619
+ rotation.set(name, index + 1);
620
+ }
621
+ const jitter = def.jitter ?? 0;
622
+ return {
623
+ cue: name,
624
+ src: sources[index],
625
+ channel: def.channel ?? 'sfx',
626
+ loop: def.loop ?? false,
627
+ rate: jitter ? 1 + (Math.random() * 2 - 1) * jitter : 1,
628
+ volume: def.volume ?? 1,
629
+ };
630
+ };
631
+ const fire = async (event, ctx = {}) => {
632
+ const steps = doc.on[event];
633
+ const start = performance.now();
634
+ const entries = [];
635
+ const run = { skipped: false, releases: new Set() };
636
+ active.add(run);
637
+ const trace = (entry) => {
638
+ const full = { t: Math.round(performance.now() - start), ...entry };
639
+ entries.push(full);
640
+ opts.onTrace?.(event, full);
641
+ };
642
+ const rt = {
643
+ scene: opts.scene,
644
+ ctx,
645
+ audio,
646
+ get skipped() {
647
+ return run.skipped;
648
+ },
649
+ get turbo() {
650
+ return turbo;
651
+ },
652
+ wait(ms) {
653
+ if (run.skipped || ms <= 0)
654
+ return Promise.resolve();
655
+ return new Promise((resolve) => {
656
+ const timer = setTimeout(() => {
657
+ run.releases.delete(release);
658
+ resolve();
659
+ }, ms / turbo);
660
+ const release = () => {
661
+ clearTimeout(timer);
662
+ resolve();
663
+ };
664
+ run.releases.add(release);
665
+ });
666
+ },
667
+ waitTap() {
668
+ if (run.skipped)
669
+ return Promise.resolve();
670
+ return new Promise((resolve) => {
671
+ const release = () => {
672
+ tapWaiters.delete(release);
673
+ run.releases.delete(release);
674
+ resolve();
675
+ };
676
+ tapWaiters.add(release);
677
+ run.releases.add(release);
678
+ });
679
+ },
680
+ async run(list) {
681
+ for (const step of list) {
682
+ const contribution = registry.step(step.do);
683
+ if (!contribution)
684
+ continue; // validated; defensive
685
+ trace({
686
+ do: step.do,
687
+ node: typeof step.node === 'string' ? step.node : undefined,
688
+ });
689
+ await contribution.run(step, rt);
690
+ }
691
+ },
692
+ resolve(value) {
693
+ if (typeof value === 'string' && value.startsWith('$')) {
694
+ return value
695
+ .slice(1)
696
+ .split('.')
697
+ .reduce((acc, key) => acc?.[key], ctx);
698
+ }
699
+ return value;
700
+ },
701
+ when(expr) {
702
+ const bare = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(expr);
703
+ if (bare)
704
+ return Boolean(ctx[bare[1]]);
705
+ const result = evalVisibleWhen(expr, ctx);
706
+ if (result === undefined) {
707
+ log(`unsupported when "${expr}" — treated as false`);
708
+ return false;
709
+ }
710
+ return result;
711
+ },
712
+ trace,
713
+ view: (id) => opts.scene.node(id),
714
+ log,
715
+ codeRef: (ref) => opts.code?.[ref],
716
+ playCue(name, action) {
717
+ if (action === 'stop')
718
+ return audio.stop(name);
719
+ const resolved = resolveCue(name);
720
+ if (resolved)
721
+ audio.play(resolved);
722
+ },
723
+ };
724
+ try {
725
+ if (!steps)
726
+ log(`fire("${event}") — no steps declared`);
727
+ else
728
+ await rt.run(steps);
729
+ }
730
+ finally {
731
+ active.delete(run);
732
+ }
733
+ return {
734
+ event,
735
+ turbo,
736
+ skipped: run.skipped,
737
+ durationMs: Math.round(performance.now() - start),
738
+ entries,
739
+ };
740
+ };
741
+ return {
742
+ fire,
743
+ skip() {
744
+ for (const run of active) {
745
+ run.skipped = true;
746
+ for (const release of [...run.releases])
747
+ release();
748
+ run.releases.clear();
749
+ }
750
+ },
751
+ setTurbo(factor) {
752
+ turbo = Math.max(0.1, factor);
753
+ },
754
+ tap() {
755
+ for (const release of [...tapWaiters])
756
+ release();
757
+ },
758
+ events: () => Object.keys(doc.on ?? {}),
759
+ destroy() {
760
+ this.skip();
761
+ active.clear();
762
+ },
763
+ };
764
+ }
765
+
766
+ exports.BUILTIN_FLOW_STEPS = BUILTIN_FLOW_STEPS;
767
+ exports.createFlowRunner = createFlowRunner;
768
+ exports.createFlowStepRegistry = createFlowStepRegistry;
769
+ exports.validateFlowDoc = validateFlowDoc;
770
+ //# sourceMappingURL=flow.cjs.js.map