@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,3626 @@
1
+ 'use strict';
2
+
3
+ var pixi_js = require('pixi.js');
4
+
5
+ // Pure layout core for scene-IR.
6
+ //
7
+ // `resolveLayoutRule` turns one LayoutRule into a Placement given a context of already
8
+ // laid-out rects. It is renderer-free and side-effect-free so every positioning idiom the
9
+ // games hand-rolled (viewport fractions, frame-hollow binding, cell badges, pins) is unit
10
+ // tested without Pixi. The interpreter (engine.ts) owns pass ordering; this module owns math.
11
+ function orientationOf(width, height, portraitFactor = 1) {
12
+ return width < height * portraitFactor ? 'portrait' : 'landscape';
13
+ }
14
+ /** Base node + orientation override + active state override (state wins), for one frame. */
15
+ function effectiveNode(node, orientation, state) {
16
+ const layers = [
17
+ node.responsive?.[orientation],
18
+ state ? node.states?.[state] : undefined,
19
+ ];
20
+ let layout = node.layout;
21
+ let props = { ...(node.props ?? {}) };
22
+ let visible = true;
23
+ for (const layer of layers) {
24
+ if (!layer)
25
+ continue;
26
+ if (layer.layout)
27
+ layout = layer.layout;
28
+ if (layer.props)
29
+ props = { ...props, ...layer.props };
30
+ if (layer.visible !== undefined)
31
+ visible = layer.visible;
32
+ }
33
+ return { layout, props, visible };
34
+ }
35
+ /** Which node id (if any) must be laid out before this rule can resolve. */
36
+ function dependencyOf(rule) {
37
+ if (!rule)
38
+ return undefined;
39
+ switch (rule.mode) {
40
+ case 'frame-fraction':
41
+ return rule.frame;
42
+ case 'pin':
43
+ return rule.to;
44
+ case 'grid-cell':
45
+ return rule.grid; // undefined = sole grid; the engine resolves that alias itself
46
+ default:
47
+ return undefined;
48
+ }
49
+ }
50
+ function edgePoint(rect, edge) {
51
+ const xs = { left: rect.x, center: rect.x + rect.width / 2, right: rect.x + rect.width };
52
+ const ys = { top: rect.y, center: rect.y + rect.height / 2, bottom: rect.y + rect.height };
53
+ const [v, h] = edge === 'center' ? ['center', 'center'] : edge.split('-');
54
+ return { x: xs[h], y: ys[v] };
55
+ }
56
+ /** AABB of a placement over a natural content size (rotation ignored — pin targets should not rotate). */
57
+ function placementBounds(p, natural) {
58
+ const width = natural.width * p.scaleX;
59
+ const height = natural.height * p.scaleY;
60
+ return { x: p.x - p.anchor[0] * width, y: p.y - p.anchor[1] * height, width, height };
61
+ }
62
+ function viewportRatio(ctx, mode) {
63
+ const rw = ctx.viewport.width / ctx.design.width;
64
+ const rh = ctx.viewport.height / ctx.design.height;
65
+ switch (mode) {
66
+ case 'width':
67
+ return rw;
68
+ case 'height':
69
+ return rh;
70
+ case 'min':
71
+ return Math.min(rw, rh);
72
+ case 'max':
73
+ return Math.max(rw, rh);
74
+ }
75
+ }
76
+ function withNudge(p, rule) {
77
+ const nx = rule.pxNudge?.x ?? 0;
78
+ const ny = rule.pxNudge?.y ?? 0;
79
+ const rotation = rule.rotation ?? p.rotation;
80
+ return { ...p, x: p.x + nx, y: p.y + ny, rotation };
81
+ }
82
+ const CENTER = [0.5, 0.5];
83
+ const TOP_LEFT = [0, 0];
84
+ /**
85
+ * Resolve one rule. Returns `waitingFor` when a referenced node has no bounds yet — the
86
+ * engine re-queues the node for the next pass (bounded; see engine).
87
+ */
88
+ function resolveLayoutRule(rule, natural, ctx) {
89
+ switch (rule.mode) {
90
+ case 'absolute': {
91
+ const s = rule.scale ?? 1;
92
+ return {
93
+ placement: withNudge({
94
+ x: rule.x,
95
+ y: rule.y,
96
+ scaleX: s,
97
+ scaleY: s,
98
+ rotation: rule.rotation ?? 0,
99
+ anchor: rule.anchor ?? TOP_LEFT,
100
+ }, rule),
101
+ };
102
+ }
103
+ case 'viewport-fraction': {
104
+ let scaleX = (rule.scale ?? 1) * (rule.scaleWith ? viewportRatio(ctx, rule.scaleWith) : 1);
105
+ let scaleY = scaleX;
106
+ if ((rule.widthFrac !== undefined || rule.heightFrac !== undefined) && natural.width > 0 && natural.height > 0) {
107
+ const byWidth = rule.widthFrac !== undefined ? (ctx.viewport.width * rule.widthFrac) / natural.width : Infinity;
108
+ const byHeight = rule.heightFrac !== undefined ? (ctx.viewport.height * rule.heightFrac) / natural.height : Infinity;
109
+ if (rule.fit === 'stretch' && rule.widthFrac !== undefined && rule.heightFrac !== undefined) {
110
+ scaleX = byWidth * (rule.scale ?? 1);
111
+ scaleY = byHeight * (rule.scale ?? 1);
112
+ }
113
+ else {
114
+ scaleX = scaleY = Math.min(byWidth, byHeight) * (rule.scale ?? 1);
115
+ }
116
+ }
117
+ return {
118
+ placement: withNudge({
119
+ x: ctx.viewport.width * rule.xFrac,
120
+ y: ctx.viewport.height * rule.yFrac,
121
+ scaleX,
122
+ scaleY,
123
+ rotation: 0,
124
+ anchor: rule.anchor ?? TOP_LEFT,
125
+ }, rule),
126
+ };
127
+ }
128
+ case 'cover': {
129
+ const s = (natural.width > 0 && natural.height > 0
130
+ ? Math.max(ctx.viewport.width / natural.width, ctx.viewport.height / natural.height)
131
+ : 1) * (rule.scale ?? 1);
132
+ return {
133
+ placement: withNudge({
134
+ x: ctx.viewport.width / 2,
135
+ y: ctx.viewport.height / 2,
136
+ scaleX: s,
137
+ scaleY: s,
138
+ rotation: 0,
139
+ anchor: CENTER,
140
+ }, rule),
141
+ };
142
+ }
143
+ case 'frame-fraction': {
144
+ const base = rule.use === 'inner' ? ctx.innerRect(rule.frame) : ctx.bounds(rule.frame);
145
+ if (!base)
146
+ return { waitingFor: rule.frame };
147
+ const x0 = base.x + (rule.xFrac ?? 0) * base.width;
148
+ const y0 = base.y + (rule.yFrac ?? 0) * base.height;
149
+ // A box is targeted when explicit wFrac/hFrac are given, or `use:'inner'` without
150
+ // explicit fractions (the whole hollow is the box — the reel-grid binding).
151
+ const boxW = rule.wFrac !== undefined ? base.width * rule.wFrac : rule.use === 'inner' ? base.width : undefined;
152
+ const boxH = rule.hFrac !== undefined ? base.height * rule.hFrac : rule.use === 'inner' ? base.height : undefined;
153
+ if (boxW === undefined && boxH === undefined) {
154
+ return {
155
+ placement: withNudge({ x: x0, y: y0, scaleX: 1, scaleY: 1, rotation: 0, anchor: rule.anchor ?? CENTER }, rule),
156
+ };
157
+ }
158
+ if (boxW === undefined || boxH === undefined) {
159
+ // One-dimension box: uniform scale from that dimension, anchor-point placement
160
+ // (the "meter bar is 32% of the frame's width" idiom).
161
+ const s = boxW !== undefined
162
+ ? natural.width > 0
163
+ ? boxW / natural.width
164
+ : 1
165
+ : natural.height > 0
166
+ ? boxH / natural.height
167
+ : 1;
168
+ return {
169
+ placement: withNudge({ x: x0, y: y0, scaleX: s, scaleY: s, rotation: 0, anchor: rule.anchor ?? CENTER }, rule),
170
+ };
171
+ }
172
+ let scaleX = 1;
173
+ let scaleY = 1;
174
+ if (natural.width > 0 && natural.height > 0) {
175
+ if (rule.fit === 'stretch') {
176
+ scaleX = boxW / natural.width;
177
+ scaleY = boxH / natural.height;
178
+ }
179
+ else {
180
+ scaleX = scaleY = Math.min(boxW / natural.width, boxH / natural.height);
181
+ }
182
+ }
183
+ return {
184
+ placement: withNudge({
185
+ x: x0 + boxW / 2,
186
+ y: y0 + boxH / 2,
187
+ scaleX,
188
+ scaleY,
189
+ rotation: 0,
190
+ anchor: rule.anchor ?? CENTER,
191
+ box: { width: boxW, height: boxH },
192
+ }, rule),
193
+ };
194
+ }
195
+ case 'grid-cell': {
196
+ const point = ctx.gridCell(rule.grid, rule.col, rule.row);
197
+ if (!point)
198
+ return { waitingFor: rule.grid ?? '@grid' };
199
+ const gridScale = rule.scaleWithGrid === false ? undefined : ctx.gridScale(rule.grid);
200
+ const s = rule.scale ?? 1;
201
+ return {
202
+ placement: withNudge({
203
+ x: point.x,
204
+ y: point.y,
205
+ scaleX: s * (gridScale?.x ?? 1),
206
+ scaleY: s * (gridScale?.y ?? 1),
207
+ rotation: 0,
208
+ anchor: rule.anchor ?? CENTER,
209
+ }, rule),
210
+ };
211
+ }
212
+ case 'pin': {
213
+ const rect = ctx.bounds(rule.to);
214
+ if (!rect)
215
+ return { waitingFor: rule.to };
216
+ const point = edgePoint(rect, rule.edge);
217
+ const s = rule.scale ?? 1;
218
+ return {
219
+ placement: withNudge({
220
+ x: point.x + (rule.offset?.[0] ?? 0),
221
+ y: point.y + (rule.offset?.[1] ?? 0),
222
+ scaleX: s,
223
+ scaleY: s,
224
+ rotation: 0,
225
+ anchor: rule.anchor ?? CENTER,
226
+ }, rule),
227
+ };
228
+ }
229
+ }
230
+ }
231
+ /**
232
+ * Evaluate a `visibleWhen` micro-expression: `<var> <op> <literal>` with
233
+ * `=== !== >= <= > <`. Unknown shapes evaluate to visible (with the engine warning once) —
234
+ * richer conditions belong in flow or code, not in the scene doc.
235
+ */
236
+ function evalVisibleWhen(expr, vars) {
237
+ const m = /^\s*([A-Za-z_$][\w$]*)\s*(===|!==|>=|<=|>|<)\s*(.+?)\s*$/.exec(expr);
238
+ if (!m)
239
+ return undefined;
240
+ const [, name, op, rawLit] = m;
241
+ let lit;
242
+ try {
243
+ lit = JSON.parse(rawLit.replace(/^'(.*)'$/s, '"$1"'));
244
+ }
245
+ catch {
246
+ return undefined;
247
+ }
248
+ const value = vars[name];
249
+ switch (op) {
250
+ case '===':
251
+ return value === lit;
252
+ case '!==':
253
+ return value !== lit;
254
+ case '>=':
255
+ return Number(value) >= Number(lit);
256
+ case '<=':
257
+ return Number(value) <= Number(lit);
258
+ case '>':
259
+ return Number(value) > Number(lit);
260
+ case '<':
261
+ return Number(value) < Number(lit);
262
+ }
263
+ return undefined;
264
+ }
265
+
266
+ // Scene contribution registry — the plugin seam of scene-IR.
267
+ //
268
+ // Every node `type` in a scene doc resolves through this registry: built-ins, official
269
+ // plugins and game-local plugins register through the same `ScenePlugin` shape, so the
270
+ // escape hatch and the plugin system are one mechanism (docs/slot-ide.md §6.2). A
271
+ // contribution carries its runtime factory plus the metadata the tooling needs: a props
272
+ // schema (validation + inspector autogen) and an `agentDoc` (the plugin's documentation
273
+ // and the agent's prompt are the same text).
274
+ /** Merge built-ins with plugin contributions; later plugins may override earlier kinds. */
275
+ function createSceneRegistry(plugins = [], builtins = []) {
276
+ const nodeTypes = new Map();
277
+ const prefabs = new Map();
278
+ for (const contribution of builtins)
279
+ nodeTypes.set(contribution.kind, contribution);
280
+ for (const plugin of plugins) {
281
+ for (const contribution of plugin.nodeTypes ?? [])
282
+ nodeTypes.set(contribution.kind, contribution);
283
+ for (const prefab of plugin.prefabs ?? [])
284
+ prefabs.set(prefab.name, prefab);
285
+ }
286
+ return {
287
+ nodeType: (kind) => nodeTypes.get(kind),
288
+ prefab: (name) => prefabs.get(name),
289
+ kinds: () => [...nodeTypes.keys()],
290
+ prefabNames: () => [...prefabs.keys()],
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Collection of easing functions for use with Tween and Timeline.
296
+ *
297
+ * All functions take a progress value t (0..1) and return the eased value.
298
+ */
299
+ const Easing = {
300
+ linear: (t) => t,
301
+ easeInQuad: (t) => t * t,
302
+ easeOutQuad: (t) => t * (2 - t),
303
+ easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
304
+ easeInCubic: (t) => t * t * t,
305
+ easeOutCubic: (t) => --t * t * t + 1,
306
+ easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
307
+ easeInQuart: (t) => t * t * t * t,
308
+ easeOutQuart: (t) => 1 - --t * t * t * t,
309
+ easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
310
+ easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
311
+ easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
312
+ easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
313
+ easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
314
+ easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
315
+ easeInOutExpo: (t) => t === 0
316
+ ? 0
317
+ : t === 1
318
+ ? 1
319
+ : t < 0.5
320
+ ? Math.pow(2, 20 * t - 10) / 2
321
+ : (2 - Math.pow(2, -20 * t + 10)) / 2,
322
+ easeInBack: (t) => {
323
+ const c1 = 1.70158;
324
+ const c3 = c1 + 1;
325
+ return c3 * t * t * t - c1 * t * t;
326
+ },
327
+ easeOutBack: (t) => {
328
+ const c1 = 1.70158;
329
+ const c3 = c1 + 1;
330
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
331
+ },
332
+ easeInOutBack: (t) => {
333
+ const c1 = 1.70158;
334
+ const c2 = c1 * 1.525;
335
+ return t < 0.5
336
+ ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
337
+ : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
338
+ },
339
+ easeOutBounce: (t) => {
340
+ const n1 = 7.5625;
341
+ const d1 = 2.75;
342
+ if (t < 1 / d1)
343
+ return n1 * t * t;
344
+ if (t < 2 / d1)
345
+ return n1 * (t -= 1.5 / d1) * t + 0.75;
346
+ if (t < 2.5 / d1)
347
+ return n1 * (t -= 2.25 / d1) * t + 0.9375;
348
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
349
+ },
350
+ easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
351
+ easeInOutBounce: (t) => t < 0.5
352
+ ? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
353
+ : (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
354
+ easeOutElastic: (t) => {
355
+ const c4 = (2 * Math.PI) / 3;
356
+ return t === 0
357
+ ? 0
358
+ : t === 1
359
+ ? 1
360
+ : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
361
+ },
362
+ easeInElastic: (t) => {
363
+ const c4 = (2 * Math.PI) / 3;
364
+ return t === 0
365
+ ? 0
366
+ : t === 1
367
+ ? 1
368
+ : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
369
+ },
370
+ };
371
+
372
+ /**
373
+ * Lightweight tween system integrated with PixiJS Ticker.
374
+ * Zero external dependencies — no GSAP required.
375
+ *
376
+ * All tweens return a Promise that resolves on completion.
377
+ *
378
+ * @example
379
+ * ```ts
380
+ * // Fade in a sprite
381
+ * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
382
+ *
383
+ * // Move and wait
384
+ * await Tween.to(sprite, { x: 500 }, 300);
385
+ *
386
+ * // From a starting value
387
+ * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
388
+ * ```
389
+ */
390
+ class Tween {
391
+ static _tweens = [];
392
+ static _tickerAdded = false;
393
+ /**
394
+ * Animate properties from current values to target values.
395
+ *
396
+ * @param target - Object to animate (Sprite, Container, etc.)
397
+ * @param props - Target property values
398
+ * @param duration - Duration in milliseconds
399
+ * @param easing - Easing function (default: easeOutQuad)
400
+ * @param onUpdate - Progress callback (0..1)
401
+ */
402
+ static to(target, props, duration, easing, onUpdate) {
403
+ // A destroyed (Pixi) target has null transform fields — skip rather than throw. This guards
404
+ // animations whose target is torn down mid-flight (e.g. a reel grid rebuilt during a spin).
405
+ if (target == null || target.destroyed)
406
+ return Promise.resolve();
407
+ return new Promise((resolve) => {
408
+ // Capture starting values
409
+ const from = {};
410
+ for (const key of Object.keys(props)) {
411
+ from[key] = Tween.getProperty(target, key);
412
+ }
413
+ const tween = {
414
+ target,
415
+ from,
416
+ to: { ...props },
417
+ duration: Math.max(1, duration),
418
+ easing: easing ?? Easing.easeOutQuad,
419
+ elapsed: 0,
420
+ delay: 0,
421
+ resolve,
422
+ onUpdate,
423
+ };
424
+ Tween._tweens.push(tween);
425
+ Tween.ensureTicker();
426
+ });
427
+ }
428
+ /**
429
+ * Animate properties from given values to current values.
430
+ */
431
+ static from(target, props, duration, easing, onUpdate) {
432
+ if (target == null || target.destroyed)
433
+ return Promise.resolve();
434
+ // Capture current values as "to"
435
+ const to = {};
436
+ for (const key of Object.keys(props)) {
437
+ to[key] = Tween.getProperty(target, key);
438
+ Tween.setProperty(target, key, props[key]);
439
+ }
440
+ return Tween.to(target, to, duration, easing, onUpdate);
441
+ }
442
+ /**
443
+ * Animate from one set of values to another.
444
+ */
445
+ static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
446
+ if (target == null || target.destroyed)
447
+ return Promise.resolve();
448
+ // Set starting values
449
+ for (const key of Object.keys(fromProps)) {
450
+ Tween.setProperty(target, key, fromProps[key]);
451
+ }
452
+ return Tween.to(target, toProps, duration, easing, onUpdate);
453
+ }
454
+ /**
455
+ * Wait for a given duration (useful in timelines).
456
+ * Uses PixiJS Ticker for consistent timing with other tweens.
457
+ */
458
+ static delay(ms) {
459
+ return new Promise((resolve) => {
460
+ let elapsed = 0;
461
+ const onTick = (ticker) => {
462
+ elapsed += ticker.deltaMS;
463
+ if (elapsed >= ms) {
464
+ pixi_js.Ticker.shared.remove(onTick);
465
+ resolve();
466
+ }
467
+ };
468
+ pixi_js.Ticker.shared.add(onTick);
469
+ });
470
+ }
471
+ /**
472
+ * Kill all tweens on a target.
473
+ */
474
+ static killTweensOf(target) {
475
+ Tween._tweens = Tween._tweens.filter((tw) => {
476
+ if (tw.target === target) {
477
+ tw.resolve();
478
+ return false;
479
+ }
480
+ return true;
481
+ });
482
+ }
483
+ /**
484
+ * Kill all active tweens.
485
+ */
486
+ static killAll() {
487
+ for (const tw of Tween._tweens) {
488
+ tw.resolve();
489
+ }
490
+ Tween._tweens.length = 0;
491
+ }
492
+ /** Number of active tweens */
493
+ static get activeTweens() {
494
+ return Tween._tweens.length;
495
+ }
496
+ /**
497
+ * Reset the tween system — kill all tweens and remove the ticker.
498
+ * Useful for cleanup between game instances, tests, or hot-reload.
499
+ */
500
+ static reset() {
501
+ for (const tw of Tween._tweens) {
502
+ tw.resolve();
503
+ }
504
+ Tween._tweens.length = 0;
505
+ if (Tween._tickerAdded) {
506
+ pixi_js.Ticker.shared.remove(Tween.tick);
507
+ Tween._tickerAdded = false;
508
+ }
509
+ }
510
+ // ─── Internal ──────────────────────────────────────────
511
+ static ensureTicker() {
512
+ if (Tween._tickerAdded)
513
+ return;
514
+ Tween._tickerAdded = true;
515
+ pixi_js.Ticker.shared.add(Tween.tick);
516
+ }
517
+ static tick = (ticker) => {
518
+ const dt = ticker.deltaMS;
519
+ const completed = [];
520
+ for (const tw of Tween._tweens) {
521
+ // target torn down mid-tween → finish it quietly
522
+ if (tw.target?.destroyed) {
523
+ completed.push(tw);
524
+ continue;
525
+ }
526
+ tw.elapsed += dt;
527
+ if (tw.elapsed < tw.delay)
528
+ continue;
529
+ const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
530
+ const t = tw.easing(raw);
531
+ // Interpolate each property
532
+ for (const key of Object.keys(tw.to)) {
533
+ const start = tw.from[key];
534
+ const end = tw.to[key];
535
+ const value = start + (end - start) * t;
536
+ Tween.setProperty(tw.target, key, value);
537
+ }
538
+ tw.onUpdate?.(raw);
539
+ if (raw >= 1) {
540
+ completed.push(tw);
541
+ }
542
+ }
543
+ // Remove completed tweens
544
+ for (const tw of completed) {
545
+ const idx = Tween._tweens.indexOf(tw);
546
+ if (idx !== -1)
547
+ Tween._tweens.splice(idx, 1);
548
+ tw.resolve();
549
+ }
550
+ // Remove ticker when no active tweens
551
+ if (Tween._tweens.length === 0 && Tween._tickerAdded) {
552
+ pixi_js.Ticker.shared.remove(Tween.tick);
553
+ Tween._tickerAdded = false;
554
+ }
555
+ };
556
+ /**
557
+ * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
558
+ */
559
+ static getProperty(target, key) {
560
+ const parts = key.split('.');
561
+ let obj = target;
562
+ for (let i = 0; i < parts.length - 1; i++) {
563
+ obj = obj?.[parts[i]];
564
+ }
565
+ return obj?.[parts[parts.length - 1]] ?? 0;
566
+ }
567
+ /**
568
+ * Set a potentially nested property.
569
+ */
570
+ static setProperty(target, key, value) {
571
+ const parts = key.split('.');
572
+ let obj = target;
573
+ for (let i = 0; i < parts.length - 1; i++) {
574
+ obj = obj?.[parts[i]];
575
+ }
576
+ if (obj == null)
577
+ return;
578
+ obj[parts[parts.length - 1]] = value;
579
+ }
580
+ }
581
+
582
+ const DEFAULT_STYLE = {
583
+ radius: 8,
584
+ idle: { color: 0x223047, alpha: 0.34 },
585
+ winning: { color: 0x00d4ff, alpha: 0.95 },
586
+ removed: { color: 0x223047, alpha: 0.12 },
587
+ fresh: { color: 0xffffff, alpha: 0.6 },
588
+ };
589
+ const cellDims = (size) => typeof size === 'number' ? { width: size, height: size } : size;
590
+ class SymbolCell extends pixi_js.Container {
591
+ __uiComponent = true;
592
+ _w;
593
+ _h;
594
+ _resolve;
595
+ _style;
596
+ _frame;
597
+ _view = null;
598
+ _data = { symbol: null };
599
+ _badges = new pixi_js.Container();
600
+ _multBadge = null;
601
+ _bonusBadge = null;
602
+ /** Last applied state key — exposed for tests/inspection. */
603
+ frameStyleKey = 'idle';
604
+ constructor(config) {
605
+ super();
606
+ const { width, height } = cellDims(config.size);
607
+ this._w = width;
608
+ this._h = height;
609
+ this._resolve = config.resolve;
610
+ this._style = { ...DEFAULT_STYLE, ...(config.frameStyle ?? {}) };
611
+ this._frame = new pixi_js.Graphics();
612
+ this.addChild(this._frame);
613
+ this.addChild(this._badges);
614
+ this._drawFrame('idle');
615
+ }
616
+ get view() {
617
+ return this._view;
618
+ }
619
+ /** The last applied cell data (symbol + badges). Read by motion controllers that rebuild tapes. */
620
+ get data() {
621
+ return this._data;
622
+ }
623
+ setData(data) {
624
+ if (this.destroyed)
625
+ return; // a killed-tween chain may resume after the cell is gone
626
+ this._data = data;
627
+ // symbol view
628
+ if (data.symbol == null) {
629
+ if (this._view) {
630
+ this._view.destroy();
631
+ this._view = null;
632
+ }
633
+ }
634
+ else {
635
+ if (this._view) {
636
+ this._view.destroy();
637
+ this._view = null;
638
+ }
639
+ const v = this._resolve(data.symbol);
640
+ if (v) {
641
+ v.resize?.({ width: this._w, height: this._h });
642
+ this.addChildAt(v, 1); // above frame, below badges
643
+ this._view = v;
644
+ }
645
+ }
646
+ // badges
647
+ this._setMultiplier(data.multiplier);
648
+ this._setBonus(data.bonus);
649
+ }
650
+ setState(state) {
651
+ if (this.destroyed)
652
+ return;
653
+ const key = state.winning
654
+ ? 'winning'
655
+ : state.removed
656
+ ? 'removed'
657
+ : state.fresh
658
+ ? 'fresh'
659
+ : 'idle';
660
+ this.frameStyleKey = key;
661
+ this._drawFrame(key);
662
+ }
663
+ playWin() {
664
+ if (this._view?.playWin)
665
+ return this._view.playWin();
666
+ // default: scale pop
667
+ const target = this._view ?? this;
668
+ return Tween.to(target, { 'scale.x': 1.15, 'scale.y': 1.15 }, 160, Easing.easeOutBack).then(() => Tween.to(target, { 'scale.x': 1, 'scale.y': 1 }, 140, Easing.easeOutQuad));
669
+ }
670
+ playIdle() {
671
+ this._view?.playIdle?.();
672
+ }
673
+ hasBadge(kind) {
674
+ return kind === 'multiplier' ? this._multBadge != null : this._bonusBadge != null;
675
+ }
676
+ _drawFrame(key) {
677
+ if (this.destroyed || this._frame.destroyed)
678
+ return;
679
+ const s = this._style[key];
680
+ this._frame.clear();
681
+ this._frame
682
+ .roundRect(-this._w / 2, -this._h / 2, this._w, this._h, this._style.radius)
683
+ .fill({ color: s.color, alpha: s.alpha });
684
+ // store the colour as tint for cheap inspection/testing
685
+ this._frame.tint = s.color;
686
+ }
687
+ _setMultiplier(value) {
688
+ if (this._multBadge) {
689
+ this._multBadge.destroy();
690
+ this._multBadge = null;
691
+ }
692
+ if (!value || value <= 1)
693
+ return;
694
+ this._multBadge = this._badge(`×${value}`, 0xffd24a);
695
+ this._multBadge.position.set(this._w / 2 - 12, -this._h / 2 + 12);
696
+ this._badges.addChild(this._multBadge);
697
+ }
698
+ _setBonus(value) {
699
+ if (this._bonusBadge) {
700
+ this._bonusBadge.destroy();
701
+ this._bonusBadge = null;
702
+ }
703
+ if (!value || value <= 0)
704
+ return;
705
+ this._bonusBadge = this._badge(`+${value}`, 0x7ad7ff);
706
+ this._bonusBadge.position.set(-this._w / 2 + 12, -this._h / 2 + 12);
707
+ this._badges.addChild(this._bonusBadge);
708
+ }
709
+ _badge(label, color) {
710
+ const c = new pixi_js.Container();
711
+ const t = new pixi_js.Text({ text: label, style: { fontSize: 18, fill: color, fontWeight: '700' } });
712
+ t.anchor.set(0.5);
713
+ c.addChild(t);
714
+ return c;
715
+ }
716
+ }
717
+
718
+ // packages/game-engine/src/slot/grid/geometry.ts
719
+ //
720
+ // Pure geometry resolver for the reel grid. Turns the (backward-compatible) grid config
721
+ // — a square `cellSize` + single `gap`, optionally overridden by rectangular / per-strip
722
+ // dimensions and per-strip gaps — into a fully-resolved, per-reel layout.
723
+ //
724
+ // Coordinate convention (unchanged from the original square grid):
725
+ // - `cellPosition` returns CELL-CENTRE coordinates.
726
+ // - Cell (0,0)'s centre sits at local x = 0. Reels extend to the right.
727
+ // - Variable-height reels are vertically CENTRED about a shared centre line, so the
728
+ // tallest reel's row 0 sits at y = 0 (matching the old Megaways behaviour).
729
+ //
730
+ // See docs/reels-analysis-and-design.md §6.
731
+ const perReelSize = (spec, w, h) => {
732
+ if (typeof spec === 'number')
733
+ return [spec, spec];
734
+ if (spec && typeof spec === 'object')
735
+ return [spec.width, spec.height];
736
+ return [w, h];
737
+ };
738
+ const gapAt = (g, i, base) => Array.isArray(g) ? (g[i] ?? base) : (g ?? base);
739
+ /** Resolve a grid config into a fully-populated per-reel geometry. */
740
+ function resolveGeometry(g) {
741
+ const cols = Math.max(0, g.cols);
742
+ const rowsPerReel = g.rowsPerReel && g.rowsPerReel.length === cols
743
+ ? g.rowsPerReel.slice()
744
+ : Array.from({ length: cols }, () => g.rows);
745
+ const maxRows = cols ? Math.max(1, ...rowsPerReel) : 0;
746
+ const baseW = g.cellWidth ?? g.cellSize;
747
+ const baseH = g.cellHeight ?? g.cellSize;
748
+ const baseGap = g.gap ?? 0;
749
+ const cellW = [];
750
+ const cellH = [];
751
+ const rowGap = [];
752
+ for (let c = 0; c < cols; c++) {
753
+ const [w, h] = perReelSize(g.cellSizePerReel?.[c], baseW, baseH);
754
+ cellW[c] = w;
755
+ cellH[c] = h;
756
+ rowGap[c] = gapAt(g.rowGap, c, baseGap);
757
+ }
758
+ const colGap = [];
759
+ for (let i = 0; i < Math.max(0, cols - 1); i++)
760
+ colGap[i] = gapAt(g.colGap, i, baseGap);
761
+ // Horizontal: reel 0 centre at x = 0, then accumulate half-widths + between-reel gaps.
762
+ const colX = [];
763
+ if (cols)
764
+ colX[0] = 0;
765
+ for (let c = 1; c < cols; c++)
766
+ colX[c] = colX[c - 1] + cellW[c - 1] / 2 + colGap[c - 1] + cellW[c] / 2;
767
+ // Vertical: each reel's rows span (rows-1)*step; centre every reel about a shared line so
768
+ // the tallest reel's row 0 stays at y = 0 (parity with the old uniform Megaways layout).
769
+ const span = rowsPerReel.map((rr, c) => Math.max(0, rr - 1) * (cellH[c] + rowGap[c]));
770
+ const halfMaxSpan = cols ? Math.max(...span) / 2 : 0;
771
+ const yOff = span.map((s) => halfMaxSpan - s / 2);
772
+ // Bounding box.
773
+ let leftX = 0;
774
+ let rightX = 0;
775
+ let topY = 0;
776
+ let bottomY = 0;
777
+ for (let c = 0; c < cols; c++) {
778
+ leftX = Math.min(leftX, colX[c] - cellW[c] / 2);
779
+ rightX = Math.max(rightX, colX[c] + cellW[c] / 2);
780
+ const reelTop = yOff[c] - cellH[c] / 2;
781
+ topY = Math.min(topY, reelTop);
782
+ bottomY = Math.max(bottomY, reelTop + rowsPerReel[c] * cellH[c] + Math.max(0, rowsPerReel[c] - 1) * rowGap[c]);
783
+ }
784
+ const gridW = rightX - leftX;
785
+ const gridH = bottomY - topY;
786
+ return {
787
+ cols,
788
+ rowsPerReel,
789
+ maxRows,
790
+ cellW,
791
+ cellH,
792
+ rowGap,
793
+ colGap,
794
+ colX,
795
+ yOff,
796
+ gridW,
797
+ gridH,
798
+ centerX: (leftX + rightX) / 2,
799
+ centerY: (topY + bottomY) / 2,
800
+ leftX,
801
+ topY,
802
+ };
803
+ }
804
+ /** Cell-centre position from a resolved geometry. */
805
+ function cellPositionOf(geom, col, row) {
806
+ return {
807
+ x: geom.colX[col] ?? 0,
808
+ y: (geom.yOff[col] ?? 0) + row * ((geom.cellH[col] ?? 0) + (geom.rowGap[col] ?? 0)),
809
+ };
810
+ }
811
+
812
+ /**
813
+ * A grid of `SymbolCell`s. Supports uniform grids, variable-height reels (Megaways), and
814
+ * rectangular / per-strip cell sizes with per-strip gaps. All layout flows through a single
815
+ * resolved geometry (see grid/geometry.ts); every consumer reads positions via `cellPosition`
816
+ * and dimensions via `cellSize(col)` rather than assuming a square cell.
817
+ */
818
+ class ReelGrid extends pixi_js.Container {
819
+ __uiComponent = true;
820
+ _cfg;
821
+ _geom;
822
+ _cells = [];
823
+ _cellLayer = new pixi_js.Container();
824
+ _resolve;
825
+ _frameStyle;
826
+ _mask = null;
827
+ constructor(config) {
828
+ super();
829
+ this._cfg = { ...config };
830
+ this._resolve = config.resolve;
831
+ this._frameStyle = config.frameStyle;
832
+ this._geom = resolveGeometry(config);
833
+ if (config.decoration?.texture) {
834
+ const pad = config.decoration.padding ?? 0;
835
+ const deco = new pixi_js.Sprite(config.decoration.texture);
836
+ deco.width = this._geom.gridW + pad * 2;
837
+ deco.height = this._geom.gridH + pad * 2;
838
+ deco.position.set(this._geom.leftX - pad, this._geom.topY - pad);
839
+ this.addChild(deco);
840
+ }
841
+ this.addChild(this._cellLayer);
842
+ this._buildCells();
843
+ if (config.mask)
844
+ this._applyMask();
845
+ }
846
+ get _cols() {
847
+ return this._geom.cols;
848
+ }
849
+ get _rowsPerReel() {
850
+ return this._geom.rowsPerReel;
851
+ }
852
+ _buildCells() {
853
+ for (let c = 0; c < this._cols; c++) {
854
+ this._cells[c] = [];
855
+ for (let r = 0; r < this._rowsPerReel[c]; r++) {
856
+ const cell = new SymbolCell({
857
+ size: this.cellSize(c),
858
+ resolve: this._resolve,
859
+ frameStyle: this._frameStyle,
860
+ });
861
+ const { x, y } = this.cellPosition(c, r);
862
+ cell.position.set(x, y);
863
+ this._cellLayer.addChild(cell);
864
+ this._cells[c][r] = cell;
865
+ }
866
+ }
867
+ }
868
+ /** Per-strip mask: one window per reel sized to that reel's own cell width × column height. */
869
+ _applyMask() {
870
+ const g = this._geom;
871
+ const m = new pixi_js.Graphics();
872
+ for (let c = 0; c < this._cols; c++) {
873
+ const rows = this._rowsPerReel[c];
874
+ const w = g.cellW[c];
875
+ const h = rows * g.cellH[c] + Math.max(0, rows - 1) * g.rowGap[c];
876
+ const x = g.colX[c] - w / 2;
877
+ const y = g.yOff[c] - g.cellH[c] / 2;
878
+ m.rect(x, y, w, h);
879
+ }
880
+ m.fill(0xffffff);
881
+ this._cellLayer.mask = m;
882
+ this.addChild(m);
883
+ this._mask = m;
884
+ }
885
+ get cols() {
886
+ return this._cols;
887
+ }
888
+ /** Tallest reel's row count (the grid's visual height in rows). */
889
+ get rows() {
890
+ return this._geom.maxRows;
891
+ }
892
+ /** Cell dimensions (px) for a reel. Rectangular / per-strip aware. */
893
+ cellSize(col) {
894
+ return { width: this._geom.cellW[col] ?? 0, height: this._geom.cellH[col] ?? 0 };
895
+ }
896
+ /** The fully-resolved grid geometry (read-only view). */
897
+ get geometry() {
898
+ return this._geom;
899
+ }
900
+ rowsOf(col) {
901
+ return this._rowsPerReel[col] ?? 0;
902
+ }
903
+ get rowsPerReel() {
904
+ return this._rowsPerReel.slice();
905
+ }
906
+ /** Cell centre position. Variable-height reels are centred about a shared centre line. */
907
+ cellPosition(col, row) {
908
+ return cellPositionOf(this._geom, col, row);
909
+ }
910
+ /** Grid bounding-box centre in local coords. */
911
+ center() {
912
+ return { x: this._geom.centerX, y: this._geom.centerY };
913
+ }
914
+ getCell(col, row) {
915
+ return this._cells[col][row];
916
+ }
917
+ setGrid(cells) {
918
+ for (let c = 0; c < this._cols; c++) {
919
+ for (let r = 0; r < this._rowsPerReel[c]; r++) {
920
+ this._cells[c]?.[r]?.setData(cells[c]?.[r] ?? { symbol: null });
921
+ }
922
+ }
923
+ }
924
+ /** Rebuild the grid with new per-reel row counts (Megaways re-roll / dynamic rows). */
925
+ reshape(rowsPerReel) {
926
+ if (rowsPerReel.length !== this._cols)
927
+ return;
928
+ this._cellLayer.removeChildren().forEach((c) => c.destroy());
929
+ this._cells = [];
930
+ this._cfg = { ...this._cfg, rowsPerReel: rowsPerReel.slice() };
931
+ this._geom = resolveGeometry(this._cfg);
932
+ this._buildCells();
933
+ if (this._mask) {
934
+ this._mask.destroy();
935
+ this.removeChild(this._mask);
936
+ this._mask = null;
937
+ this._applyMask();
938
+ }
939
+ }
940
+ /**
941
+ * Re-resolve geometry after a base cell-size change and reposition cells. Accepts a square
942
+ * scalar (updates `cellSize`) or explicit `{width,height}`. Per-strip overrides still apply.
943
+ * (Cell frames are not re-drawn here — a geometry change routes through a full rebuild upstream.)
944
+ */
945
+ resize(size) {
946
+ if (typeof size === 'number') {
947
+ this._cfg = { ...this._cfg, cellSize: size, cellWidth: undefined, cellHeight: undefined };
948
+ }
949
+ else {
950
+ this._cfg = { ...this._cfg, cellWidth: size.width, cellHeight: size.height };
951
+ }
952
+ this._geom = resolveGeometry(this._cfg);
953
+ for (let c = 0; c < this._cols; c++) {
954
+ for (let r = 0; r < this._rowsPerReel[c]; r++) {
955
+ const { x, y } = this.cellPosition(c, r);
956
+ this._cells[c][r].position.set(x, y);
957
+ }
958
+ }
959
+ }
960
+ }
961
+
962
+ // packages/game-engine/src/slot/anim/easing-map.ts
963
+ /** Resolve a descriptor's string easing name to the engine's easing function. */
964
+ const EASING_BY_NAME = {
965
+ linear: Easing.linear,
966
+ easeInQuad: Easing.easeInQuad,
967
+ easeOutQuad: Easing.easeOutQuad,
968
+ easeInOutQuad: Easing.easeInOutQuad,
969
+ easeInCubic: Easing.easeInCubic,
970
+ easeOutCubic: Easing.easeOutCubic,
971
+ easeInOutCubic: Easing.easeInOutCubic,
972
+ easeInBack: Easing.easeInBack,
973
+ easeOutBack: Easing.easeOutBack,
974
+ easeInOutBack: Easing.easeInOutBack,
975
+ easeOutBounce: Easing.easeOutBounce,
976
+ easeInBounce: Easing.easeInBounce,
977
+ easeOutElastic: Easing.easeOutElastic,
978
+ easeInSine: Easing.easeInSine,
979
+ easeOutSine: Easing.easeOutSine,
980
+ easeInOutSine: Easing.easeInOutSine,
981
+ };
982
+ /** Resolve an easing name to a function, falling back to easeOutQuad. */
983
+ function easingByName(name) {
984
+ return (name && EASING_BY_NAME[name]) || Easing.easeOutQuad;
985
+ }
986
+
987
+ // packages/game-engine/src/slot/config/ReelSystemConfig.ts
988
+ //
989
+ // The single, fully-typed configuration object for the configurable reel system.
990
+ // Everything is optional with sensible defaults — `resolveReelConfig(partial)` deep-merges
991
+ // a partial config onto DEFAULT_REEL_CONFIG so games (and the reel-lab playground) can
992
+ // override only what they need.
993
+ //
994
+ // Design notes are in docs/reels-analysis-and-design.md.
995
+ const FEATURE_KEYS = [
996
+ 'reelModifier', // pre-spin
997
+ 'giant',
998
+ 'stacked',
999
+ 'mystery', // post-spin reveal
1000
+ 'expandingWild',
1001
+ 'walkingWild',
1002
+ 'randomWild',
1003
+ 'transform',
1004
+ 'split',
1005
+ 'nudge',
1006
+ 'multiplier',
1007
+ 'sticky',
1008
+ 'holdAndSpin',
1009
+ ];
1010
+ // ─────────────────────────────────────────────────────────────────────────────
1011
+ // Defaults
1012
+ // ─────────────────────────────────────────────────────────────────────────────
1013
+ const DEFAULT_REEL_CONFIG = {
1014
+ grid: {
1015
+ cols: 5,
1016
+ rows: 3,
1017
+ cellSize: 96,
1018
+ gap: 6,
1019
+ evaluation: 'lines',
1020
+ minRows: 2,
1021
+ maxRows: 7,
1022
+ topReel: null,
1023
+ mask: true,
1024
+ decoration: { padding: 0 },
1025
+ },
1026
+ motion: {
1027
+ style: 'swap',
1028
+ spinUp: 500,
1029
+ hold: 200,
1030
+ stopStagger: 120,
1031
+ stopMode: 'sequential',
1032
+ stopOrder: 'ltr',
1033
+ settle: { amp: 7, ms: 240, easing: 'easeOutBack' },
1034
+ squash: { enabled: false, scaleX: 1.18, scaleY: 0.82, ms: 90 },
1035
+ blur: { enabled: false, alpha: 0.85, strength: 8, streaks: false },
1036
+ turboFactor: 0.5,
1037
+ intensity: 'full',
1038
+ slamStop: true,
1039
+ symbolsPerReel: 6,
1040
+ },
1041
+ anticipation: {
1042
+ enabled: false,
1043
+ triggerSymbols: ['scatter'],
1044
+ threshold: 2,
1045
+ reels: 'trailing',
1046
+ slowdownFactor: 0.3,
1047
+ holdMs: 400,
1048
+ zoom: { enabled: false, scale: 1.15, ms: 600 },
1049
+ },
1050
+ cascade: {
1051
+ enabled: false,
1052
+ gravity: true,
1053
+ timings: { reveal: 300, highlight: 400, remove: 250, drop: 220, refill: 220, wait: 150 },
1054
+ easings: { highlight: 'easeOutQuad', remove: 'easeInBack', drop: 'easeOutBounce' },
1055
+ perStepDecel: 0.08,
1056
+ perStepDecelCap: 1.5,
1057
+ dimNonWinners: false,
1058
+ dimAlpha: 0.35,
1059
+ multiplier: {
1060
+ enabled: false,
1061
+ start: 1,
1062
+ mode: 'add',
1063
+ step: 1,
1064
+ cap: null,
1065
+ persistInFreeSpins: false,
1066
+ },
1067
+ },
1068
+ win: {
1069
+ highlightScale: 1.12,
1070
+ glow: true,
1071
+ frameShake: { enabled: false, amp: 3, ms: 180, onlyOnSymbols: null },
1072
+ },
1073
+ features: {
1074
+ expandingWild: {
1075
+ enabled: false,
1076
+ symbol: 'wild',
1077
+ reels: [],
1078
+ toFullReel: true,
1079
+ ms: 420,
1080
+ easing: 'easeOutBack',
1081
+ onlyInFreeSpins: false,
1082
+ },
1083
+ sticky: { enabled: false, symbols: ['wild'], durationSpins: 3, ringColor: 0xec4899 },
1084
+ walkingWild: {
1085
+ enabled: false,
1086
+ symbol: 'wild',
1087
+ direction: 'left',
1088
+ stepPerSpin: 1,
1089
+ awardsRespin: true,
1090
+ asStacked: false,
1091
+ },
1092
+ multiplier: {
1093
+ enabled: false,
1094
+ symbol: null,
1095
+ scope: 'perSymbol',
1096
+ combine: 'additive',
1097
+ attachedToWild: false,
1098
+ max: 128,
1099
+ accumulateAcrossFreeSpins: false,
1100
+ },
1101
+ mystery: { enabled: false, symbol: 'mystery', revealPool: [], canRevealWild: false, ms: 300 },
1102
+ transform: {
1103
+ enabled: false,
1104
+ trigger: null,
1105
+ source: 'randomLow',
1106
+ target: 'wild',
1107
+ allInstances: true,
1108
+ upgradeOnly: false,
1109
+ ms: 320,
1110
+ },
1111
+ giant: {
1112
+ enabled: false,
1113
+ width: 2,
1114
+ height: 2,
1115
+ symbols: [],
1116
+ chosenPerSpin: false,
1117
+ onlyInFreeSpins: false,
1118
+ },
1119
+ split: { enabled: false, symbol: 'split', factor: 2, reels: [], deferUntilRespinsEnd: true },
1120
+ stacked: { enabled: false, symbols: [], height: 3 },
1121
+ nudge: {
1122
+ enabled: false,
1123
+ reels: [],
1124
+ step: 1,
1125
+ toFullReel: false,
1126
+ multiplierStart: 1,
1127
+ multiplierPerNudge: 1,
1128
+ },
1129
+ reelModifier: { enabled: false, pool: [], appliesIn: 'both' },
1130
+ holdAndSpin: {
1131
+ enabled: false,
1132
+ lockSymbols: ['coin'],
1133
+ triggerThreshold: 6,
1134
+ respinsAwarded: 3,
1135
+ resetOnNewSymbol: true,
1136
+ jackpotTiers: ['Mini', 'Minor', 'Major', 'Grand'],
1137
+ fullGridAwardsGrand: true,
1138
+ },
1139
+ randomWild: {
1140
+ enabled: false,
1141
+ count: [1, 3],
1142
+ sticky: false,
1143
+ multiplier: 1,
1144
+ trigger: 'onFreeSpins',
1145
+ chance: 0.2,
1146
+ },
1147
+ },
1148
+ };
1149
+ /** Intensity → duration scale (accessibility). */
1150
+ const INTENSITY_SCALE = { full: 1, reduced: 0.7, minimal: 0.4 };
1151
+ function isPlainObject(v) {
1152
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
1153
+ }
1154
+ /** Deep-merge `partial` onto `base` (arrays replace, objects merge). Returns a new object. */
1155
+ function mergeReelConfig(base, partial) {
1156
+ if (partial == null)
1157
+ return structuredCloneSafe(base);
1158
+ const out = structuredCloneSafe(base);
1159
+ for (const [k, v] of Object.entries(partial)) {
1160
+ if (v === undefined)
1161
+ continue;
1162
+ const cur = out[k];
1163
+ if (isPlainObject(v) && isPlainObject(cur))
1164
+ out[k] = mergeReelConfig(cur, v);
1165
+ else
1166
+ out[k] = v;
1167
+ }
1168
+ return out;
1169
+ }
1170
+ /** Resolve a partial config to a fully-populated `ReelSystemConfig`. */
1171
+ function resolveReelConfig(partial) {
1172
+ return mergeReelConfig(DEFAULT_REEL_CONFIG, partial);
1173
+ }
1174
+ function structuredCloneSafe(v) {
1175
+ // structuredClone is available in modern browsers + Node 17+; fall back to JSON for safety.
1176
+ if (typeof structuredClone === 'function')
1177
+ return structuredClone(v);
1178
+ return JSON.parse(JSON.stringify(v));
1179
+ }
1180
+ /** Effective per-reel row counts (resolves Megaways `rowsPerReel`, else uniform `rows`). */
1181
+ function effectiveRowsPerReel(grid) {
1182
+ if (grid.rowsPerReel && grid.rowsPerReel.length === grid.cols)
1183
+ return grid.rowsPerReel.slice();
1184
+ return Array.from({ length: grid.cols }, () => grid.rows);
1185
+ }
1186
+ /** Total ways-to-win for a ways/megaways grid (product of per-reel heights). */
1187
+ function waysCount(grid) {
1188
+ return effectiveRowsPerReel(grid).reduce((a, b) => a * b, 1);
1189
+ }
1190
+
1191
+ // packages/game-engine/src/slot/config/presets.ts
1192
+ //
1193
+ // Reel presets distilled from our shipped games (see docs/reels-analysis-and-design.md §2).
1194
+ // Each is a DeepPartial override on DEFAULT_REEL_CONFIG.
1195
+ const PRESETS = {
1196
+ classic: {
1197
+ id: 'classic',
1198
+ name: 'Classic 5×3 lines',
1199
+ note: 'Plain spinning reels, sequential stops, settle bounce.',
1200
+ config: {
1201
+ grid: { cols: 5, rows: 3, evaluation: 'lines', cellSize: 96, gap: 6 },
1202
+ motion: {
1203
+ style: 'strip',
1204
+ spinUp: 600,
1205
+ stopStagger: 140,
1206
+ blur: { enabled: true, alpha: 0.85, strength: 6, streaks: false },
1207
+ },
1208
+ },
1209
+ },
1210
+ 'kitsune-wrath': {
1211
+ id: 'kitsune-wrath',
1212
+ name: 'Kitsune Wrath 7×7 cluster',
1213
+ note: 'Tumble grid, sticky wilds, position multipliers, scatter anticipation.',
1214
+ config: {
1215
+ grid: { cols: 7, rows: 7, evaluation: 'cluster', cellSize: 66, gap: 0 },
1216
+ motion: {
1217
+ style: 'cascade-drop',
1218
+ spinUp: 400,
1219
+ squash: { enabled: true, scaleX: 1.12, scaleY: 0.88, ms: 90 },
1220
+ },
1221
+ cascade: {
1222
+ enabled: true,
1223
+ gravity: true,
1224
+ dimNonWinners: true,
1225
+ multiplier: {
1226
+ enabled: true,
1227
+ start: 1,
1228
+ mode: 'mul',
1229
+ step: 2,
1230
+ cap: 128,
1231
+ persistInFreeSpins: false,
1232
+ },
1233
+ },
1234
+ anticipation: { enabled: true, triggerSymbols: ['scatter'], threshold: 4 },
1235
+ features: {
1236
+ sticky: { enabled: true, durationSpins: 3 },
1237
+ multiplier: { enabled: true, scope: 'perSymbol', combine: 'multiplicative', max: 128 },
1238
+ },
1239
+ },
1240
+ },
1241
+ 'moon-spice': {
1242
+ id: 'moon-spice',
1243
+ name: 'Moon Spice 5×4 ways',
1244
+ note: 'Spin + cascade refill, frame shake on wild/scatter, 1024 ways.',
1245
+ config: {
1246
+ grid: { cols: 5, rows: 4, evaluation: 'ways', cellSize: 90, gap: 4 },
1247
+ motion: {
1248
+ style: 'strip',
1249
+ spinUp: 1200,
1250
+ stopStagger: 120,
1251
+ settle: { amp: 6, ms: 220, easing: 'easeOutQuad' },
1252
+ },
1253
+ cascade: {
1254
+ enabled: true,
1255
+ gravity: true,
1256
+ easings: { highlight: 'easeOutQuad', remove: 'easeInBack', drop: 'easeOutBounce' },
1257
+ },
1258
+ win: { frameShake: { enabled: true, amp: 2.2, ms: 180, onlyOnSymbols: ['wild', 'scatter'] } },
1259
+ features: { multiplier: { enabled: true, scope: 'perSymbol', combine: 'additive' } },
1260
+ },
1261
+ },
1262
+ 'stone-rush': {
1263
+ id: 'stone-rush',
1264
+ name: 'Stone Rush 7×7 tumble',
1265
+ note: 'Weighty gravity with squash/stretch, per-step deceleration, multiplier orbs.',
1266
+ config: {
1267
+ grid: { cols: 7, rows: 7, evaluation: 'cluster', cellSize: 66, gap: 8 },
1268
+ motion: {
1269
+ style: 'cascade-drop',
1270
+ spinUp: 320,
1271
+ squash: { enabled: true, scaleX: 1.3, scaleY: 0.7, ms: 110 },
1272
+ },
1273
+ cascade: {
1274
+ enabled: true,
1275
+ gravity: true,
1276
+ perStepDecel: 0.08,
1277
+ perStepDecelCap: 1.5,
1278
+ multiplier: {
1279
+ enabled: true,
1280
+ start: 1,
1281
+ mode: 'add',
1282
+ step: 1,
1283
+ cap: 100,
1284
+ persistInFreeSpins: true,
1285
+ },
1286
+ },
1287
+ features: {
1288
+ multiplier: { enabled: true, scope: 'global', combine: 'additive', max: 100 },
1289
+ expandingWild: { enabled: true, symbol: 'wild', toFullReel: false },
1290
+ },
1291
+ },
1292
+ },
1293
+ 'hot-ross': {
1294
+ id: 'hot-ross',
1295
+ name: 'Hot Ross 5×5 lines',
1296
+ note: 'Classic strip spin with motion blur, scatter tease, expanding wilds, sticky reels.',
1297
+ config: {
1298
+ grid: { cols: 5, rows: 5, evaluation: 'lines', cellSize: 88, gap: 4 },
1299
+ motion: {
1300
+ style: 'strip',
1301
+ spinUp: 1650,
1302
+ stopStagger: 210,
1303
+ blur: { enabled: true, alpha: 0.82, strength: 8, streaks: true },
1304
+ settle: { amp: 7, ms: 240, easing: 'easeOutBack' },
1305
+ },
1306
+ anticipation: {
1307
+ enabled: true,
1308
+ triggerSymbols: ['scatter'],
1309
+ threshold: 2,
1310
+ slowdownFactor: 0.28,
1311
+ holdMs: 450,
1312
+ },
1313
+ features: {
1314
+ expandingWild: { enabled: true, symbol: 'wild', reels: [1, 2, 3], toFullReel: true },
1315
+ sticky: { enabled: true, durationSpins: 0 },
1316
+ },
1317
+ },
1318
+ },
1319
+ magnus: {
1320
+ id: 'magnus',
1321
+ name: 'Magnus 6×6 transmute',
1322
+ note: 'Cascade with idle-breathing symbols, scatter anticipation + reel zoom, transform.',
1323
+ config: {
1324
+ grid: { cols: 6, rows: 6, evaluation: 'cluster', cellSize: 74, gap: 2 },
1325
+ motion: {
1326
+ style: 'cascade-drop',
1327
+ spinUp: 400,
1328
+ squash: { enabled: true, scaleX: 1.14, scaleY: 0.86, ms: 110 },
1329
+ },
1330
+ cascade: {
1331
+ enabled: true,
1332
+ gravity: true,
1333
+ multiplier: {
1334
+ enabled: true,
1335
+ start: 1,
1336
+ mode: 'add',
1337
+ step: 1,
1338
+ cap: null,
1339
+ persistInFreeSpins: true,
1340
+ },
1341
+ },
1342
+ anticipation: {
1343
+ enabled: true,
1344
+ triggerSymbols: ['scatter'],
1345
+ threshold: 2,
1346
+ holdMs: 280,
1347
+ zoom: { enabled: true, scale: 1.3, ms: 600 },
1348
+ },
1349
+ features: {
1350
+ transform: {
1351
+ enabled: true,
1352
+ source: 'randomLow',
1353
+ target: 'h1',
1354
+ allInstances: true,
1355
+ upgradeOnly: true,
1356
+ },
1357
+ },
1358
+ },
1359
+ },
1360
+ megaways: {
1361
+ id: 'megaways',
1362
+ name: 'Megaways 6× (2–7)',
1363
+ note: 'Variable per-reel heights, tumble + climbing multiplier, anticipation.',
1364
+ config: {
1365
+ grid: {
1366
+ cols: 6,
1367
+ rows: 4,
1368
+ rowsPerReel: [4, 6, 3, 7, 5, 2],
1369
+ evaluation: 'megaways',
1370
+ cellSize: 74,
1371
+ gap: 4,
1372
+ minRows: 2,
1373
+ maxRows: 7,
1374
+ },
1375
+ motion: { style: 'strip', spinUp: 900, stopStagger: 130 },
1376
+ cascade: {
1377
+ enabled: true,
1378
+ gravity: true,
1379
+ multiplier: {
1380
+ enabled: true,
1381
+ start: 1,
1382
+ mode: 'add',
1383
+ step: 1,
1384
+ cap: null,
1385
+ persistInFreeSpins: true,
1386
+ },
1387
+ },
1388
+ anticipation: { enabled: true, triggerSymbols: ['scatter'], threshold: 3 },
1389
+ },
1390
+ },
1391
+ };
1392
+
1393
+ // packages/game-engine/src/slot/motion/SpinEngine.ts
1394
+ //
1395
+ // Configurable spin motion. Supports three styles — 'swap' (texture-swap ring),
1396
+ // 'strip' (a tape of symbols slides past a masked window) and 'cascade-drop'
1397
+ // (symbols drop in from above) — plus stop modes, turbo/intensity scaling, motion
1398
+ // blur, settle-bounce, squash-on-impact and slam (quick) stop.
1399
+ //
1400
+ // Presentation only: the landing grid is the already-resolved outcome. The engine
1401
+ // never re-rolls a result.
1402
+ class SpinEngine {
1403
+ _grid;
1404
+ _resolve;
1405
+ _cfg;
1406
+ _win = DEFAULT_REEL_CONFIG.win;
1407
+ _killed = false;
1408
+ _shaking = false;
1409
+ _temp = [];
1410
+ constructor(grid, resolve, cfg, win) {
1411
+ this._grid = grid;
1412
+ this._resolve = resolve;
1413
+ this._cfg = cfg;
1414
+ if (win)
1415
+ this._win = win;
1416
+ }
1417
+ setConfig(cfg) {
1418
+ this._cfg = cfg;
1419
+ }
1420
+ setWin(win) {
1421
+ this._win = win;
1422
+ }
1423
+ /** Quick decaying frame shake when a landed reel carries a configured trigger symbol. */
1424
+ async _frameShake(landing) {
1425
+ const fs = this._win.frameShake;
1426
+ if (!fs.enabled || this._shaking || this._killed)
1427
+ return;
1428
+ const triggers = fs.onlyOnSymbols;
1429
+ if (triggers && !landing.some((c) => c.symbol && triggers.includes(c.symbol)))
1430
+ return;
1431
+ this._shaking = true;
1432
+ const baseX = this._grid.x;
1433
+ const amp = fs.amp * INTENSITY_SCALE[this._cfg.intensity];
1434
+ const steps = 4;
1435
+ for (let i = 0; i < steps && !this._killed; i++) {
1436
+ const a = amp * (1 - i / steps) * (i % 2 ? -1 : 1);
1437
+ await Tween.to(this._grid, { x: baseX + a }, fs.ms / (steps + 1), EASING_BY_NAME['easeOutQuad']);
1438
+ }
1439
+ if (!this._killed && !this._grid.destroyed)
1440
+ this._grid.x = baseX;
1441
+ this._shaking = false;
1442
+ }
1443
+ scale(opts) {
1444
+ const turbo = opts?.turbo ? this._cfg.turboFactor : 1;
1445
+ return turbo * INTENSITY_SCALE[this._cfg.intensity];
1446
+ }
1447
+ /** PURE: per-reel stop schedule. No Pixi mutation. */
1448
+ plan(data, opts) {
1449
+ const f = this.scale(opts);
1450
+ const cols = this._grid.cols;
1451
+ const order = (reel) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
1452
+ const anticipate = new Set(opts?.anticipateReels ?? []);
1453
+ const out = [];
1454
+ for (let reel = 0; reel < cols; reel++) {
1455
+ const idx = order(reel);
1456
+ let stopTime;
1457
+ if (this._cfg.stopMode === 'sync')
1458
+ stopTime = (this._cfg.spinUp + this._cfg.hold) * f;
1459
+ else if (this._cfg.stopMode === 'random')
1460
+ stopTime =
1461
+ (this._cfg.spinUp +
1462
+ this._cfg.hold +
1463
+ idx * this._cfg.stopStagger * (0.4 + (reel % 3) * 0.3)) *
1464
+ f;
1465
+ else
1466
+ stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
1467
+ const isAnticipated = anticipate.has(reel);
1468
+ if (isAnticipated)
1469
+ stopTime += (opts?.anticipateHoldMs ?? 0) * f;
1470
+ out.push({
1471
+ reel,
1472
+ stopTime,
1473
+ landing: data.targetGrid[reel] ?? [],
1474
+ settle: { amp: this._cfg.settle.amp, ms: this._cfg.settle.ms * f },
1475
+ anticipated: isAnticipated,
1476
+ });
1477
+ }
1478
+ return out;
1479
+ }
1480
+ /** Execute the spin for every reel concurrently. */
1481
+ async run(data, opts) {
1482
+ this._killed = false;
1483
+ this._temp = [];
1484
+ const plan = this.plan(data, opts);
1485
+ const f = this.scale(opts);
1486
+ await Promise.all(plan.map((p) => this._runReel(p, data, opts, f)));
1487
+ this._cleanupTemp();
1488
+ }
1489
+ async _runReel(p, data, opts, f) {
1490
+ if (this._killed)
1491
+ return;
1492
+ switch (this._cfg.style) {
1493
+ case 'strip':
1494
+ return this._runStrip(p, data, opts, f);
1495
+ case 'cascade-drop':
1496
+ return this._runDrop(p, opts, f);
1497
+ case 'swap':
1498
+ default:
1499
+ return this._runSwap(p, data, opts, f);
1500
+ }
1501
+ }
1502
+ /** Anticipation time-stretch factor for a reel (>=1, longer = slower). */
1503
+ slowOf(p, opts) {
1504
+ return p.anticipated ? Math.max(1, 1 / (opts?.anticipateSlowdown ?? 1)) : 1;
1505
+ }
1506
+ // ── swap: cycle symbols quickly in the real cells, then land ──────────────
1507
+ async _runSwap(p, data, opts, f) {
1508
+ const rows = this._grid.rowsOf(p.reel);
1509
+ const cells = Array.from({ length: rows }, (_, r) => this._grid.getCell(p.reel, r));
1510
+ const strip = data.strip?.(p.reel) ?? p.landing.map((c) => c.symbol ?? '').filter(Boolean);
1511
+ const tape = strip.length ? strip : ['?'];
1512
+ const blur = this._applyBlur(cells, true);
1513
+ const tickMs = 1000 / 30;
1514
+ // anticipation makes the reel spin longer before it lands
1515
+ const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p, opts)) / tickMs));
1516
+ for (let i = 0; i < ticks; i++) {
1517
+ if (this._killed)
1518
+ break;
1519
+ for (let r = 0; r < cells.length; r++)
1520
+ cells[r].setData({ symbol: tape[(i + r) % tape.length] || null });
1521
+ await Tween.delay(tickMs);
1522
+ }
1523
+ blur?.();
1524
+ for (let r = 0; r < cells.length; r++)
1525
+ cells[r].setData(p.landing[r] ?? { symbol: null });
1526
+ await this._settle(p.reel, p.settle, f);
1527
+ await this._frameShake(p.landing);
1528
+ }
1529
+ // ── strip: a tape Container slides down past the window, then lands ────────
1530
+ async _runStrip(p, data, opts, f) {
1531
+ const rows = this._grid.rowsOf(p.reel);
1532
+ const realCells = Array.from({ length: rows }, (_, r) => this._grid.getCell(p.reel, r));
1533
+ const base = this._grid.cellPosition(p.reel, 0);
1534
+ const step = this._grid.cellPosition(p.reel, 1).y - base.y;
1535
+ const tapeLen = Math.max(this._cfg.symbolsPerReel, rows + 4);
1536
+ const strip = data.strip?.(p.reel) ?? p.landing.map((c) => c.symbol ?? '').filter(Boolean);
1537
+ const pool = strip.length ? strip : ['?'];
1538
+ // Tape cells laid out top→bottom at local y = i*step. The bottom `rows` cells carry the
1539
+ // landing symbols; everything above is filler.
1540
+ const tape = new pixi_js.Container();
1541
+ tape.x = base.x;
1542
+ const landingStart = tapeLen - rows;
1543
+ for (let i = 0; i < tapeLen; i++) {
1544
+ const cell = new SymbolCell({ size: this._grid.cellSize(p.reel), resolve: this._resolve });
1545
+ const sym = i >= landingStart
1546
+ ? (p.landing[i - landingStart]?.symbol ?? null)
1547
+ : (pool[i % pool.length] ?? null);
1548
+ cell.setData({ symbol: sym });
1549
+ cell.position.set(0, i * step);
1550
+ tape.addChild(cell);
1551
+ }
1552
+ // At rest the bottom block aligns with the window; start shifted up by the whole tape.
1553
+ const restY = base.y - landingStart * step;
1554
+ const startY = restY - tapeLen * step;
1555
+ tape.y = startY;
1556
+ realCells.forEach((c) => (c.visible = false));
1557
+ this._grid.addChild(tape);
1558
+ this._temp.push(tape);
1559
+ const clearBlur = this._applyBlur([tape], true);
1560
+ const slow = this.slowOf(p, opts);
1561
+ // overshoot/settle honour the configured settle (amp in px, easing)
1562
+ const overshoot = p.settle.amp || step * 0.18;
1563
+ await Tween.to(tape, { y: restY + overshoot }, p.stopTime * slow, easingByName('easeInOutQuad'));
1564
+ if (this._killed) {
1565
+ clearBlur?.();
1566
+ return;
1567
+ }
1568
+ clearBlur?.();
1569
+ await Tween.to(tape, { y: restY }, Math.max(120, p.settle.ms), easingByName(this._cfg.settle.easing));
1570
+ // hand the result back to the real cells
1571
+ for (let r = 0; r < rows; r++)
1572
+ realCells[r].setData(p.landing[r] ?? { symbol: null });
1573
+ realCells.forEach((c) => (c.visible = true));
1574
+ tape.destroy();
1575
+ this._temp = this._temp.filter((t) => t !== tape);
1576
+ // squash the real cells on impact when enabled
1577
+ if (this._cfg.squash.enabled)
1578
+ await Promise.all(realCells.map((c) => this._squashCell(c, f)));
1579
+ await this._frameShake(p.landing);
1580
+ }
1581
+ // ── cascade-drop: symbols drop in from above with stagger + bounce + squash ─
1582
+ async _runDrop(p, opts, f) {
1583
+ const rows = this._grid.rowsOf(p.reel);
1584
+ const step = this._grid.cellPosition(p.reel, 1).y - this._grid.cellPosition(p.reel, 0).y;
1585
+ const slow = this.slowOf(p, opts); // anticipation drops the reel in more slowly
1586
+ await Promise.all(Array.from({ length: rows }, (_, r) => r).map(async (r) => {
1587
+ if (this._killed)
1588
+ return;
1589
+ const cell = this._grid.getCell(p.reel, r);
1590
+ const to = this._grid.cellPosition(p.reel, r);
1591
+ cell.setData(p.landing[r] ?? { symbol: null });
1592
+ cell.position.set(to.x, to.y - step * (rows + 1));
1593
+ cell.alpha = 1;
1594
+ const delay = (p.reel * this._cfg.stopStagger * 0.4 + r * 24) * f * slow;
1595
+ if (delay)
1596
+ await Tween.delay(delay);
1597
+ await Tween.to(cell, { 'position.y': to.y }, this._cfg.spinUp * 0.6 * f * slow, easingByName(this._cfg.settle.easing));
1598
+ await this._squashCell(cell, f);
1599
+ }));
1600
+ await this._frameShake(p.landing);
1601
+ }
1602
+ // ── shared helpers ────────────────────────────────────────────────────────
1603
+ async _settle(reel, settle, f) {
1604
+ if (this._killed || settle.amp <= 0)
1605
+ return;
1606
+ const cells = Array.from({ length: this._grid.rowsOf(reel) }, (_, r) => this._grid.getCell(reel, r));
1607
+ const parent = cells[0]?.parent;
1608
+ if (!parent)
1609
+ return;
1610
+ // bounce the whole reel column by moving each cell, then squash the impact
1611
+ await Promise.all(cells.map((c) => {
1612
+ const y = c.y;
1613
+ return Tween.fromTo(c, { y: y - settle.amp }, { y }, settle.ms, EASING_BY_NAME[this._cfg.settle.easing] ?? EASING_BY_NAME['easeOutBack']);
1614
+ }));
1615
+ if (this._cfg.squash.enabled)
1616
+ await Promise.all(cells.map((c) => this._squashCell(c, f)));
1617
+ }
1618
+ async _squashCell(cell, f) {
1619
+ if (!this._cfg.squash.enabled || this._killed)
1620
+ return;
1621
+ const { scaleX, scaleY, ms } = this._cfg.squash;
1622
+ await Tween.to(cell, { 'scale.x': scaleX, 'scale.y': scaleY }, ms * 0.5 * f, EASING_BY_NAME['easeOutQuad']);
1623
+ await Tween.to(cell, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.5 * f, EASING_BY_NAME['easeOutBack']);
1624
+ }
1625
+ /** Apply motion blur (alpha + optional BlurFilter) to targets; returns a disposer that clears it. */
1626
+ _applyBlur(targets, _motion) {
1627
+ if (!this._cfg.blur.enabled)
1628
+ return null;
1629
+ const filter = this._cfg.blur.strength > 0
1630
+ ? new pixi_js.BlurFilter({ strength: this._cfg.blur.strength, quality: 2 })
1631
+ : null;
1632
+ for (const t of targets) {
1633
+ t.alpha = this._cfg.blur.alpha;
1634
+ if (filter)
1635
+ t.filters = [filter];
1636
+ }
1637
+ return () => {
1638
+ for (const t of targets) {
1639
+ t.alpha = 1;
1640
+ t.filters = [];
1641
+ }
1642
+ filter?.destroy();
1643
+ };
1644
+ }
1645
+ _cleanupTemp() {
1646
+ for (const t of this._temp) {
1647
+ if (!t.destroyed)
1648
+ t.destroy();
1649
+ }
1650
+ this._temp = [];
1651
+ }
1652
+ /** Slam / quick stop: snap everything to the target and stop animating. */
1653
+ skip() {
1654
+ if (!this._cfg.slamStop && !this._killed) ;
1655
+ this._killed = true;
1656
+ this._shaking = false;
1657
+ this._cleanupTemp();
1658
+ if (this._grid.destroyed)
1659
+ return;
1660
+ Tween.killTweensOf(this._grid);
1661
+ this._grid.x = 0; // undo any in-flight frame shake
1662
+ for (let c = 0; c < this._grid.cols; c++) {
1663
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
1664
+ const cell = this._grid.getCell(c, r);
1665
+ if (cell.destroyed)
1666
+ continue;
1667
+ Tween.killTweensOf(cell);
1668
+ cell.visible = true;
1669
+ cell.alpha = 1;
1670
+ cell.filters = [];
1671
+ cell.scale.set(1);
1672
+ }
1673
+ }
1674
+ this._cleanupTemp();
1675
+ }
1676
+ }
1677
+
1678
+ // packages/game-engine/src/slot/motion/AnticipationController.ts
1679
+ //
1680
+ // Decides which trailing reels get the "anticipation" slow-down treatment, based purely on
1681
+ // the already-resolved landing grid (presentation only — never a secondary outcome decision).
1682
+ class AnticipationController {
1683
+ _cfg;
1684
+ constructor(cfg) {
1685
+ this._cfg = cfg;
1686
+ }
1687
+ setConfig(cfg) {
1688
+ this._cfg = cfg;
1689
+ }
1690
+ /** Count how many trigger symbols land on a given reel. */
1691
+ countOnReel(reel) {
1692
+ let n = 0;
1693
+ for (const c of reel)
1694
+ if (c?.symbol && this._cfg.triggerSymbols.includes(c.symbol))
1695
+ n++;
1696
+ return n;
1697
+ }
1698
+ /**
1699
+ * Decide anticipation from the landing grid. Trigger symbols are counted left→right;
1700
+ * once the running total reaches `threshold`, every still-spinning reel after that point
1701
+ * is flagged for the slow treatment (this mirrors "searching for the last scatter").
1702
+ */
1703
+ decide(targetGrid) {
1704
+ if (!this._cfg.enabled)
1705
+ return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1706
+ if (Array.isArray(this._cfg.reels)) {
1707
+ // explicit reel list — arm only if the threshold is met somewhere on the board
1708
+ const total = targetGrid.reduce((sum, reel) => sum + this.countOnReel(reel), 0);
1709
+ const active = total >= this._cfg.threshold;
1710
+ return active
1711
+ ? {
1712
+ active,
1713
+ reels: this._cfg.reels.slice(),
1714
+ slowdown: this._cfg.slowdownFactor,
1715
+ holdMs: this._cfg.holdMs,
1716
+ }
1717
+ : { active: false, reels: [], slowdown: 1, holdMs: 0 };
1718
+ }
1719
+ // 'trailing': find the reel where the cumulative count hits the threshold
1720
+ let running = 0;
1721
+ let armReel = -1;
1722
+ for (let c = 0; c < targetGrid.length; c++) {
1723
+ running += this.countOnReel(targetGrid[c] ?? []);
1724
+ if (running >= this._cfg.threshold) {
1725
+ armReel = c;
1726
+ break;
1727
+ }
1728
+ }
1729
+ if (armReel < 0)
1730
+ return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1731
+ const reels = [];
1732
+ for (let c = armReel + 1; c < targetGrid.length; c++)
1733
+ reels.push(c);
1734
+ if (reels.length === 0)
1735
+ return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1736
+ return { active: true, reels, slowdown: this._cfg.slowdownFactor, holdMs: this._cfg.holdMs };
1737
+ }
1738
+ /** Optionally zoom the grid in while anticipating, then settle back. Returns a reset fn. */
1739
+ async zoomIn(grid) {
1740
+ if (!this._cfg.zoom.enabled)
1741
+ return async () => { };
1742
+ const sx = grid.scale.x;
1743
+ const sy = grid.scale.y;
1744
+ await Tween.to(grid, { 'scale.x': sx * this._cfg.zoom.scale, 'scale.y': sy * this._cfg.zoom.scale }, this._cfg.zoom.ms, easingByName('easeOutCubic'));
1745
+ return async () => {
1746
+ await Tween.to(grid, { 'scale.x': sx, 'scale.y': sy }, this._cfg.zoom.ms, easingByName('easeOutCubic'));
1747
+ };
1748
+ }
1749
+ }
1750
+
1751
+ // packages/game-engine/src/slot/cascade/TumbleController.ts
1752
+ //
1753
+ // Richer cascade/tumble than the back-compat CascadeController: animates surviving symbols
1754
+ // sliding into the gaps (gravity), supports per-step deceleration (tension build), dimming of
1755
+ // non-winning symbols, and a running win multiplier (Pragmatic Tumble / NetEnt Avalanche style).
1756
+ //
1757
+ // Presentation only — the settled board is provided by the caller.
1758
+ class TumbleController {
1759
+ _grid;
1760
+ _cfg;
1761
+ _win = DEFAULT_REEL_CONFIG.win;
1762
+ _killed = false;
1763
+ _mult;
1764
+ constructor(grid, cfg, win) {
1765
+ this._grid = grid;
1766
+ this._cfg = cfg;
1767
+ this._mult = cfg.multiplier.start;
1768
+ if (win)
1769
+ this._win = win;
1770
+ }
1771
+ setConfig(cfg) {
1772
+ this._cfg = cfg;
1773
+ }
1774
+ setWin(win) {
1775
+ this._win = win;
1776
+ }
1777
+ /** Killed, or the grid was torn down underneath us (rebuild during a cascade). */
1778
+ get _dead() {
1779
+ return this._killed || this._grid.destroyed;
1780
+ }
1781
+ get multiplier() {
1782
+ return this._mult;
1783
+ }
1784
+ resetMultiplier() {
1785
+ this._mult = this._cfg.multiplier.start;
1786
+ }
1787
+ advanceMultiplier() {
1788
+ const m = this._cfg.multiplier;
1789
+ if (!m.enabled)
1790
+ return;
1791
+ const next = m.mode === 'mul' ? this._mult * m.step : this._mult + m.step;
1792
+ this._mult = m.cap != null ? Math.min(next, m.cap) : next;
1793
+ }
1794
+ /** Derive survivor slides for one column when explicit drops aren't supplied. */
1795
+ deriveDrops(step) {
1796
+ const removedByCol = new Map();
1797
+ for (const r of step.removedCells) {
1798
+ if (!removedByCol.has(r.col))
1799
+ removedByCol.set(r.col, new Set());
1800
+ removedByCol.get(r.col).add(r.row);
1801
+ }
1802
+ const out = [];
1803
+ for (const [col, removed] of removedByCol) {
1804
+ const rows = this._grid.rowsOf(col);
1805
+ // survivors keep order and fall to the bottom; count holes below each survivor
1806
+ const survivors = [];
1807
+ for (let r = 0; r < rows; r++)
1808
+ if (!removed.has(r))
1809
+ survivors.push(r);
1810
+ const newCount = rows - survivors.length;
1811
+ survivors.forEach((fromRow, i) => {
1812
+ const toRow = newCount + i;
1813
+ if (toRow !== fromRow)
1814
+ out.push({ col, fromRow, toRow });
1815
+ });
1816
+ }
1817
+ return out;
1818
+ }
1819
+ /** Run a single cascade step. `stepIndex` drives per-step deceleration. */
1820
+ async step(step, stepIndex = 0, opts) {
1821
+ if (this._grid.destroyed)
1822
+ return;
1823
+ if (!this._cfg.enabled) {
1824
+ this._grid.setGrid(step.settledGrid);
1825
+ return;
1826
+ }
1827
+ this._killed = false;
1828
+ const turbo = opts?.turbo ? 0.5 : 1;
1829
+ const decel = Math.min(this._cfg.perStepDecelCap, 1 + stepIndex * this._cfg.perStepDecel);
1830
+ const f = turbo * decel;
1831
+ const t = this._cfg.timings;
1832
+ // 1. highlight winners (+ dim others). win.highlightScale / win.glow drive the pop.
1833
+ const winSet = new Set(step.winningCells.map((w) => `${w.col}:${w.row}`));
1834
+ const dimmed = this._cfg.dimNonWinners && step.winningCells.length > 0;
1835
+ if (dimmed)
1836
+ this._dim(winSet);
1837
+ const hs = this._win.highlightScale;
1838
+ await Promise.all(step.winningCells.map((w) => {
1839
+ const cell = this._grid.getCell(w.col, w.row);
1840
+ if (this._win.glow)
1841
+ cell.setState({ winning: true });
1842
+ return Tween.to(cell, { 'scale.x': hs, 'scale.y': hs }, t.highlight * f, easingByName(this._cfg.easings.highlight));
1843
+ }));
1844
+ if (this._dead) {
1845
+ if (dimmed)
1846
+ this._undim();
1847
+ return;
1848
+ }
1849
+ await Tween.delay(t.wait * f);
1850
+ // 2. remove the cleared cells (removedCells, falling back to winningCells)
1851
+ const cleared = step.removedCells.length ? step.removedCells : step.winningCells;
1852
+ await Promise.all(cleared.map((w) => {
1853
+ const cell = this._grid.getCell(w.col, w.row);
1854
+ return Tween.to(cell, { 'scale.x': 0, 'scale.y': 0, alpha: 0 }, t.remove * f, easingByName(this._cfg.easings.remove));
1855
+ }));
1856
+ if (this._dead) {
1857
+ if (dimmed)
1858
+ this._undim();
1859
+ return;
1860
+ }
1861
+ for (const w of cleared) {
1862
+ const cell = this._grid.getCell(w.col, w.row);
1863
+ cell.setData({ symbol: null });
1864
+ cell.setState({});
1865
+ cell.scale.set(1);
1866
+ cell.alpha = 1;
1867
+ }
1868
+ this._undim();
1869
+ // 3. gravity: survivors slide down, new cells drop from above (per-reel row step)
1870
+ const rowStepOf = (col) => this._grid.rowsOf(col) > 1
1871
+ ? this._grid.cellPosition(col, 1).y - this._grid.cellPosition(col, 0).y
1872
+ : this._grid.cellSize(col).height;
1873
+ const slides = this._cfg.gravity ? (step.drops ?? this.deriveDrops(step)) : [];
1874
+ const anims = [];
1875
+ for (const d of slides) {
1876
+ const cell = this._grid.getCell(d.col, d.toRow);
1877
+ const home = this._grid.cellPosition(d.col, d.toRow);
1878
+ cell.setData(step.settledGrid[d.col]?.[d.toRow] ?? { symbol: null });
1879
+ cell.alpha = 1;
1880
+ cell.scale.set(1);
1881
+ cell.position.set(home.x, this._grid.cellPosition(d.col, d.fromRow).y);
1882
+ anims.push(Tween.to(cell, { 'position.y': home.y }, t.drop * f, easingByName(this._cfg.easings.drop)));
1883
+ }
1884
+ const perCol = {};
1885
+ for (const n of step.newCells) {
1886
+ const cell = this._grid.getCell(n.col, n.row);
1887
+ const home = this._grid.cellPosition(n.col, n.row);
1888
+ cell.setData({ symbol: n.symbol });
1889
+ cell.setState({ fresh: true });
1890
+ cell.alpha = 1;
1891
+ cell.scale.set(1);
1892
+ cell.position.set(home.x, home.y - rowStepOf(n.col) * (this._grid.rowsOf(n.col) + 1));
1893
+ const idx = (perCol[n.col] = (perCol[n.col] ?? 0) + 1);
1894
+ anims.push((async () => {
1895
+ await Tween.delay(idx * 28 * f);
1896
+ if (this._dead)
1897
+ return;
1898
+ await Tween.to(cell, { 'position.y': home.y }, t.refill * f, easingByName(this._cfg.easings.drop));
1899
+ cell.setState({});
1900
+ })());
1901
+ }
1902
+ await Promise.all(anims);
1903
+ if (this._dead)
1904
+ return;
1905
+ // 4. normalise + advance multiplier
1906
+ this._grid.setGrid(step.settledGrid);
1907
+ this._resetPositions();
1908
+ if (step.winningCells.length)
1909
+ this.advanceMultiplier();
1910
+ }
1911
+ _dim(winSet) {
1912
+ for (let c = 0; c < this._grid.cols; c++)
1913
+ for (let r = 0; r < this._grid.rowsOf(c); r++)
1914
+ if (!winSet.has(`${c}:${r}`))
1915
+ this._grid.getCell(c, r).alpha = this._cfg.dimAlpha;
1916
+ }
1917
+ _undim() {
1918
+ for (let c = 0; c < this._grid.cols; c++)
1919
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
1920
+ const cell = this._grid.getCell(c, r);
1921
+ if (cell.alpha !== 0)
1922
+ cell.alpha = 1;
1923
+ }
1924
+ }
1925
+ _resetPositions() {
1926
+ if (this._grid.destroyed)
1927
+ return;
1928
+ for (let c = 0; c < this._grid.cols; c++)
1929
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
1930
+ const cell = this._grid.getCell(c, r);
1931
+ const { x, y } = this._grid.cellPosition(c, r);
1932
+ cell.position.set(x, y);
1933
+ cell.scale.set(1);
1934
+ cell.alpha = 1;
1935
+ }
1936
+ }
1937
+ skip() {
1938
+ this._killed = true;
1939
+ for (let c = 0; c < this._grid.cols; c++)
1940
+ for (let r = 0; r < this._grid.rowsOf(c); r++)
1941
+ Tween.killTweensOf(this._grid.getCell(c, r));
1942
+ this._resetPositions();
1943
+ }
1944
+ }
1945
+
1946
+ // packages/game-engine/src/slot/cascade/ReelStepController.ts
1947
+ //
1948
+ // ReelStep™ mechanic. Flow: reels stop → winning lines are paid → each reel scrolls DOWN by N
1949
+ // positions (N = winning symbols that played on that reel) → the board is re-evaluated → repeat,
1950
+ // until no wins remain. Unlike a cascade/tumble, nothing is removed: the existing symbols ride
1951
+ // down and N fresh symbols enter from the top. Reels with N=0 stay put; each reel moves
1952
+ // independently by its own N.
1953
+ //
1954
+ // Presentation only — the caller supplies each step's per-reel shift vector and the post-shift
1955
+ // board. Fits classic fixed-line grids (5×3, 5×4, 5×5, …), not ways/cluster.
1956
+ /**
1957
+ * PURE: lay out one reel's scroll tape (top→bottom). The `shift` fresh symbols (the top of the
1958
+ * settled reel) stack above the reel's current symbols; the tape starts `shift` cells high so the
1959
+ * current symbols fill the window, then slides down by `shift` to reveal the fresh ones. `shift` is
1960
+ * clamped to the visible window height. Returns the stacked cells and the start offset (in cells,
1961
+ * relative to row 0) the tape animates from.
1962
+ */
1963
+ function buildReelStepTape(before, settledCol, shift) {
1964
+ const rows = before.length;
1965
+ const s = Math.max(0, Math.min(shift, rows));
1966
+ const incoming = Array.from({ length: s }, (_, i) => settledCol[i] ?? { symbol: null });
1967
+ return { stack: [...incoming, ...before], shift: s, startOffsetCells: 0 - s };
1968
+ }
1969
+ class ReelStepController {
1970
+ _grid;
1971
+ _resolve;
1972
+ _cfg;
1973
+ _win = DEFAULT_REEL_CONFIG.win;
1974
+ _killed = false;
1975
+ _mult;
1976
+ _temp = [];
1977
+ /** Board the in-flight step settles to — used to snap on skip(). */
1978
+ _pending = null;
1979
+ constructor(grid, resolve, cfg, win) {
1980
+ this._grid = grid;
1981
+ this._resolve = resolve;
1982
+ this._cfg = cfg;
1983
+ this._mult = cfg.multiplier.start;
1984
+ if (win)
1985
+ this._win = win;
1986
+ }
1987
+ setConfig(cfg) {
1988
+ this._cfg = cfg;
1989
+ }
1990
+ setWin(win) {
1991
+ this._win = win;
1992
+ }
1993
+ /** Killed, or the grid was torn down underneath us (rebuild mid-chain). */
1994
+ get _dead() {
1995
+ return this._killed || this._grid.destroyed;
1996
+ }
1997
+ get multiplier() {
1998
+ return this._mult;
1999
+ }
2000
+ resetMultiplier() {
2001
+ this._mult = this._cfg.multiplier.start;
2002
+ }
2003
+ advanceMultiplier() {
2004
+ const m = this._cfg.multiplier;
2005
+ if (!m.enabled)
2006
+ return;
2007
+ const next = m.mode === 'mul' ? this._mult * m.step : this._mult + m.step;
2008
+ this._mult = m.cap != null ? Math.min(next, m.cap) : next;
2009
+ }
2010
+ /** Run one ReelStep: pay the winning cells, then scroll each reel down by shifts[col]. */
2011
+ async step(step, stepIndex = 0, opts) {
2012
+ if (this._grid.destroyed)
2013
+ return;
2014
+ this._killed = false;
2015
+ this._pending = step.settledGrid;
2016
+ // 1. celebrate/pay the winning cells.
2017
+ await this._payWins(step, opts);
2018
+ if (this._dead)
2019
+ return;
2020
+ // No shift → just settle the board (defensive; a real ReelStep always shifts something).
2021
+ const hasShift = step.shifts.some((n) => n > 0);
2022
+ if (!this._cfg.enabled || !hasShift) {
2023
+ this._grid.setGrid(step.settledGrid);
2024
+ if (step.winningCells.length)
2025
+ this.advanceMultiplier();
2026
+ this._pending = null;
2027
+ return;
2028
+ }
2029
+ // 2. scroll every reel down by its own N (0 = untouched), all reels concurrently.
2030
+ const turbo = opts?.turbo ? 0.5 : 1;
2031
+ const decel = Math.min(this._cfg.perStepDecelCap, 1 + stepIndex * this._cfg.perStepDecel);
2032
+ const f = turbo * decel;
2033
+ await Promise.all(step.shifts.map((n, col) => n > 0 ? this._scrollReel(col, n, step.settledGrid, f) : Promise.resolve()));
2034
+ if (this._dead) {
2035
+ this._cleanupTemp();
2036
+ return;
2037
+ }
2038
+ // 3. normalise + advance multiplier.
2039
+ this._grid.setGrid(step.settledGrid);
2040
+ this._resetPositions();
2041
+ this._cleanupTemp();
2042
+ this._pending = null;
2043
+ if (step.winningCells.length)
2044
+ this.advanceMultiplier();
2045
+ }
2046
+ /** Highlight + hold the winning cells, then release them back to rest before the shift. */
2047
+ async _payWins(step, opts) {
2048
+ if (!step.winningCells.length)
2049
+ return;
2050
+ const turbo = opts?.turbo ? 0.5 : 1;
2051
+ const t = this._cfg.timings;
2052
+ const hs = this._win.highlightScale;
2053
+ const winSet = new Set(step.winningCells.map((w) => `${w.col}:${w.row}`));
2054
+ if (this._cfg.dimNonWinners)
2055
+ this._dim(winSet);
2056
+ await Promise.all(step.winningCells.map((w) => {
2057
+ const cell = this._grid.getCell(w.col, w.row);
2058
+ if (this._win.glow)
2059
+ cell.setState({ winning: true });
2060
+ return Tween.to(cell, { 'scale.x': hs, 'scale.y': hs }, t.highlight * turbo, easingByName(this._cfg.easings.highlight));
2061
+ }));
2062
+ if (this._dead) {
2063
+ this._undim();
2064
+ return;
2065
+ }
2066
+ await Tween.delay(t.wait * turbo);
2067
+ for (const w of step.winningCells) {
2068
+ const cell = this._grid.getCell(w.col, w.row);
2069
+ cell.setState({});
2070
+ cell.scale.set(1);
2071
+ }
2072
+ this._undim();
2073
+ }
2074
+ /**
2075
+ * Scroll one reel down by `n` positions. A tape carrying the reel's current symbols with `n`
2076
+ * fresh symbols stacked on top slides down by `n` cells: the fresh symbols enter from the top,
2077
+ * the existing ones ride down, and the bottom `n` ride off below the window. Ends on
2078
+ * settledGrid[col]. The tape shares the cells' parent (so the reel mask, if any, clips it).
2079
+ */
2080
+ async _scrollReel(col, n, settledGrid, f) {
2081
+ const rows = this._grid.rowsOf(col);
2082
+ if (rows === 0 || this._dead)
2083
+ return;
2084
+ const realCells = Array.from({ length: rows }, (_, r) => this._grid.getCell(col, r));
2085
+ const layer = realCells[0].parent ?? this._grid;
2086
+ const base = this._grid.cellPosition(col, 0);
2087
+ const step = rows > 1 ? this._grid.cellPosition(col, 1).y - base.y : this._grid.cellSize(col).height;
2088
+ // current visible symbols (top→bottom), captured before we hide them
2089
+ const before = realCells.map((c) => ({ ...c.data }));
2090
+ const { stack, startOffsetCells } = buildReelStepTape(before, settledGrid[col] ?? [], n);
2091
+ // Tape laid out top→bottom at local y = i*step: [incoming(shift)] above [before(rows)].
2092
+ const tape = new pixi_js.Container();
2093
+ tape.x = base.x;
2094
+ for (let i = 0; i < stack.length; i++) {
2095
+ const cell = new SymbolCell({ size: this._grid.cellSize(col), resolve: this._resolve });
2096
+ cell.setData(stack[i]);
2097
+ cell.position.set(0, i * step);
2098
+ tape.addChild(cell);
2099
+ }
2100
+ // Start: the `before` block fills the window; the incoming block sits above it (masked off).
2101
+ tape.y = base.y + startOffsetCells * step;
2102
+ realCells.forEach((c) => (c.visible = false));
2103
+ layer.addChild(tape);
2104
+ this._temp.push(tape);
2105
+ // Slide down by `shift` positions, with a small overshoot then settle-back.
2106
+ const overshoot = step * 0.12;
2107
+ await Tween.to(tape, { y: base.y + overshoot }, this._cfg.timings.drop * f, easingByName(this._cfg.easings.drop));
2108
+ if (this._dead)
2109
+ return;
2110
+ await Tween.to(tape, { y: base.y }, Math.max(90, this._cfg.timings.refill * f), easingByName('easeOutQuad'));
2111
+ if (this._dead)
2112
+ return;
2113
+ // Hand the settled symbols back to the real cells.
2114
+ for (let r = 0; r < rows; r++)
2115
+ realCells[r].setData(settledGrid[col]?.[r] ?? { symbol: null });
2116
+ realCells.forEach((c) => (c.visible = true));
2117
+ tape.destroy();
2118
+ this._temp = this._temp.filter((tp) => tp !== tape);
2119
+ }
2120
+ _dim(winSet) {
2121
+ for (let c = 0; c < this._grid.cols; c++)
2122
+ for (let r = 0; r < this._grid.rowsOf(c); r++)
2123
+ if (!winSet.has(`${c}:${r}`))
2124
+ this._grid.getCell(c, r).alpha = this._cfg.dimAlpha;
2125
+ }
2126
+ _undim() {
2127
+ for (let c = 0; c < this._grid.cols; c++)
2128
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
2129
+ const cell = this._grid.getCell(c, r);
2130
+ if (cell.alpha !== 0)
2131
+ cell.alpha = 1;
2132
+ }
2133
+ }
2134
+ _resetPositions() {
2135
+ if (this._grid.destroyed)
2136
+ return;
2137
+ for (let c = 0; c < this._grid.cols; c++)
2138
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
2139
+ const cell = this._grid.getCell(c, r);
2140
+ const { x, y } = this._grid.cellPosition(c, r);
2141
+ cell.position.set(x, y);
2142
+ cell.scale.set(1);
2143
+ cell.alpha = 1;
2144
+ cell.visible = true;
2145
+ }
2146
+ }
2147
+ _cleanupTemp() {
2148
+ for (const t of this._temp)
2149
+ if (!t.destroyed)
2150
+ t.destroy();
2151
+ this._temp = [];
2152
+ }
2153
+ /** Hard-cancel: kill tweens, drop tapes, snap to the in-flight step's settled board. */
2154
+ skip() {
2155
+ this._killed = true;
2156
+ for (let c = 0; c < this._grid.cols; c++)
2157
+ for (let r = 0; r < this._grid.rowsOf(c); r++)
2158
+ Tween.killTweensOf(this._grid.getCell(c, r));
2159
+ this._cleanupTemp();
2160
+ if (this._pending && !this._grid.destroyed)
2161
+ this._grid.setGrid(this._pending);
2162
+ this._pending = null;
2163
+ this._resetPositions();
2164
+ }
2165
+ }
2166
+
2167
+ // packages/game-engine/src/slot/features/types.ts
2168
+ //
2169
+ // Uniform interface for special reel feature mechanics. Each feature is a presentation module:
2170
+ // given the current board + its config (and optional per-spin data), it plays an animation that
2171
+ // visualises the mechanic. `demo()` is the self-contained showcase the reel-lab playground triggers.
2172
+ // ── shared animation helpers ────────────────────────────────────────────────
2173
+ /** Center position of a cell, in fx-layer coordinates (fx shares the grid's transform). */
2174
+ function cellCenter(grid, col, row) {
2175
+ return grid.cellPosition(col, row);
2176
+ }
2177
+ /** Vertical row-to-row step for a reel (falls back to the reel's cell height for 1-row reels). */
2178
+ function rowStepOf(grid, col = 0) {
2179
+ if (grid.rowsOf(col) > 1)
2180
+ return grid.cellPosition(col, 1).y - grid.cellPosition(col, 0).y;
2181
+ return grid.cellSize(col).height;
2182
+ }
2183
+ /** Horizontal reel-to-reel step at a boundary (falls back to the reel's cell width for 1-col grids). */
2184
+ function colStepOf(grid, col = 0) {
2185
+ if (col + 1 < grid.cols)
2186
+ return grid.cellPosition(col + 1, 0).x - grid.cellPosition(col, 0).x;
2187
+ return grid.cellSize(col).width;
2188
+ }
2189
+ /** A glowing ring drawn around a cell. Returns a disposer. */
2190
+ function glowRing(fx, grid, col, row, color) {
2191
+ if (fx.destroyed)
2192
+ return () => { };
2193
+ const { x, y } = cellCenter(grid, col, row);
2194
+ const { width, height } = grid.cellSize(col);
2195
+ const g = new pixi_js.Graphics()
2196
+ .roundRect(x - width / 2, y - height / 2, width, height, 10)
2197
+ .stroke({ color, width: 3, alpha: 0.9 });
2198
+ fx.addChild(g);
2199
+ return () => g.destroy();
2200
+ }
2201
+ /** Pulse a cell up and back. */
2202
+ async function pulseCell(cell, scale = 1.15, ms = 220) {
2203
+ if (cell.destroyed)
2204
+ return;
2205
+ await Tween.to(cell, { 'scale.x': scale, 'scale.y': scale }, ms * 0.5, easingByName('easeOutBack'));
2206
+ if (cell.destroyed)
2207
+ return;
2208
+ await Tween.to(cell, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.5, easingByName('easeOutQuad'));
2209
+ }
2210
+ /** Floating label that rises and fades. */
2211
+ async function floatLabel(fx, x, y, text, color = 0xffd24a, ms = 700) {
2212
+ if (fx.destroyed)
2213
+ return;
2214
+ const t = new pixi_js.Text({ text, style: { fontSize: 26, fill: color, fontWeight: '800' } });
2215
+ t.anchor.set(0.5);
2216
+ t.position.set(x, y);
2217
+ t.scale.set(0.5);
2218
+ fx.addChild(t);
2219
+ await Tween.to(t, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.25, easingByName('easeOutBack'));
2220
+ await Tween.to(t, { y: y - 50, alpha: 0 }, ms * 0.75, easingByName('easeOutQuad'));
2221
+ t.destroy();
2222
+ }
2223
+ /** Replace a cell's symbol with a quick morph (shrink → swap → pop). */
2224
+ async function morphSymbol(cell, data, ms = 280) {
2225
+ if (cell.destroyed)
2226
+ return;
2227
+ await Tween.to(cell, { 'scale.x': 0.1, 'scale.y': 0.1 }, ms * 0.4, easingByName('easeInBack'));
2228
+ if (cell.destroyed)
2229
+ return;
2230
+ cell.setData(data);
2231
+ cell.scale.set(0.1);
2232
+ await Tween.to(cell, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.6, easingByName('easeOutBack'));
2233
+ }
2234
+ /** Drop a cell in from above its home position. */
2235
+ async function dropCell(grid, cell, col, row, ms = 280) {
2236
+ if (cell.destroyed)
2237
+ return;
2238
+ const home = grid.cellPosition(col, row);
2239
+ cell.position.set(home.x, home.y - rowStepOf(grid, col) * (row + 2));
2240
+ cell.alpha = 1;
2241
+ cell.scale.set(1);
2242
+ await Tween.to(cell, { 'position.y': home.y }, ms, easingByName('easeOutBounce'));
2243
+ }
2244
+ function pickFromBoard(board, predicate) {
2245
+ const out = [];
2246
+ for (let c = 0; c < board.length; c++)
2247
+ for (let r = 0; r < (board[c]?.length ?? 0); r++)
2248
+ if (board[c][r] && predicate(board[c][r], c, r))
2249
+ out.push({ col: c, row: r });
2250
+ return out;
2251
+ }
2252
+
2253
+ // packages/game-engine/src/slot/features/wilds.ts
2254
+ function reelsOf(cfg, cols) {
2255
+ return cfg.length
2256
+ ? cfg.filter((r) => r >= 0 && r < cols)
2257
+ : Array.from({ length: cols }, (_, i) => i);
2258
+ }
2259
+ const randInt = (a, b, seed) => Math.min(b, a + Math.floor(((Math.sin(seed * 99.13) + 1) / 2) * (b - a + 1)));
2260
+ /** Expanding wild: a landed wild grows to fill its whole reel. */
2261
+ const ExpandingWild = {
2262
+ key: 'expandingWild',
2263
+ label: 'Expanding wild',
2264
+ enabled: (c) => c.features.expandingWild.enabled,
2265
+ async demo(ctx) {
2266
+ const f = ctx.cfg.features.expandingWild;
2267
+ if (f.onlyInFreeSpins && !ctx.freeSpins) {
2268
+ ctx.log?.('Expanding wild: free-spins only');
2269
+ return;
2270
+ }
2271
+ const cols = ctx.grid.cols;
2272
+ const eligible = reelsOf(f.reels, cols);
2273
+ const reel = eligible[randInt(0, eligible.length - 1, cols)];
2274
+ const rows = ctx.grid.rowsOf(reel);
2275
+ ctx.log?.(`Expanding wild on reel ${reel}`);
2276
+ const disposers = [];
2277
+ for (let r = 0; r < (f.toFullReel ? rows : Math.min(2, rows)); r++) {
2278
+ const cell = ctx.grid.getCell(reel, r);
2279
+ await morphSymbol(cell, { symbol: f.symbol }, f.ms / Math.max(1, rows));
2280
+ disposers.push(glowRing(ctx.fx, ctx.grid, reel, r, 0xffd700));
2281
+ if (ctx.board[reel])
2282
+ ctx.board[reel][r] = { symbol: f.symbol };
2283
+ }
2284
+ await Tween.delay(500);
2285
+ disposers.forEach((d) => d());
2286
+ },
2287
+ };
2288
+ /** Sticky symbols: chosen symbols lock in place for N spins. */
2289
+ const StickySymbols = {
2290
+ key: 'sticky',
2291
+ label: 'Sticky symbols',
2292
+ enabled: (c) => c.features.sticky.enabled,
2293
+ async demo(ctx) {
2294
+ const f = ctx.cfg.features.sticky;
2295
+ const targets = pickFromBoard(ctx.board, (c) => !!c.symbol).slice(0, 3);
2296
+ const picks = targets.filter((_, i) => i % 2 === 0);
2297
+ ctx.log?.(`Sticky: locking ${picks.length} cell(s) for ${f.durationSpins || 'feature'} spins`);
2298
+ await Promise.all(picks.map(async (p, i) => {
2299
+ const cell = ctx.grid.getCell(p.col, p.row);
2300
+ cell.setData({ symbol: f.symbols[0] ?? 'wild', sticky: { remaining: f.durationSpins } });
2301
+ glowRing(ctx.fx, ctx.grid, p.col, p.row, f.ringColor);
2302
+ await pulseCell(cell, 1.2, 260);
2303
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2304
+ await floatLabel(ctx.fx, x, y - ctx.grid.cellSize(p.col).height / 2, f.durationSpins ? `STICKY ${f.durationSpins}` : 'STICKY', f.ringColor, 600 + i * 50);
2305
+ }));
2306
+ },
2307
+ };
2308
+ /** Walking wild: a wild shifts one reel each spin until it leaves the grid. */
2309
+ const WalkingWild = {
2310
+ key: 'walkingWild',
2311
+ label: 'Walking wild',
2312
+ enabled: (c) => c.features.walkingWild.enabled,
2313
+ async demo(ctx) {
2314
+ const f = ctx.cfg.features.walkingWild;
2315
+ const cols = ctx.grid.cols;
2316
+ const dir = f.direction === 'left' ? -1 : 1;
2317
+ let col = dir === 1 ? 0 : cols - 1;
2318
+ // clamp the row into each reel's own height (Megaways reels differ in length)
2319
+ const rowIn = (c) => Math.min(Math.floor(ctx.grid.rowsOf(col) / 2), ctx.grid.rowsOf(c) - 1);
2320
+ let row = rowIn(col);
2321
+ await morphSymbol(ctx.grid.getCell(col, row), { symbol: f.symbol }, 240);
2322
+ ctx.log?.(`Walking wild marching ${f.direction}`);
2323
+ // walk across the grid
2324
+ for (let step = 0; step < cols; step++) {
2325
+ const next = col + dir * f.stepPerSpin;
2326
+ if (next < 0 || next >= cols)
2327
+ break;
2328
+ const nextRow = rowIn(next);
2329
+ const from = cellCenter(ctx.grid, col, row);
2330
+ const to = cellCenter(ctx.grid, next, nextRow);
2331
+ ctx.grid.getCell(col, row).setData({ symbol: null });
2332
+ const ghost = ctx.grid.getCell(next, nextRow);
2333
+ ghost.setData({ symbol: f.symbol });
2334
+ ghost.position.set(from.x, from.y);
2335
+ const ring = glowRing(ctx.fx, ctx.grid, next, nextRow, 0xff66cc);
2336
+ await Tween.to(ghost, { 'position.x': to.x, 'position.y': to.y }, 260, easingByName('easeInOutQuad'));
2337
+ ring();
2338
+ col = next;
2339
+ row = nextRow;
2340
+ }
2341
+ },
2342
+ };
2343
+ /** Random wild injection: a few wilds drop onto random positions. */
2344
+ const RandomWild = {
2345
+ key: 'randomWild',
2346
+ label: 'Random wild injection',
2347
+ enabled: (c) => c.features.randomWild.enabled,
2348
+ async demo(ctx) {
2349
+ const f = ctx.cfg.features.randomWild;
2350
+ const n = Array.isArray(f.count) ? randInt(f.count[0], f.count[1], ctx.grid.cols + 7) : f.count;
2351
+ const spots = pickFromBoard(ctx.board, () => true);
2352
+ const chosen = [];
2353
+ for (let i = 0; i < n && spots.length; i++)
2354
+ chosen.push(spots.splice(randInt(0, spots.length - 1, i + 3), 1)[0]);
2355
+ ctx.log?.(`Injecting ${chosen.length} wild(s)${f.sticky ? ' (sticky)' : ''}${f.multiplier > 1 ? ` ×${f.multiplier}` : ''}`);
2356
+ await Promise.all(chosen.map(async (p) => {
2357
+ const cell = ctx.grid.getCell(p.col, p.row);
2358
+ cell.setData({
2359
+ symbol: 'wild',
2360
+ multiplier: f.multiplier > 1 ? f.multiplier : undefined,
2361
+ sticky: f.sticky ? { remaining: 0 } : undefined,
2362
+ });
2363
+ await dropCell(ctx.grid, cell, p.col, p.row, 300);
2364
+ if (f.sticky)
2365
+ glowRing(ctx.fx, ctx.grid, p.col, p.row, 0xec4899);
2366
+ }));
2367
+ },
2368
+ };
2369
+
2370
+ // packages/game-engine/src/slot/features/symbols.ts
2371
+ const pick = (arr, seed) => arr[Math.floor(((Math.sin(seed * 12.9898) + 1) / 2) * arr.length) % arr.length];
2372
+ /** Mystery symbols: all mystery tiles reveal the SAME single random symbol per spin. */
2373
+ const MysterySymbols = {
2374
+ key: 'mystery',
2375
+ label: 'Mystery symbols',
2376
+ enabled: (c) => c.features.mystery.enabled,
2377
+ async demo(ctx) {
2378
+ const f = ctx.cfg.features.mystery;
2379
+ const spots = pickFromBoard(ctx.board, () => true)
2380
+ .filter((_, i) => i % 3 === 0)
2381
+ .slice(0, 5);
2382
+ spots.forEach((p) => ctx.grid.getCell(p.col, p.row).setData({ symbol: f.symbol }));
2383
+ await Promise.all(spots.map((p) => pulseCell(ctx.grid.getCell(p.col, p.row), 1.1, 200)));
2384
+ const pool = f.revealPool.length ? f.revealPool : ['h1', 'h2', 'h3'];
2385
+ const reveal = pick(pool, ctx.grid.cols + spots.length);
2386
+ ctx.log?.(`Mystery: ${spots.length} tiles reveal "${reveal}"`);
2387
+ await Promise.all(spots.map((p) => morphSymbol(ctx.grid.getCell(p.col, p.row), { symbol: reveal }, f.ms)));
2388
+ spots.forEach((p) => {
2389
+ if (ctx.board[p.col])
2390
+ ctx.board[p.col][p.row] = { symbol: reveal };
2391
+ });
2392
+ },
2393
+ };
2394
+ /** Symbol transform / upgrade: every instance of one symbol type becomes another. */
2395
+ const SymbolTransform = {
2396
+ key: 'transform',
2397
+ label: 'Symbol transform / upgrade',
2398
+ enabled: (c) => c.features.transform.enabled,
2399
+ async demo(ctx) {
2400
+ const f = ctx.cfg.features.transform;
2401
+ const present = Array.from(new Set(pickFromBoard(ctx.board, (c) => !!c.symbol).map((p) => ctx.board[p.col][p.row].symbol)));
2402
+ const lows = present.filter((s) => /^(l|o|a|k|q|j|t|10)/i.test(s));
2403
+ const source = f.source === 'randomLow'
2404
+ ? (pick(lows.length ? lows : present, ctx.grid.cols) ?? present[0])
2405
+ : f.source;
2406
+ if (!source) {
2407
+ ctx.log?.('Transform: nothing to convert');
2408
+ return;
2409
+ }
2410
+ const matches = pickFromBoard(ctx.board, (c) => c.symbol === source);
2411
+ const targets = f.allInstances ? matches : matches.slice(0, 1);
2412
+ ctx.log?.(`Transform: ${targets.length}× "${source}" → "${f.target}"`);
2413
+ await Promise.all(targets.map(async (p, i) => {
2414
+ await Tween.delay(i * 40);
2415
+ await morphSymbol(ctx.grid.getCell(p.col, p.row), { symbol: f.target }, f.ms);
2416
+ if (ctx.board[p.col])
2417
+ ctx.board[p.col][p.row] = { symbol: f.target };
2418
+ }));
2419
+ },
2420
+ };
2421
+ /** Giant / colossal symbol spanning width×height cells. */
2422
+ const GiantSymbol = {
2423
+ key: 'giant',
2424
+ label: 'Giant / colossal symbol',
2425
+ enabled: (c) => c.features.giant.enabled,
2426
+ async demo(ctx) {
2427
+ const f = ctx.cfg.features.giant;
2428
+ if (f.onlyInFreeSpins && !ctx.freeSpins) {
2429
+ ctx.log?.('Giant: free-spins only');
2430
+ return;
2431
+ }
2432
+ const cols = ctx.grid.cols;
2433
+ const w = Math.min(f.width, cols);
2434
+ const anchorCol = Math.max(0, Math.floor((cols - w) / 2));
2435
+ // span only as tall as the SHORTEST covered reel so every covered cell exists (Megaways-safe)
2436
+ let minRows = ctx.grid.rowsOf(anchorCol);
2437
+ for (let c = anchorCol; c < anchorCol + w; c++)
2438
+ minRows = Math.min(minRows, ctx.grid.rowsOf(c));
2439
+ const h = Math.min(f.height, minRows);
2440
+ const sym = f.symbols.length ? pick(f.symbols, cols) : 'h1';
2441
+ ctx.log?.(`Giant ${w}×${h} "${sym}"`);
2442
+ const view = ctx.resolve(sym);
2443
+ if (!view)
2444
+ return;
2445
+ const cs = ctx.grid.cellSize(anchorCol);
2446
+ const stepX = colStepOf(ctx.grid, anchorCol);
2447
+ const step = rowStepOf(ctx.grid, anchorCol);
2448
+ const tl = cellCenter(ctx.grid, anchorCol, 0);
2449
+ const giant = new pixi_js.Container();
2450
+ giant.addChild(view);
2451
+ // span w×h cells (footprint ignores gaps, matching the previous single-cell unit)
2452
+ view.resize?.({ width: cs.width * w, height: cs.height * h });
2453
+ giant.position.set(tl.x + ((w - 1) * stepX) / 2, tl.y + ((h - 1) * step) / 2);
2454
+ // hide covered cells
2455
+ for (let c = anchorCol; c < anchorCol + w; c++)
2456
+ for (let r = 0; r < h; r++)
2457
+ ctx.grid.getCell(c, r).visible = false;
2458
+ giant.scale.set(0.2);
2459
+ giant.alpha = 0;
2460
+ ctx.fx.addChild(giant);
2461
+ await Tween.to(giant, { 'scale.x': 1, 'scale.y': 1, alpha: 1 }, 420, easingByName('easeOutBack'));
2462
+ await Tween.delay(600);
2463
+ giant.destroy();
2464
+ for (let c = anchorCol; c < anchorCol + w; c++)
2465
+ for (let r = 0; r < h; r++)
2466
+ ctx.grid.getCell(c, r).visible = true;
2467
+ },
2468
+ };
2469
+ /** Split symbol (xSplit): doubles every symbol to its left. */
2470
+ const SplitSymbol = {
2471
+ key: 'split',
2472
+ label: 'Split symbol (xSplit)',
2473
+ enabled: (c) => c.features.split.enabled,
2474
+ async demo(ctx) {
2475
+ const f = ctx.cfg.features.split;
2476
+ const cols = ctx.grid.cols;
2477
+ const splitReel = f.reels.length ? f.reels[0] : cols - 1;
2478
+ const row = Math.floor(ctx.grid.rowsOf(splitReel) / 2);
2479
+ await morphSymbol(ctx.grid.getCell(splitReel, row), { symbol: f.symbol }, 240);
2480
+ ctx.log?.(`Split ×${f.factor}: doubling symbols left of reel ${splitReel}`);
2481
+ const left = pickFromBoard(ctx.board, (_c, col) => col < splitReel);
2482
+ await Promise.all(left.map(async (p, i) => {
2483
+ await Tween.delay((i % 6) * 30);
2484
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2485
+ const cs = ctx.grid.cellSize(p.col);
2486
+ await pulseCell(ctx.grid.getCell(p.col, p.row), 1.12, 200);
2487
+ await floatLabel(ctx.fx, x + cs.width / 3, y - cs.height / 3, `×${f.factor}`, 0x9b5cff, 600);
2488
+ }));
2489
+ },
2490
+ };
2491
+ /** Stacked symbols: a reel shows a full stack of one symbol. */
2492
+ const StackedSymbols = {
2493
+ key: 'stacked',
2494
+ label: 'Stacked symbols',
2495
+ enabled: (c) => c.features.stacked.enabled,
2496
+ async demo(ctx) {
2497
+ const f = ctx.cfg.features.stacked;
2498
+ const cols = ctx.grid.cols;
2499
+ const reel = Math.floor(cols / 2);
2500
+ const rows = ctx.grid.rowsOf(reel);
2501
+ const sym = f.symbols.length ? pick(f.symbols, reel) : 'h1';
2502
+ const height = Math.min(f.height, rows);
2503
+ ctx.log?.(`Stacked "${sym}" ×${height} on reel ${reel}`);
2504
+ const start = Math.floor((rows - height) / 2);
2505
+ await Promise.all(Array.from({ length: height }, (_, i) => start + i).map(async (r, i) => {
2506
+ await Tween.delay(i * 50);
2507
+ const cell = ctx.grid.getCell(reel, r);
2508
+ await morphSymbol(cell, { symbol: sym }, 220);
2509
+ if (ctx.board[reel])
2510
+ ctx.board[reel][r] = { symbol: sym };
2511
+ }));
2512
+ },
2513
+ };
2514
+
2515
+ // packages/game-engine/src/slot/features/extra.ts
2516
+ const seeded = (a, b, seed) => Math.min(b, a + Math.floor(((Math.sin(seed * 53.17) + 1) / 2) * (b - a + 1)));
2517
+ /** Multiplier symbols: marked cells carry values that combine into the win. */
2518
+ const MultiplierSymbols = {
2519
+ key: 'multiplier',
2520
+ label: 'Multiplier symbols',
2521
+ enabled: (c) => c.features.multiplier.enabled,
2522
+ async demo(ctx) {
2523
+ const f = ctx.cfg.features.multiplier;
2524
+ const spots = pickFromBoard(ctx.board, () => true)
2525
+ .filter((_, i) => i % 4 === 0)
2526
+ .slice(0, 3);
2527
+ const values = [2, 3, 5].slice(0, spots.length);
2528
+ spots.forEach((p, i) => ctx.grid.getCell(p.col, p.row).setData({ symbol: f.symbol ?? 'wild', multiplier: values[i] }));
2529
+ await Promise.all(spots.map((p) => pulseCell(ctx.grid.getCell(p.col, p.row), 1.2, 240)));
2530
+ const total = f.combine === 'additive'
2531
+ ? values.reduce((a, b) => a + b, 0)
2532
+ : values.reduce((a, b) => a * b, 1);
2533
+ const capped = Math.min(total, f.max);
2534
+ ctx.log?.(`Multiplier (${f.combine}, ${f.scope}): ${values.join(f.combine === 'additive' ? ' + ' : ' × ')} = ×${capped}`);
2535
+ // fly each value toward the centre, then show the combined total
2536
+ await Promise.all(spots.map(async (p) => {
2537
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2538
+ await floatLabel(ctx.fx, x, y, `×${values[spots.indexOf(p)]}`, 0xffd24a, 500);
2539
+ }));
2540
+ const { x: cx, y: cy } = ctx.grid.center();
2541
+ await floatLabel(ctx.fx, cx, cy, `×${capped}`, 0xffe24a, 900);
2542
+ },
2543
+ };
2544
+ /** Nudge / xNudge: a reel nudges one position (xNudge fills a stacked wild, +1 mult per nudge). */
2545
+ const NudgeReels = {
2546
+ key: 'nudge',
2547
+ label: 'Nudge / xNudge',
2548
+ enabled: (c) => c.features.nudge.enabled,
2549
+ async demo(ctx) {
2550
+ const f = ctx.cfg.features.nudge;
2551
+ const cols = ctx.grid.cols;
2552
+ const reel = f.reels.length ? f.reels[0] : Math.floor(cols / 2);
2553
+ const rows = ctx.grid.rowsOf(reel);
2554
+ ctx.log?.(f.toFullReel ? `xNudge: filling reel ${reel} with wild` : `Nudge reel ${reel} by ${f.step}`);
2555
+ if (f.toFullReel) {
2556
+ let mult = f.multiplierStart;
2557
+ for (let r = 0; r < rows; r++) {
2558
+ const cell = ctx.grid.getCell(reel, r);
2559
+ await morphSymbol(cell, { symbol: 'wild', multiplier: mult > 1 ? mult : undefined }, 160);
2560
+ mult += f.multiplierPerNudge;
2561
+ }
2562
+ const { x, y } = cellCenter(ctx.grid, reel, Math.floor(rows / 2));
2563
+ await floatLabel(ctx.fx, x, y, `×${mult - f.multiplierPerNudge}`, 0xff7a3c, 800);
2564
+ }
2565
+ else {
2566
+ // shift the whole column down by `step` cells, then settle
2567
+ const step = ctx.grid.cellPosition(reel, 1).y - ctx.grid.cellPosition(reel, 0).y;
2568
+ const cells = Array.from({ length: rows }, (_, r) => ctx.grid.getCell(reel, r));
2569
+ await Promise.all(cells.map((c) => Tween.to(c, { 'position.y': c.y + step * f.step }, 240, easingByName('easeOutBack'))));
2570
+ cells.forEach((c, r) => c.position.set(ctx.grid.cellPosition(reel, r).x, ctx.grid.cellPosition(reel, r).y));
2571
+ }
2572
+ },
2573
+ };
2574
+ /** Hold-and-spin / Hold & Win: special symbols lock and respins reset on each new lock. */
2575
+ const HoldAndSpin = {
2576
+ key: 'holdAndSpin',
2577
+ label: 'Hold & Spin (respin)',
2578
+ enabled: (c) => c.features.holdAndSpin.enabled,
2579
+ async demo(ctx) {
2580
+ const f = ctx.cfg.features.holdAndSpin;
2581
+ const all = pickFromBoard(ctx.board, () => true);
2582
+ const sym = f.lockSymbols[0] ?? 'coin';
2583
+ const locked = new Set();
2584
+ const lock = async (p, value) => {
2585
+ const cell = ctx.grid.getCell(p.col, p.row);
2586
+ cell.setData({ symbol: sym });
2587
+ await dropCell(ctx.grid, cell, p.col, p.row, 240);
2588
+ glowRing(ctx.fx, ctx.grid, p.col, p.row, 0xffcf5c);
2589
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2590
+ await floatLabel(ctx.fx, x, y, value, 0xffe24a, 500);
2591
+ locked.add(`${p.col}:${p.row}`);
2592
+ };
2593
+ // trigger
2594
+ const trigger = all.slice(0, f.triggerThreshold);
2595
+ ctx.log?.(`Hold & Spin triggered with ${trigger.length} ${sym}`);
2596
+ await Promise.all(trigger.map((p, i) => lock(p, `${(i + 1) * 5}`)));
2597
+ let respins = f.respinsAwarded;
2598
+ let round = 0;
2599
+ while (respins > 0) {
2600
+ round++;
2601
+ const free = all.filter((p) => !locked.has(`${p.col}:${p.row}`));
2602
+ const landed = free.filter((_, i) => seeded(0, 3, i + round * 3) === 0).slice(0, 2);
2603
+ ctx.log?.(`Respin ${round}: ${respins} left` + (landed.length ? ` — ${landed.length} new lock` : ''));
2604
+ if (landed.length && f.resetOnNewSymbol) {
2605
+ respins = f.respinsAwarded;
2606
+ await Promise.all(landed.map((p) => lock(p, 'JP')));
2607
+ }
2608
+ else
2609
+ respins--;
2610
+ if (locked.size >= all.length) {
2611
+ ctx.log?.(f.fullGridAwardsGrand ? 'Full grid — GRAND!' : 'Full grid');
2612
+ break;
2613
+ }
2614
+ await Tween.delay(120);
2615
+ }
2616
+ ctx.log?.(`Hold & Spin: ${locked.size} locked`);
2617
+ },
2618
+ };
2619
+ /** Random pre-spin reel modifiers (add rows, inject wilds, set giant, guaranteed wilds). */
2620
+ const ReelModifier = {
2621
+ key: 'reelModifier',
2622
+ label: 'Random reel modifier',
2623
+ enabled: (c) => c.features.reelModifier.enabled,
2624
+ async demo(ctx) {
2625
+ const f = ctx.cfg.features.reelModifier;
2626
+ const pool = f.pool.length
2627
+ ? f.pool
2628
+ : [{ effect: 'addWilds', magnitude: 3, weight: 1 }];
2629
+ const totalW = pool.reduce((a, m) => a + m.weight, 0);
2630
+ let roll = ((Math.sin(ctx.grid.cols * 7.7) + 1) / 2) * totalW;
2631
+ let mod = pool[0];
2632
+ for (const m of pool) {
2633
+ roll -= m.weight;
2634
+ if (roll <= 0) {
2635
+ mod = m;
2636
+ break;
2637
+ }
2638
+ }
2639
+ ctx.log?.(`Reel modifier: ${mod.effect} (+${mod.magnitude})`);
2640
+ if (mod.effect === 'addRows') {
2641
+ const next = ctx.grid.rowsPerReel.map((r) => r + mod.magnitude);
2642
+ // grow the board (new cells on top) and keep the system config in sync so the new rows
2643
+ // aren't blank and a later spin/rebuild doesn't revert the shape
2644
+ for (let c = 0; c < ctx.board.length; c++) {
2645
+ const col = ctx.board[c] ?? (ctx.board[c] = []);
2646
+ const fill = col[0]?.symbol ?? 'h1';
2647
+ for (let i = 0; i < mod.magnitude; i++)
2648
+ col.unshift({ symbol: fill });
2649
+ }
2650
+ ctx.cfg.grid.rowsPerReel = next;
2651
+ ctx.grid.reshape(next);
2652
+ ctx.grid.setGrid(ctx.board);
2653
+ }
2654
+ else if (mod.effect === 'addWilds' || mod.effect === 'guaranteedWilds') {
2655
+ const spots = pickFromBoard(ctx.board, () => true);
2656
+ const chosen = [];
2657
+ for (let i = 0; i < mod.magnitude && spots.length; i++)
2658
+ chosen.push(spots.splice(seeded(0, spots.length - 1, i), 1)[0]);
2659
+ await Promise.all(chosen.map(async (p) => {
2660
+ const cell = ctx.grid.getCell(p.col, p.row);
2661
+ cell.setData({ symbol: 'wild' });
2662
+ await dropCell(ctx.grid, cell, p.col, p.row, 280);
2663
+ }));
2664
+ }
2665
+ else if (mod.effect === 'setGiant') {
2666
+ const cell = ctx.grid.getCell(0, 0);
2667
+ await pulseCell(cell, 1.3, 300);
2668
+ }
2669
+ },
2670
+ };
2671
+
2672
+ /** All feature modules keyed by their FeatureKey. */
2673
+ const FEATURES = {
2674
+ expandingWild: ExpandingWild,
2675
+ sticky: StickySymbols,
2676
+ walkingWild: WalkingWild,
2677
+ randomWild: RandomWild,
2678
+ mystery: MysterySymbols,
2679
+ transform: SymbolTransform,
2680
+ giant: GiantSymbol,
2681
+ split: SplitSymbol,
2682
+ stacked: StackedSymbols,
2683
+ multiplier: MultiplierSymbols,
2684
+ nudge: NudgeReels,
2685
+ holdAndSpin: HoldAndSpin,
2686
+ reelModifier: ReelModifier,
2687
+ };
2688
+ /** Features in canonical resolve order (see docs §3.4). */
2689
+ const FEATURE_LIST = FEATURE_KEYS.map((k) => FEATURES[k]);
2690
+
2691
+ // packages/game-engine/src/slot/system/ReelSystem.ts
2692
+ //
2693
+ // The configurable reel system facade. `createReelSystem({ resolve, config })` builds a grid and
2694
+ // wires the spin engine, anticipation controller, tumble/cascade controller and the special-feature
2695
+ // registry — all driven by one `ReelSystemConfig`. Presentation only: it draws boards you feed it.
2696
+ /** Drop a `rowsPerReel` whose length no longer matches `cols` so geometry/ways stay consistent. */
2697
+ function normalizeGrid(cfg) {
2698
+ if (cfg.grid.rowsPerReel && cfg.grid.rowsPerReel.length !== cfg.grid.cols) {
2699
+ const next = { ...cfg, grid: { ...cfg.grid } };
2700
+ delete next.grid.rowsPerReel;
2701
+ return next;
2702
+ }
2703
+ return cfg;
2704
+ }
2705
+ function createReelSystem(opts) {
2706
+ let config = normalizeGrid(resolveReelConfig(opts.config));
2707
+ const resolve = opts.resolve;
2708
+ const log = opts.log;
2709
+ const view = new pixi_js.Container();
2710
+ let grid;
2711
+ let fx;
2712
+ let spin;
2713
+ let anticipation;
2714
+ let tumble;
2715
+ let reelStepCtl;
2716
+ let board = opts.board ?? emptyBoard(config);
2717
+ // custom features keyed by id; built-ins live in FEATURES/FEATURE_LIST
2718
+ const custom = new Map();
2719
+ for (const f of opts.features ?? [])
2720
+ custom.set(f.key, f);
2721
+ const allFeatures = () => {
2722
+ const seen = new Set();
2723
+ const out = [];
2724
+ for (const f of [...FEATURE_LIST, ...custom.values()]) {
2725
+ const eff = custom.get(f.key) ?? f; // a custom feature overrides a built-in with the same key
2726
+ if (seen.has(eff.key))
2727
+ continue;
2728
+ seen.add(eff.key);
2729
+ out.push(eff);
2730
+ }
2731
+ return out;
2732
+ };
2733
+ const findFeature = (key) => custom.get(key) ?? FEATURES[key];
2734
+ function buildGrid() {
2735
+ if (grid) {
2736
+ spin?.skip();
2737
+ tumble?.skip();
2738
+ reelStepCtl?.skip();
2739
+ for (const child of fx?.children.slice() ?? [])
2740
+ Tween.killTweensOf(child);
2741
+ grid.destroy({ children: true });
2742
+ }
2743
+ grid = new ReelGrid({
2744
+ cols: config.grid.cols,
2745
+ rows: config.grid.rows,
2746
+ rowsPerReel: config.grid.rowsPerReel ?? effectiveRowsPerReel(config.grid),
2747
+ cellSize: config.grid.cellSize,
2748
+ cellWidth: config.grid.cellWidth,
2749
+ cellHeight: config.grid.cellHeight,
2750
+ cellSizePerReel: config.grid.cellSizePerReel,
2751
+ gap: config.grid.gap,
2752
+ colGap: config.grid.colGap,
2753
+ rowGap: config.grid.rowGap,
2754
+ resolve,
2755
+ frameStyle: config.grid.frameStyle,
2756
+ mask: config.grid.mask,
2757
+ decoration: config.grid.decoration?.padding
2758
+ ? { padding: config.grid.decoration.padding }
2759
+ : undefined,
2760
+ });
2761
+ fx = new pixi_js.Container();
2762
+ grid.addChild(fx);
2763
+ view.addChild(grid);
2764
+ spin = new SpinEngine(grid, resolve, config.motion, config.win);
2765
+ anticipation = new AnticipationController(config.anticipation);
2766
+ tumble = new TumbleController(grid, config.cascade, config.win);
2767
+ reelStepCtl = new ReelStepController(grid, resolve, config.cascade, config.win);
2768
+ grid.setGrid(board);
2769
+ }
2770
+ function geometryChanged(next) {
2771
+ const a = config.grid, b = next.grid;
2772
+ return (a.cols !== b.cols ||
2773
+ a.rows !== b.rows ||
2774
+ a.cellSize !== b.cellSize ||
2775
+ a.cellWidth !== b.cellWidth ||
2776
+ a.cellHeight !== b.cellHeight ||
2777
+ a.gap !== b.gap ||
2778
+ a.mask !== b.mask ||
2779
+ JSON.stringify(a.cellSizePerReel) !== JSON.stringify(b.cellSizePerReel) ||
2780
+ JSON.stringify(a.colGap) !== JSON.stringify(b.colGap) ||
2781
+ JSON.stringify(a.rowGap) !== JSON.stringify(b.rowGap) ||
2782
+ JSON.stringify(a.rowsPerReel) !== JSON.stringify(b.rowsPerReel) ||
2783
+ (a.decoration?.padding ?? 0) !== (b.decoration?.padding ?? 0));
2784
+ }
2785
+ function ctx(freeSpins) {
2786
+ return { grid, resolve, cfg: config, fx, board, freeSpins, log };
2787
+ }
2788
+ buildGrid();
2789
+ const api = {
2790
+ view,
2791
+ get grid() {
2792
+ return grid;
2793
+ },
2794
+ get fx() {
2795
+ return fx;
2796
+ },
2797
+ get config() {
2798
+ return config;
2799
+ },
2800
+ get board() {
2801
+ return board;
2802
+ },
2803
+ get ways() {
2804
+ return waysCount(config.grid);
2805
+ },
2806
+ setBoard(next) {
2807
+ board = next;
2808
+ // grow the board to the grid shape so feature code can index safely
2809
+ grid.setGrid(next);
2810
+ },
2811
+ setConfig(next) {
2812
+ const norm = normalizeGrid(next);
2813
+ const rebuild = geometryChanged(norm);
2814
+ config = norm;
2815
+ if (rebuild)
2816
+ buildGrid();
2817
+ else {
2818
+ spin.setConfig(config.motion);
2819
+ spin.setWin(config.win);
2820
+ anticipation.setConfig(config.anticipation);
2821
+ tumble.setConfig(config.cascade);
2822
+ tumble.setWin(config.win);
2823
+ reelStepCtl.setConfig(config.cascade);
2824
+ reelStepCtl.setWin(config.win);
2825
+ }
2826
+ },
2827
+ update(partial) {
2828
+ api.setConfig(mergeReelConfig(config, partial));
2829
+ },
2830
+ async spin(target, runOpts) {
2831
+ const data = { targetGrid: target };
2832
+ const decision = anticipation.decide(target);
2833
+ let resetZoom = null;
2834
+ if (decision.active) {
2835
+ log?.(`Anticipation on reels [${decision.reels.join(', ')}]`);
2836
+ resetZoom = await anticipation.zoomIn(grid);
2837
+ }
2838
+ await spin.run(data, {
2839
+ ...runOpts,
2840
+ anticipateReels: decision.active ? decision.reels : undefined,
2841
+ anticipateSlowdown: decision.slowdown,
2842
+ anticipateHoldMs: decision.holdMs,
2843
+ });
2844
+ if (resetZoom)
2845
+ await resetZoom();
2846
+ board = target;
2847
+ },
2848
+ get multiplier() {
2849
+ // cascade and reelStep share the same config start; only the active mechanic climbs.
2850
+ return Math.max(tumble.multiplier, reelStepCtl.multiplier);
2851
+ },
2852
+ async cascade(steps, cOpts) {
2853
+ // keep the multiplier climbing across free-spins when configured; otherwise reset per spin
2854
+ const persist = config.cascade.multiplier.persistInFreeSpins && !!cOpts?.freeSpins;
2855
+ if (!persist)
2856
+ tumble.resetMultiplier();
2857
+ for (let i = 0; i < steps.length; i++) {
2858
+ await tumble.step(steps[i], i, cOpts);
2859
+ board = steps[i].settledGrid;
2860
+ }
2861
+ if (config.cascade.multiplier.enabled)
2862
+ log?.(`Cascade multiplier ×${tumble.multiplier}`);
2863
+ },
2864
+ async reelStep(steps, rOpts) {
2865
+ const persist = config.cascade.multiplier.persistInFreeSpins && !!rOpts?.freeSpins;
2866
+ if (!persist)
2867
+ reelStepCtl.resetMultiplier();
2868
+ for (let i = 0; i < steps.length; i++) {
2869
+ await reelStepCtl.step(steps[i], i, rOpts);
2870
+ board = steps[i].settledGrid;
2871
+ }
2872
+ if (config.cascade.multiplier.enabled)
2873
+ log?.(`ReelStep multiplier ×${reelStepCtl.multiplier}`);
2874
+ },
2875
+ registerFeature(feature) {
2876
+ custom.set(feature.key, feature);
2877
+ },
2878
+ features() {
2879
+ return allFeatures();
2880
+ },
2881
+ enabledFeatures() {
2882
+ return allFeatures().filter((f) => f.enabled(config));
2883
+ },
2884
+ featureContext(fOpts) {
2885
+ return ctx(fOpts?.freeSpins);
2886
+ },
2887
+ async runFeature(key, fOpts) {
2888
+ const feature = findFeature(key);
2889
+ if (!feature) {
2890
+ log?.(`Unknown feature "${key}"`);
2891
+ return;
2892
+ }
2893
+ if (!feature.enabled(config)) {
2894
+ log?.(`${feature.label} is disabled`);
2895
+ return;
2896
+ }
2897
+ await feature.demo(ctx(fOpts?.freeSpins));
2898
+ },
2899
+ resize(cellSize) {
2900
+ config = mergeReelConfig(config, { grid: { cellSize } });
2901
+ grid.resize(cellSize);
2902
+ },
2903
+ skip() {
2904
+ spin.skip();
2905
+ tumble.skip();
2906
+ reelStepCtl.skip();
2907
+ // kill in-flight overlay tweens (labels/rings) so a rebuild never animates destroyed nodes
2908
+ for (const child of fx.children.slice())
2909
+ Tween.killTweensOf(child);
2910
+ fx.removeChildren().forEach((c) => c.destroy());
2911
+ },
2912
+ destroy() {
2913
+ api.skip();
2914
+ grid.destroy({ children: true });
2915
+ view.destroy({ children: true });
2916
+ },
2917
+ };
2918
+ return api;
2919
+ }
2920
+ function emptyBoard(cfg) {
2921
+ const rows = effectiveRowsPerReel(cfg.grid);
2922
+ return Array.from({ length: cfg.grid.cols }, (_, c) => Array.from({ length: rows[c] }, () => ({ symbol: null })));
2923
+ }
2924
+
2925
+ // Built-in node contributions: the core vocabulary every scene doc can rely on.
2926
+ // Everything else (ropes, meters, particles, game HUDs) arrives as a ScenePlugin through
2927
+ // the same contribution shape — built-ins get no special powers.
2928
+ const num = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
2929
+ const str = (v, fallback) => (typeof v === 'string' ? v : fallback);
2930
+ // ── container / layer ───────────────────────────────────────────────────────────────
2931
+ function containerContribution(kind) {
2932
+ return {
2933
+ kind,
2934
+ agentDoc: 'Grouping node; order of children = z-order. Keep it un-laid-out (identity) unless you move a whole group. With props.space {width,height} it becomes a nested coordinate space: descendants lay out inside that fixed design space and its own rule places the space (cover → the diorama idiom). Natural size = space size, else 0×0.',
2935
+ create(node) {
2936
+ const view = new pixi_js.Container();
2937
+ return {
2938
+ view,
2939
+ measure() {
2940
+ const space = node.props?.space;
2941
+ return space && typeof space.width === 'number' && typeof space.height === 'number'
2942
+ ? { width: space.width, height: space.height }
2943
+ : { width: 0, height: 0 };
2944
+ },
2945
+ applyProps(props) {
2946
+ view.alpha = num(props.alpha, 1);
2947
+ },
2948
+ };
2949
+ },
2950
+ };
2951
+ }
2952
+ // ── rect (plates, masks, dimmers) ───────────────────────────────────────────────────
2953
+ const rectContribution = {
2954
+ kind: 'rect',
2955
+ agentDoc: 'Solid rounded rectangle: plates behind text, dimmers, and mask targets (reference its id from another node\'s `mask`). Props: width, height, fill, alpha, radius, stroke {color,width}.',
2956
+ create(node) {
2957
+ const g = new pixi_js.Graphics();
2958
+ let size = { width: 0, height: 0 };
2959
+ const draw = (props) => {
2960
+ const width = num(props.width, 100);
2961
+ const height = num(props.height, 100);
2962
+ const radius = num(props.radius, 0);
2963
+ size = { width, height };
2964
+ g.clear();
2965
+ g.roundRect(0, 0, width, height, radius).fill({
2966
+ color: str(props.fill, '#ffffff'),
2967
+ alpha: num(props.alpha, 1),
2968
+ });
2969
+ const stroke = props.stroke;
2970
+ if (stroke) {
2971
+ g.roundRect(0, 0, width, height, radius).stroke({
2972
+ color: stroke.color ?? '#ffffff',
2973
+ width: stroke.width ?? 2,
2974
+ });
2975
+ }
2976
+ };
2977
+ draw(node.props ?? {});
2978
+ return { view: g, measure: () => size, applyProps: draw };
2979
+ },
2980
+ };
2981
+ function spriteInstance(node, ctx) {
2982
+ // The sprite lives inside a wrapper so content-level transforms (flipX) survive the
2983
+ // engine overwriting the node view's scale every layout.
2984
+ const wrapper = new pixi_js.Container();
2985
+ const sprite = new pixi_js.Sprite(pixi_js.Texture.EMPTY);
2986
+ wrapper.addChild(sprite);
2987
+ const resolveTexture = (p) => {
2988
+ const alias = str(p.src, '');
2989
+ const full = alias ? ctx.texture(alias) : pixi_js.Texture.EMPTY;
2990
+ const region = p.region;
2991
+ if (!region || full === pixi_js.Texture.EMPTY)
2992
+ return full;
2993
+ // Sub-rect of the texture, in fractions (half-cut castles/veils, atlas crops).
2994
+ const frame = full.frame;
2995
+ return new pixi_js.Texture({
2996
+ source: full.source,
2997
+ frame: new pixi_js.Rectangle(frame.x + (region.left ?? 0) * frame.width, frame.y + (region.top ?? 0) * frame.height, (region.width ?? 1) * frame.width, (region.height ?? 1) * frame.height),
2998
+ });
2999
+ };
3000
+ // applyProps receives the FULL effective props each time (base + orientation + state),
3001
+ // so absent keys must reset to defaults — otherwise leaving a state leaks its look.
3002
+ const apply = (p) => {
3003
+ sprite.texture = resolveTexture(p);
3004
+ // Visual props go on the wrapper (the node's view) so tint/alpha are observable and
3005
+ // patchable on the node itself; the inner sprite only carries texture + flip.
3006
+ wrapper.tint = p.tint ?? 0xffffff;
3007
+ wrapper.alpha = num(p.alpha, 1);
3008
+ const flip = p.flipX === true;
3009
+ sprite.scale.x = flip ? -1 : 1;
3010
+ sprite.position.x = flip ? sprite.texture.width : 0;
3011
+ };
3012
+ apply(node.props ?? {});
3013
+ return {
3014
+ view: wrapper,
3015
+ measure: () => ({ width: sprite.texture.width, height: sprite.texture.height }),
3016
+ applyProps: apply,
3017
+ };
3018
+ }
3019
+ const spriteContribution = {
3020
+ kind: 'sprite',
3021
+ agentDoc: 'Static art. Props: src (texture alias), tint, alpha. Natural size = texture size; use layout to place/scale.',
3022
+ create: spriteInstance,
3023
+ };
3024
+ const reelFrameContribution = {
3025
+ kind: 'reelFrame',
3026
+ agentDoc: 'The reel frame art. Declare where its hollow is once in props.inner {left,top,width,height} (fractions of the texture); the grid then binds with layout {mode:"frame-fraction", frame:<this id>, use:"inner"} — never restate the numbers.',
3027
+ create: spriteInstance,
3028
+ };
3029
+ // ── text ────────────────────────────────────────────────────────────────────────────
3030
+ const textContribution = {
3031
+ kind: 'text',
3032
+ agentDoc: 'Vector text (labels, logos-as-type). Props: text, fontSize, fill, fontFamily, fontWeight, align, letterSpacing. For rolling win numbers prefer a bitmapNumber/prefab, not text.',
3033
+ create(node) {
3034
+ const props = node.props ?? {};
3035
+ const view = new pixi_js.Text({
3036
+ text: str(props.text, ''),
3037
+ style: {
3038
+ fontFamily: str(props.fontFamily, 'Arial, sans-serif'),
3039
+ fontSize: num(props.fontSize, 32),
3040
+ fill: props.fill ?? '#ffffff',
3041
+ fontWeight: props.fontWeight ?? 'normal',
3042
+ align: props.align ?? 'left',
3043
+ letterSpacing: num(props.letterSpacing, 0),
3044
+ },
3045
+ });
3046
+ return {
3047
+ view,
3048
+ measure: () => ({ width: view.width / view.scale.x, height: view.height / view.scale.y }),
3049
+ applyProps(p) {
3050
+ if (p.text !== undefined)
3051
+ view.text = str(p.text, '');
3052
+ view.style.fill = p.fill ?? '#ffffff';
3053
+ view.style.fontSize = num(p.fontSize, 32);
3054
+ view.style.letterSpacing = num(p.letterSpacing, 0);
3055
+ view.alpha = num(p.alpha, 1);
3056
+ },
3057
+ };
3058
+ },
3059
+ };
3060
+ // ── animatedSprite ──────────────────────────────────────────────────────────────────
3061
+ const animatedSpriteContribution = {
3062
+ kind: 'animatedSprite',
3063
+ agentDoc: 'Frame animation from a grid spritesheet. Props: sheet {alias, cols, rows}, frame (static frame index, default 0), fps + playing:true for an ambient loop. Choreographed playback belongs to flow/animation clips, not here.',
3064
+ create(node, ctx) {
3065
+ const view = new pixi_js.Container();
3066
+ let animated = null;
3067
+ let size = { width: 0, height: 0 };
3068
+ const apply = (p) => {
3069
+ const sheet = p.sheet;
3070
+ const alias = str(sheet?.alias, '');
3071
+ const cols = num(sheet?.cols, 1);
3072
+ const rows = num(sheet?.rows, 1);
3073
+ const full = alias ? ctx.texture(alias) : pixi_js.Texture.EMPTY;
3074
+ const frames = [];
3075
+ if (full !== pixi_js.Texture.EMPTY && cols > 0 && rows > 0) {
3076
+ const fw = full.frame.width / cols;
3077
+ const fh = full.frame.height / rows;
3078
+ for (let r = 0; r < rows; r++) {
3079
+ for (let c = 0; c < cols; c++) {
3080
+ frames.push(new pixi_js.Texture({ source: full.source, frame: new pixi_js.Rectangle(full.frame.x + c * fw, full.frame.y + r * fh, fw, fh) }));
3081
+ }
3082
+ }
3083
+ }
3084
+ animated?.destroy();
3085
+ animated = new pixi_js.AnimatedSprite(frames.length ? frames : [pixi_js.Texture.EMPTY]);
3086
+ animated.animationSpeed = num(p.fps, 12) / 60;
3087
+ animated.loop = true;
3088
+ const frame = Math.min(num(p.frame, 0), animated.totalFrames - 1);
3089
+ if (p.playing === true)
3090
+ animated.gotoAndPlay(Math.max(0, frame));
3091
+ else
3092
+ animated.gotoAndStop(Math.max(0, frame));
3093
+ animated.alpha = num(p.alpha, 1);
3094
+ view.removeChildren();
3095
+ view.addChild(animated);
3096
+ size = { width: animated.textures.length ? animated.textures[0].width : 0, height: animated.textures.length ? animated.textures[0].height : 0 };
3097
+ };
3098
+ apply(node.props ?? {});
3099
+ return {
3100
+ view,
3101
+ measure: () => size,
3102
+ applyProps: apply,
3103
+ destroy: () => animated?.destroy(),
3104
+ };
3105
+ },
3106
+ };
3107
+ // ── reelGrid ────────────────────────────────────────────────────────────────────────
3108
+ const reelGridContribution = {
3109
+ kind: 'reelGrid',
3110
+ agentDoc: 'The reel window, delegating to createReelSystem. Props: preset (named ReelSystemConfig preset), config (DeepPartial override), board (string[][] of symbol ids, column-major). Bind into the frame hollow with {mode:"frame-fraction", frame:"<frameId>", use:"inner"}. Needs `resolveSymbol` passed to createSceneFromDoc.',
3111
+ create(node, ctx) {
3112
+ if (!ctx.resolveSymbol) {
3113
+ throw new Error(`scene node "${node.id}": reelGrid requires opts.resolveSymbol in createSceneFromDoc`);
3114
+ }
3115
+ const props = node.props ?? {};
3116
+ const presetId = props.preset;
3117
+ const base = presetId ? PRESETS[presetId]?.config : undefined;
3118
+ if (presetId && !base)
3119
+ throw new Error(`scene node "${node.id}": unknown reel preset "${String(presetId)}"`);
3120
+ const override = (props.config ?? {});
3121
+ const system = createReelSystem({
3122
+ resolve: ctx.resolveSymbol,
3123
+ config: resolveReelConfig({ ...(base ?? {}), ...override }),
3124
+ log: ctx.log,
3125
+ });
3126
+ // Doc boards are plain symbol-id grids; lift them to CellData (string entries allowed
3127
+ // per column so scene.json stays hand-writable).
3128
+ const setBoard = (board) => {
3129
+ if (!Array.isArray(board))
3130
+ return;
3131
+ system.setBoard(board.map((column) => column.map((cell) => (typeof cell === 'string' ? { symbol: cell } : cell))));
3132
+ };
3133
+ setBoard(props.board);
3134
+ // The reel system's local content does not start at (0,0) (cells/decoration extend
3135
+ // around the geometry origin), so align it inside a wrapper: measure() re-reads the
3136
+ // real local bounds each layout and keeps the wrapper's (0,0) = content top-left.
3137
+ const wrapper = new pixi_js.Container();
3138
+ wrapper.addChild(system.view);
3139
+ let contentOrigin = { x: 0, y: 0 };
3140
+ const syncContent = () => {
3141
+ const lb = system.view.getLocalBounds();
3142
+ contentOrigin = { x: lb.x, y: lb.y };
3143
+ system.view.position.set(-lb.x, -lb.y);
3144
+ return { width: lb.width, height: lb.height };
3145
+ };
3146
+ const instance = {
3147
+ view: wrapper,
3148
+ system,
3149
+ measure: syncContent,
3150
+ gridCell(col, row) {
3151
+ const p = system.grid.cellPosition(col, row);
3152
+ return { x: p.x - contentOrigin.x, y: p.y - contentOrigin.y };
3153
+ },
3154
+ applyProps(p) {
3155
+ if (p.board !== undefined)
3156
+ setBoard(p.board);
3157
+ },
3158
+ destroy: () => system.destroy(),
3159
+ };
3160
+ return instance;
3161
+ },
3162
+ };
3163
+ // ── prefab dispatch ─────────────────────────────────────────────────────────────────
3164
+ const prefabContribution = {
3165
+ kind: 'prefab',
3166
+ agentDoc: 'Instantiates a game-registered prefab (meters, HUDs). Props: prefab (registered name) + the prefab\'s own props. Register prefabs via a ScenePlugin.',
3167
+ create(node, ctx) {
3168
+ const name = str(node.props?.prefab, '');
3169
+ const prefab = ctx.registry.prefab(name);
3170
+ if (!prefab)
3171
+ throw new Error(`scene node "${node.id}": unknown prefab "${name}"`);
3172
+ return prefab.create(node.props ?? {}, ctx);
3173
+ },
3174
+ };
3175
+ const BUILTIN_NODE_TYPES = [
3176
+ containerContribution('container'),
3177
+ containerContribution('layer'),
3178
+ rectContribution,
3179
+ spriteContribution,
3180
+ reelFrameContribution,
3181
+ textContribution,
3182
+ animatedSpriteContribution,
3183
+ reelGridContribution,
3184
+ prefabContribution,
3185
+ ];
3186
+
3187
+ // Structural validation of a scene doc against a registry.
3188
+ //
3189
+ // Errors are written for the round-trip loop: an agent (or a human) gets told *which*
3190
+ // node is wrong and — for unknown kinds — that a plugin is missing, by name, instead of
3191
+ // a generic parse failure.
3192
+ function validateSceneDoc(doc, registry) {
3193
+ const errors = [];
3194
+ if (doc.version !== 1)
3195
+ errors.push({ message: `unsupported scene doc version ${String(doc.version)} (expected 1)` });
3196
+ if (!doc.id)
3197
+ errors.push({ message: 'doc.id is required' });
3198
+ if (!doc.design || doc.design.width <= 0 || doc.design.height <= 0) {
3199
+ errors.push({ message: 'doc.design must declare a positive width/height' });
3200
+ }
3201
+ if (!doc.root) {
3202
+ errors.push({ message: 'doc.root is required' });
3203
+ return errors;
3204
+ }
3205
+ const ids = new Set();
3206
+ const anchors = new Set();
3207
+ const maskRefs = [];
3208
+ const visit = (node) => {
3209
+ if (!node.id) {
3210
+ errors.push({ message: `node of type "${node.type}" has no id` });
3211
+ }
3212
+ else if (ids.has(node.id)) {
3213
+ errors.push({ nodeId: node.id, message: `duplicate node id "${node.id}"` });
3214
+ }
3215
+ else {
3216
+ ids.add(node.id);
3217
+ }
3218
+ if (node.anchorName) {
3219
+ if (anchors.has(node.anchorName)) {
3220
+ errors.push({ nodeId: node.id, message: `duplicate anchorName "${node.anchorName}"` });
3221
+ }
3222
+ anchors.add(node.anchorName);
3223
+ }
3224
+ const contribution = registry.nodeType(node.type);
3225
+ if (!contribution) {
3226
+ errors.push({
3227
+ nodeId: node.id,
3228
+ message: `unknown node type "${node.type}" — no plugin contributes it. ` +
3229
+ `Registered kinds: ${registry.kinds().join(', ')}`,
3230
+ });
3231
+ }
3232
+ if (node.type === 'prefab') {
3233
+ const name = node.props?.prefab;
3234
+ if (typeof name !== 'string' || !name) {
3235
+ errors.push({ nodeId: node.id, message: `prefab node needs props.prefab (a registered prefab name)` });
3236
+ }
3237
+ else if (!registry.prefab(name)) {
3238
+ errors.push({
3239
+ nodeId: node.id,
3240
+ message: `unknown prefab "${name}". Registered prefabs: ${registry.prefabNames().join(', ') || '(none)'}`,
3241
+ });
3242
+ }
3243
+ }
3244
+ if (node.mask)
3245
+ maskRefs.push({ nodeId: node.id, mask: node.mask });
3246
+ node.children?.forEach(visit);
3247
+ };
3248
+ visit(doc.root);
3249
+ for (const ref of maskRefs) {
3250
+ if (!ids.has(ref.mask)) {
3251
+ errors.push({ nodeId: ref.nodeId, message: `mask references unknown node "${ref.mask}"` });
3252
+ }
3253
+ }
3254
+ return errors;
3255
+ }
3256
+
3257
+ // Scene-IR interpreter: `createSceneFromDoc` executes a SceneDoc directly (no codegen).
3258
+ //
3259
+ // One instance owns the display tree, runs the layout passes, applies orientation/state
3260
+ // overrides and serves the observability surface (tree/select/patch) that the editor GUI
3261
+ // and the agent share. The doc object is the single source of truth: patches mutate it,
3262
+ // `handle.doc()` is what gets persisted.
3263
+ const MAX_LAYOUT_PASSES = 5;
3264
+ function createSceneFromDoc(doc, opts = {}) {
3265
+ const log = opts.log ?? ((msg) => console.warn(`[scene] ${msg}`));
3266
+ const registry = createSceneRegistry(opts.plugins ?? [], BUILTIN_NODE_TYPES);
3267
+ const errors = validateSceneDoc(doc, registry);
3268
+ if (errors.length > 0) {
3269
+ const detail = errors.map((e) => (e.nodeId ? `[${e.nodeId}] ${e.message}` : e.message)).join('\n ');
3270
+ throw new Error(`scene doc "${doc.id}" is invalid:\n ${detail}`);
3271
+ }
3272
+ const createCtx = {
3273
+ registry,
3274
+ texture: opts.texture ?? ((alias) => pixi_js.Texture.from(alias)),
3275
+ resolveSymbol: opts.resolveSymbol,
3276
+ log,
3277
+ };
3278
+ const vars = { ...(opts.vars ?? {}) };
3279
+ const runtimes = new Map();
3280
+ const warned = new Set();
3281
+ const warnOnce = (key, msg) => {
3282
+ if (warned.has(key))
3283
+ return;
3284
+ warned.add(key);
3285
+ log(msg);
3286
+ };
3287
+ let lastWidth = doc.design.width;
3288
+ let lastHeight = doc.design.height;
3289
+ const build = (node, parent, parentView) => {
3290
+ const contribution = registry.nodeType(node.type);
3291
+ if (!contribution)
3292
+ throw new Error(`scene node "${node.id}": unknown type "${node.type}"`); // validated; defensive
3293
+ let instance;
3294
+ try {
3295
+ instance = contribution.create(node, createCtx);
3296
+ }
3297
+ catch (err) {
3298
+ throw new Error(`scene node "${node.id}" (${node.type}) failed to create: ${err.message}`);
3299
+ }
3300
+ parentView.addChild(instance.view);
3301
+ const runtime = {
3302
+ node,
3303
+ instance,
3304
+ parent,
3305
+ state: null,
3306
+ // create() already applied the base props — seed the cache so the first layout
3307
+ // does not spuriously rebuild nodes that have no applyProps.
3308
+ appliedProps: JSON.stringify(node.props ?? {}),
3309
+ visibleBase: true,
3310
+ worldScaleX: 1,
3311
+ worldScaleY: 1,
3312
+ };
3313
+ runtimes.set(node.id, runtime);
3314
+ for (const child of node.children ?? [])
3315
+ build(child, runtime, instance.view);
3316
+ return runtime;
3317
+ };
3318
+ const rootView = new pixi_js.Container();
3319
+ build(doc.root, null, rootView);
3320
+ const wireMasks = () => {
3321
+ for (const runtime of runtimes.values()) {
3322
+ if (!runtime.node.mask)
3323
+ continue;
3324
+ const maskRuntime = runtimes.get(runtime.node.mask);
3325
+ runtime.instance.view.mask = maskRuntime ? maskRuntime.instance.view : null;
3326
+ }
3327
+ };
3328
+ wireMasks();
3329
+ // The doc's sole reelGrid is the implicit target of `grid-cell` rules without a `grid` ref.
3330
+ const defaultGridId = () => {
3331
+ const grids = [...runtimes.values()].filter((r) => r.instance.gridCell);
3332
+ if (grids.length > 1)
3333
+ warnOnce('multi-grid', `doc has ${grids.length} grid nodes — grid-cell rules should name one`);
3334
+ return grids[0]?.node.id;
3335
+ };
3336
+ const rebuildNode = (runtime) => {
3337
+ const parentView = runtime.parent ? runtime.parent.instance.view : rootView;
3338
+ const index = parentView.getChildIndex(runtime.instance.view);
3339
+ parentView.removeChild(runtime.instance.view);
3340
+ runtime.instance.destroy?.();
3341
+ const contribution = registry.nodeType(runtime.node.type);
3342
+ runtime.instance = contribution.create(runtime.node, createCtx);
3343
+ parentView.addChildAt(runtime.instance.view, index);
3344
+ // Children live inside the instance view — rebuild the whole subtree beneath it.
3345
+ for (const child of runtime.node.children ?? []) {
3346
+ const childRuntime = runtimes.get(child.id);
3347
+ if (childRuntime) {
3348
+ runtimes.delete(child.id);
3349
+ childRuntime.instance.destroy?.();
3350
+ }
3351
+ build(child, runtime, runtime.instance.view);
3352
+ }
3353
+ wireMasks();
3354
+ };
3355
+ const relayout = () => layout(lastWidth, lastHeight);
3356
+ const layout = (width, height) => {
3357
+ lastWidth = width;
3358
+ lastHeight = height;
3359
+ const orientation = orientationOf(width, height, opts.portraitFactor ?? 1);
3360
+ // Phase 1 — effective props (base + orientation + state), applied before measuring.
3361
+ for (const runtime of [...runtimes.values()]) {
3362
+ const eff = effectiveNode(runtime.node, orientation, runtime.state);
3363
+ runtime.visibleBase = eff.visible;
3364
+ const space = eff.props.space;
3365
+ runtime.spaceSize =
3366
+ space && typeof space.width === 'number' && space.width > 0 && typeof space.height === 'number' && space.height > 0
3367
+ ? { width: space.width, height: space.height }
3368
+ : undefined;
3369
+ const propsJson = JSON.stringify(eff.props ?? {});
3370
+ if (propsJson !== runtime.appliedProps) {
3371
+ runtime.appliedProps = propsJson;
3372
+ if (runtime.instance.applyProps)
3373
+ runtime.instance.applyProps(eff.props);
3374
+ else
3375
+ rebuildNode(runtime);
3376
+ }
3377
+ }
3378
+ // Phase 2 — resolve layout rules in passes (deps: parent first, then referenced nodes).
3379
+ for (const runtime of runtimes.values())
3380
+ runtime.bounds = undefined;
3381
+ // Frame = nearest ancestor with a declared space; null = the live viewport.
3382
+ const frameCache = new Map();
3383
+ const frameOf = (runtime) => {
3384
+ if (frameCache.has(runtime))
3385
+ return frameCache.get(runtime);
3386
+ let cursor = runtime.parent;
3387
+ while (cursor && !cursor.spaceSize)
3388
+ cursor = cursor.parent;
3389
+ frameCache.set(runtime, cursor);
3390
+ return cursor;
3391
+ };
3392
+ // Rules may only reference nodes in the SAME frame — a cross-space rect is meaningless.
3393
+ const boundsInFrame = (id, frame) => {
3394
+ const target = runtimes.get(id);
3395
+ if (!target)
3396
+ return undefined;
3397
+ if (frameOf(target) !== frame) {
3398
+ warnOnce(`xspace:${id}`, `layout references "${id}" across coordinate spaces — unsupported`);
3399
+ return undefined;
3400
+ }
3401
+ return target.bounds;
3402
+ };
3403
+ const ctxFor = (frame) => ({
3404
+ viewport: frame ? frame.spaceSize : { width, height },
3405
+ design: doc.design,
3406
+ bounds: (id) => boundsInFrame(id, frame),
3407
+ innerRect: (id) => {
3408
+ const target = runtimes.get(id);
3409
+ if (!target?.bounds || boundsInFrame(id, frame) === undefined)
3410
+ return undefined;
3411
+ const inner = target.node.props?.inner;
3412
+ if (!inner) {
3413
+ warnOnce(`inner:${id}`, `frame-fraction use:'inner' targets "${id}" which declares no props.inner`);
3414
+ return target.bounds;
3415
+ }
3416
+ return {
3417
+ x: target.bounds.x + inner.left * target.bounds.width,
3418
+ y: target.bounds.y + inner.top * target.bounds.height,
3419
+ width: inner.width * target.bounds.width,
3420
+ height: inner.height * target.bounds.height,
3421
+ };
3422
+ },
3423
+ gridCell: (id, col, row) => {
3424
+ const gridId = id ?? defaultGridId();
3425
+ const grid = gridId ? runtimes.get(gridId) : undefined;
3426
+ if (!grid || !grid.instance.gridCell || !boundsInFrame(grid.node.id, frame))
3427
+ return undefined;
3428
+ const local = grid.instance.gridCell(col, row);
3429
+ return {
3430
+ x: grid.bounds.x + local.x * grid.worldScaleX,
3431
+ y: grid.bounds.y + local.y * grid.worldScaleY,
3432
+ };
3433
+ },
3434
+ gridScale: (id) => {
3435
+ const gridId = id ?? defaultGridId();
3436
+ const grid = gridId ? runtimes.get(gridId) : undefined;
3437
+ return grid && boundsInFrame(grid.node.id, frame) ? { x: grid.worldScaleX, y: grid.worldScaleY } : undefined;
3438
+ },
3439
+ });
3440
+ const ctxByFrame = new Map();
3441
+ const layoutCtx = (frame) => {
3442
+ let cached = ctxByFrame.get(frame);
3443
+ if (!cached) {
3444
+ cached = ctxFor(frame);
3445
+ ctxByFrame.set(frame, cached);
3446
+ }
3447
+ return cached;
3448
+ };
3449
+ let pending = [...runtimes.values()];
3450
+ for (let pass = 0; pass < MAX_LAYOUT_PASSES && pending.length > 0; pass++) {
3451
+ const next = [];
3452
+ let progressed = false;
3453
+ for (const runtime of pending) {
3454
+ const frame = frameOf(runtime);
3455
+ // The parent's rect anchors this node — unless the parent IS the node's space root
3456
+ // (space content is authored in the space's own fixed coordinates).
3457
+ const parentIsFrame = runtime.parent === frame;
3458
+ if (runtime.parent && !parentIsFrame && !runtime.parent.bounds) {
3459
+ next.push(runtime);
3460
+ continue;
3461
+ }
3462
+ const parentOriginX = parentIsFrame ? 0 : (runtime.parent?.bounds?.x ?? 0);
3463
+ const parentOriginY = parentIsFrame ? 0 : (runtime.parent?.bounds?.y ?? 0);
3464
+ const parentScaleX = parentIsFrame ? 1 : (runtime.parent?.worldScaleX ?? 1);
3465
+ const parentScaleY = parentIsFrame ? 1 : (runtime.parent?.worldScaleY ?? 1);
3466
+ const eff = effectiveNode(runtime.node, orientation, runtime.state);
3467
+ const natural = runtime.instance.measure();
3468
+ const rule = eff.layout;
3469
+ const resolution = rule
3470
+ ? resolveLayoutRule(rule, natural, layoutCtx(frame))
3471
+ : {
3472
+ placement: {
3473
+ x: parentOriginX,
3474
+ y: parentOriginY,
3475
+ scaleX: parentScaleX,
3476
+ scaleY: parentScaleY,
3477
+ rotation: 0,
3478
+ anchor: [0, 0],
3479
+ },
3480
+ };
3481
+ if ('waitingFor' in resolution) {
3482
+ next.push(runtime);
3483
+ continue;
3484
+ }
3485
+ const p = resolution.placement;
3486
+ const view = runtime.instance.view;
3487
+ view.position.set((p.x - parentOriginX) / parentScaleX, (p.y - parentOriginY) / parentScaleY);
3488
+ view.scale.set(p.scaleX / parentScaleX, p.scaleY / parentScaleY);
3489
+ view.rotation = p.rotation;
3490
+ view.pivot.set(p.anchor[0] * natural.width, p.anchor[1] * natural.height);
3491
+ runtime.bounds = placementBounds(p, natural);
3492
+ runtime.worldScaleX = p.scaleX;
3493
+ runtime.worldScaleY = p.scaleY;
3494
+ if (p.rotation !== 0 && (runtime.node.children?.length ?? 0) > 0) {
3495
+ warnOnce(`rot:${runtime.node.id}`, `node "${runtime.node.id}" rotates with laid-out children — child rules ignore parent rotation`);
3496
+ }
3497
+ progressed = true;
3498
+ }
3499
+ pending = next;
3500
+ if (!progressed)
3501
+ break;
3502
+ }
3503
+ for (const runtime of pending) {
3504
+ warnOnce(`unresolved:${runtime.node.id}`, `layout of "${runtime.node.id}" never resolved (circular or missing reference) — left at identity`);
3505
+ runtime.bounds = { x: 0, y: 0, width: 0, height: 0 };
3506
+ }
3507
+ // Phase 3 — visibility (base/state visible flag + visibleWhen condition).
3508
+ for (const runtime of runtimes.values()) {
3509
+ let visible = runtime.visibleBase;
3510
+ const expr = runtime.node.visibleWhen;
3511
+ if (visible && expr) {
3512
+ const result = evalVisibleWhen(expr, vars);
3513
+ if (result === undefined)
3514
+ warnOnce(`when:${runtime.node.id}`, `unsupported visibleWhen "${expr}" on "${runtime.node.id}" — treated as visible`);
3515
+ else
3516
+ visible = result;
3517
+ }
3518
+ runtime.instance.view.visible = visible;
3519
+ }
3520
+ };
3521
+ const toOutline = (node) => {
3522
+ const runtime = runtimes.get(node.id);
3523
+ return {
3524
+ id: node.id,
3525
+ type: node.type,
3526
+ name: node.name,
3527
+ anchorName: node.anchorName,
3528
+ visible: runtime?.instance.view.visible ?? true,
3529
+ state: runtime?.state ?? null,
3530
+ children: (node.children ?? []).map(toOutline),
3531
+ };
3532
+ };
3533
+ const findNode = (root, id) => {
3534
+ if (root.id === id)
3535
+ return root;
3536
+ for (const child of root.children ?? []) {
3537
+ const hit = findNode(child, id);
3538
+ if (hit)
3539
+ return hit;
3540
+ }
3541
+ return undefined;
3542
+ };
3543
+ const setVar = (name, value) => {
3544
+ vars[name] = value;
3545
+ relayout();
3546
+ };
3547
+ const setState = (id, state) => {
3548
+ const runtime = runtimes.get(id);
3549
+ if (!runtime)
3550
+ return warnOnce(`state:${id}`, `setState: unknown node "${id}"`);
3551
+ if (state && !runtime.node.states?.[state]) {
3552
+ return warnOnce(`state:${id}:${state}`, `setState: node "${id}" has no state "${state}"`);
3553
+ }
3554
+ runtime.state = state;
3555
+ relayout();
3556
+ };
3557
+ const applyPatch = (patch) => {
3558
+ switch (patch.op) {
3559
+ case 'set-props': {
3560
+ const node = findNode(doc.root, patch.id);
3561
+ if (!node)
3562
+ return warnOnce(`patch:${patch.id}`, `patch: unknown node "${patch.id}"`);
3563
+ node.props = { ...(node.props ?? {}), ...patch.props };
3564
+ break;
3565
+ }
3566
+ case 'set-layout': {
3567
+ const node = findNode(doc.root, patch.id);
3568
+ if (!node)
3569
+ return warnOnce(`patch:${patch.id}`, `patch: unknown node "${patch.id}"`);
3570
+ if (patch.orientation) {
3571
+ node.responsive = { ...(node.responsive ?? {}) };
3572
+ node.responsive[patch.orientation] = {
3573
+ ...(node.responsive[patch.orientation] ?? {}),
3574
+ layout: patch.layout,
3575
+ };
3576
+ }
3577
+ else {
3578
+ node.layout = patch.layout;
3579
+ }
3580
+ break;
3581
+ }
3582
+ case 'set-state':
3583
+ return setState(patch.id, patch.state);
3584
+ case 'set-var':
3585
+ return setVar(patch.name, patch.value);
3586
+ }
3587
+ relayout();
3588
+ };
3589
+ return {
3590
+ view: rootView,
3591
+ node: (id) => runtimes.get(id)?.instance.view,
3592
+ instance: (id) => runtimes.get(id)?.instance,
3593
+ anchor(name) {
3594
+ for (const runtime of runtimes.values()) {
3595
+ if (runtime.node.anchorName === name)
3596
+ return runtime.instance.view;
3597
+ }
3598
+ return undefined;
3599
+ },
3600
+ tree: () => toOutline(doc.root),
3601
+ layout,
3602
+ setVar,
3603
+ setState,
3604
+ patch: applyPatch,
3605
+ doc: () => doc,
3606
+ destroy() {
3607
+ for (const runtime of runtimes.values())
3608
+ runtime.instance.destroy?.();
3609
+ runtimes.clear();
3610
+ rootView.destroy({ children: true });
3611
+ },
3612
+ };
3613
+ }
3614
+
3615
+ exports.BUILTIN_NODE_TYPES = BUILTIN_NODE_TYPES;
3616
+ exports.createSceneFromDoc = createSceneFromDoc;
3617
+ exports.createSceneRegistry = createSceneRegistry;
3618
+ exports.dependencyOf = dependencyOf;
3619
+ exports.edgePoint = edgePoint;
3620
+ exports.effectiveNode = effectiveNode;
3621
+ exports.evalVisibleWhen = evalVisibleWhen;
3622
+ exports.orientationOf = orientationOf;
3623
+ exports.placementBounds = placementBounds;
3624
+ exports.resolveLayoutRule = resolveLayoutRule;
3625
+ exports.validateSceneDoc = validateSceneDoc;
3626
+ //# sourceMappingURL=scene.cjs.js.map