@energy8platform/game-engine 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/audio.cjs.js +15 -5
  2. package/dist/audio.cjs.js.map +1 -1
  3. package/dist/audio.d.ts +5 -0
  4. package/dist/audio.esm.js +15 -5
  5. package/dist/audio.esm.js.map +1 -1
  6. package/dist/core.cjs.js +108 -19
  7. package/dist/core.cjs.js.map +1 -1
  8. package/dist/core.d.ts +46 -3
  9. package/dist/core.esm.js +109 -21
  10. package/dist/core.esm.js.map +1 -1
  11. package/dist/game-spec.cjs.js +13 -0
  12. package/dist/game-spec.cjs.js.map +1 -0
  13. package/dist/game-spec.d.ts +1 -0
  14. package/dist/game-spec.esm.js +2 -0
  15. package/dist/game-spec.esm.js.map +1 -0
  16. package/dist/host.cjs.js +3612 -0
  17. package/dist/host.cjs.js.map +1 -0
  18. package/dist/host.d.ts +1000 -0
  19. package/dist/host.esm.js +3603 -0
  20. package/dist/host.esm.js.map +1 -0
  21. package/dist/index.cjs.js +59 -19
  22. package/dist/index.cjs.js.map +1 -1
  23. package/dist/index.d.ts +19 -2
  24. package/dist/index.esm.js +59 -19
  25. package/dist/index.esm.js.map +1 -1
  26. package/dist/react.cjs.js.map +1 -1
  27. package/dist/react.d.ts +19 -2
  28. package/dist/react.esm.js.map +1 -1
  29. package/dist/shell.cjs.js +19 -0
  30. package/dist/shell.cjs.js.map +1 -0
  31. package/dist/shell.d.ts +1 -0
  32. package/dist/shell.esm.js +2 -0
  33. package/dist/shell.esm.js.map +1 -0
  34. package/dist/slot.cjs.js +998 -0
  35. package/dist/slot.cjs.js.map +1 -0
  36. package/dist/slot.d.ts +333 -0
  37. package/dist/slot.esm.js +985 -0
  38. package/dist/slot.esm.js.map +1 -0
  39. package/package.json +28 -2
  40. package/src/audio/AudioManager.ts +17 -5
  41. package/src/core/GameApplication.ts +38 -6
  42. package/src/core/index.ts +2 -0
  43. package/src/game-spec/index.ts +1 -0
  44. package/src/host/autoplay.ts +78 -0
  45. package/src/host/balanceGate.ts +46 -0
  46. package/src/host/buildConfig.ts +28 -0
  47. package/src/host/createSlotGame.ts +543 -0
  48. package/src/host/fatalError.ts +104 -0
  49. package/src/host/freeSpinsCounter.ts +44 -0
  50. package/src/host/index.ts +21 -0
  51. package/src/host/overlayController.ts +81 -0
  52. package/src/host/pauseController.ts +21 -0
  53. package/src/host/playError.ts +64 -0
  54. package/src/host/preboot.ts +25 -0
  55. package/src/host/replay.ts +9 -0
  56. package/src/host/runRound.ts +55 -0
  57. package/src/host/sceneAudio.ts +14 -0
  58. package/src/host/sceneController.ts +96 -0
  59. package/src/host/sceneStart.ts +25 -0
  60. package/src/host/shellConfig.ts +384 -0
  61. package/src/host/skipGesture.ts +24 -0
  62. package/src/host/slotPlay.ts +62 -0
  63. package/src/host/types.ts +74 -0
  64. package/src/scenes/IntroScene.ts +66 -0
  65. package/src/shell/index.ts +20 -0
  66. package/src/slot/anim/CascadeController.ts +102 -0
  67. package/src/slot/anim/ReelSpinController.ts +81 -0
  68. package/src/slot/anim/easing-map.ts +14 -0
  69. package/src/slot/freeSpins/FreeSpinsSession.ts +40 -0
  70. package/src/slot/grid/AnimatedSymbol.ts +68 -0
  71. package/src/slot/grid/ReelGrid.ts +92 -0
  72. package/src/slot/grid/SymbolCell.ts +127 -0
  73. package/src/slot/grid/SymbolView.ts +13 -0
  74. package/src/slot/index.ts +21 -0
  75. package/src/slot/multiplier/MultiplierAccumulator.ts +29 -0
  76. package/src/slot/overlay/BigWinOverlay.ts +89 -0
  77. package/src/slot/overlay/CountUpDisplay.ts +56 -0
  78. package/src/slot/overlay/tiers.ts +29 -0
  79. package/src/types.ts +3 -0
  80. package/src/viewport/ViewportManager.ts +19 -9
@@ -0,0 +1,985 @@
1
+ import { Ticker, AnimatedSprite, Texture, Container, Sprite, Graphics, Text } from 'pixi.js';
2
+
3
+ /**
4
+ * Collection of easing functions for use with Tween and Timeline.
5
+ *
6
+ * All functions take a progress value t (0..1) and return the eased value.
7
+ */
8
+ const Easing = {
9
+ linear: (t) => t,
10
+ easeOutQuad: (t) => t * (2 - t),
11
+ easeOutCubic: (t) => --t * t * t + 1,
12
+ easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
13
+ easeInBack: (t) => {
14
+ const c1 = 1.70158;
15
+ const c3 = c1 + 1;
16
+ return c3 * t * t * t - c1 * t * t;
17
+ },
18
+ easeOutBack: (t) => {
19
+ const c1 = 1.70158;
20
+ const c3 = c1 + 1;
21
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
22
+ },
23
+ easeOutBounce: (t) => {
24
+ const n1 = 7.5625;
25
+ const d1 = 2.75;
26
+ if (t < 1 / d1)
27
+ return n1 * t * t;
28
+ if (t < 2 / d1)
29
+ return n1 * (t -= 1.5 / d1) * t + 0.75;
30
+ if (t < 2.5 / d1)
31
+ return n1 * (t -= 2.25 / d1) * t + 0.9375;
32
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
33
+ }};
34
+
35
+ /**
36
+ * Lightweight tween system integrated with PixiJS Ticker.
37
+ * Zero external dependencies — no GSAP required.
38
+ *
39
+ * All tweens return a Promise that resolves on completion.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * // Fade in a sprite
44
+ * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
45
+ *
46
+ * // Move and wait
47
+ * await Tween.to(sprite, { x: 500 }, 300);
48
+ *
49
+ * // From a starting value
50
+ * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
51
+ * ```
52
+ */
53
+ class Tween {
54
+ static _tweens = [];
55
+ static _tickerAdded = false;
56
+ /**
57
+ * Animate properties from current values to target values.
58
+ *
59
+ * @param target - Object to animate (Sprite, Container, etc.)
60
+ * @param props - Target property values
61
+ * @param duration - Duration in milliseconds
62
+ * @param easing - Easing function (default: easeOutQuad)
63
+ * @param onUpdate - Progress callback (0..1)
64
+ */
65
+ static to(target, props, duration, easing, onUpdate) {
66
+ return new Promise((resolve) => {
67
+ // Capture starting values
68
+ const from = {};
69
+ for (const key of Object.keys(props)) {
70
+ from[key] = Tween.getProperty(target, key);
71
+ }
72
+ const tween = {
73
+ target,
74
+ from,
75
+ to: { ...props },
76
+ duration: Math.max(1, duration),
77
+ easing: easing ?? Easing.easeOutQuad,
78
+ elapsed: 0,
79
+ delay: 0,
80
+ resolve,
81
+ onUpdate,
82
+ };
83
+ Tween._tweens.push(tween);
84
+ Tween.ensureTicker();
85
+ });
86
+ }
87
+ /**
88
+ * Animate properties from given values to current values.
89
+ */
90
+ static from(target, props, duration, easing, onUpdate) {
91
+ // Capture current values as "to"
92
+ const to = {};
93
+ for (const key of Object.keys(props)) {
94
+ to[key] = Tween.getProperty(target, key);
95
+ Tween.setProperty(target, key, props[key]);
96
+ }
97
+ return Tween.to(target, to, duration, easing, onUpdate);
98
+ }
99
+ /**
100
+ * Animate from one set of values to another.
101
+ */
102
+ static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
103
+ // Set starting values
104
+ for (const key of Object.keys(fromProps)) {
105
+ Tween.setProperty(target, key, fromProps[key]);
106
+ }
107
+ return Tween.to(target, toProps, duration, easing, onUpdate);
108
+ }
109
+ /**
110
+ * Wait for a given duration (useful in timelines).
111
+ * Uses PixiJS Ticker for consistent timing with other tweens.
112
+ */
113
+ static delay(ms) {
114
+ return new Promise((resolve) => {
115
+ let elapsed = 0;
116
+ const onTick = (ticker) => {
117
+ elapsed += ticker.deltaMS;
118
+ if (elapsed >= ms) {
119
+ Ticker.shared.remove(onTick);
120
+ resolve();
121
+ }
122
+ };
123
+ Ticker.shared.add(onTick);
124
+ });
125
+ }
126
+ /**
127
+ * Kill all tweens on a target.
128
+ */
129
+ static killTweensOf(target) {
130
+ Tween._tweens = Tween._tweens.filter((tw) => {
131
+ if (tw.target === target) {
132
+ tw.resolve();
133
+ return false;
134
+ }
135
+ return true;
136
+ });
137
+ }
138
+ /**
139
+ * Kill all active tweens.
140
+ */
141
+ static killAll() {
142
+ for (const tw of Tween._tweens) {
143
+ tw.resolve();
144
+ }
145
+ Tween._tweens.length = 0;
146
+ }
147
+ /** Number of active tweens */
148
+ static get activeTweens() {
149
+ return Tween._tweens.length;
150
+ }
151
+ /**
152
+ * Reset the tween system — kill all tweens and remove the ticker.
153
+ * Useful for cleanup between game instances, tests, or hot-reload.
154
+ */
155
+ static reset() {
156
+ for (const tw of Tween._tweens) {
157
+ tw.resolve();
158
+ }
159
+ Tween._tweens.length = 0;
160
+ if (Tween._tickerAdded) {
161
+ Ticker.shared.remove(Tween.tick);
162
+ Tween._tickerAdded = false;
163
+ }
164
+ }
165
+ // ─── Internal ──────────────────────────────────────────
166
+ static ensureTicker() {
167
+ if (Tween._tickerAdded)
168
+ return;
169
+ Tween._tickerAdded = true;
170
+ Ticker.shared.add(Tween.tick);
171
+ }
172
+ static tick = (ticker) => {
173
+ const dt = ticker.deltaMS;
174
+ const completed = [];
175
+ for (const tw of Tween._tweens) {
176
+ tw.elapsed += dt;
177
+ if (tw.elapsed < tw.delay)
178
+ continue;
179
+ const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
180
+ const t = tw.easing(raw);
181
+ // Interpolate each property
182
+ for (const key of Object.keys(tw.to)) {
183
+ const start = tw.from[key];
184
+ const end = tw.to[key];
185
+ const value = start + (end - start) * t;
186
+ Tween.setProperty(tw.target, key, value);
187
+ }
188
+ tw.onUpdate?.(raw);
189
+ if (raw >= 1) {
190
+ completed.push(tw);
191
+ }
192
+ }
193
+ // Remove completed tweens
194
+ for (const tw of completed) {
195
+ const idx = Tween._tweens.indexOf(tw);
196
+ if (idx !== -1)
197
+ Tween._tweens.splice(idx, 1);
198
+ tw.resolve();
199
+ }
200
+ // Remove ticker when no active tweens
201
+ if (Tween._tweens.length === 0 && Tween._tickerAdded) {
202
+ Ticker.shared.remove(Tween.tick);
203
+ Tween._tickerAdded = false;
204
+ }
205
+ };
206
+ /**
207
+ * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
208
+ */
209
+ static getProperty(target, key) {
210
+ const parts = key.split('.');
211
+ let obj = target;
212
+ for (let i = 0; i < parts.length - 1; i++) {
213
+ obj = obj[parts[i]];
214
+ }
215
+ return obj[parts[parts.length - 1]] ?? 0;
216
+ }
217
+ /**
218
+ * Set a potentially nested property.
219
+ */
220
+ static setProperty(target, key, value) {
221
+ const parts = key.split('.');
222
+ let obj = target;
223
+ for (let i = 0; i < parts.length - 1; i++) {
224
+ obj = obj[parts[i]];
225
+ }
226
+ obj[parts[parts.length - 1]] = value;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Helper for creating frame-based animations from spritesheets.
232
+ *
233
+ * Wraps PixiJS `AnimatedSprite` with a convenient API for
234
+ * common iGaming effects: coin showers, symbol animations,
235
+ * sparkle trails, win celebrations.
236
+ *
237
+ * Cheaper than Spine for simple frame sequences.
238
+ *
239
+ * @example
240
+ * ```ts
241
+ * // From an array of textures
242
+ * const coinAnim = SpriteAnimation.create(coinTextures, {
243
+ * fps: 30,
244
+ * loop: true,
245
+ * });
246
+ * scene.addChild(coinAnim);
247
+ *
248
+ * // From a spritesheet with a naming pattern
249
+ * const sheet = Assets.get('effects');
250
+ * const sparkle = SpriteAnimation.fromSpritesheet(sheet, 'sparkle_');
251
+ * sparkle.play();
252
+ *
253
+ * // From a numbered range
254
+ * const explosion = SpriteAnimation.fromRange(sheet, 'explosion_{i}', 0, 24, {
255
+ * fps: 60,
256
+ * loop: false,
257
+ * onComplete: () => explosion.destroy(),
258
+ * });
259
+ * ```
260
+ */
261
+ class SpriteAnimation {
262
+ /**
263
+ * Create an animated sprite from an array of textures.
264
+ *
265
+ * @param textures - Array of PixiJS Textures
266
+ * @param config - Animation options
267
+ * @returns Configured AnimatedSprite
268
+ */
269
+ static create(textures, config = {}) {
270
+ const sprite = new AnimatedSprite(textures);
271
+ // Configure
272
+ sprite.animationSpeed = (config.fps ?? 24) / 60; // PixiJS uses speed relative to 60fps ticker
273
+ sprite.loop = config.loop ?? true;
274
+ // Anchor
275
+ if (config.anchor !== undefined) {
276
+ if (typeof config.anchor === 'number') {
277
+ sprite.anchor.set(config.anchor);
278
+ }
279
+ else {
280
+ sprite.anchor.set(config.anchor.x, config.anchor.y);
281
+ }
282
+ }
283
+ else {
284
+ sprite.anchor.set(0.5);
285
+ }
286
+ // Complete callback
287
+ if (config.onComplete) {
288
+ sprite.onComplete = config.onComplete;
289
+ }
290
+ // Auto-play
291
+ if (config.autoPlay !== false) {
292
+ sprite.play();
293
+ }
294
+ return sprite;
295
+ }
296
+ /**
297
+ * Create an animated sprite from a spritesheet using a name prefix.
298
+ *
299
+ * Collects all textures whose keys start with `prefix`, sorted alphabetically.
300
+ *
301
+ * @param sheet - PixiJS Spritesheet instance
302
+ * @param prefix - Texture name prefix (e.g., 'coin_')
303
+ * @param config - Animation options
304
+ * @returns Configured AnimatedSprite
305
+ */
306
+ static fromSpritesheet(sheet, prefix, config = {}) {
307
+ const textures = SpriteAnimation.getTexturesByPrefix(sheet, prefix);
308
+ if (textures.length === 0) {
309
+ console.warn(`[SpriteAnimation] No textures found with prefix "${prefix}"`);
310
+ }
311
+ return SpriteAnimation.create(textures, config);
312
+ }
313
+ /**
314
+ * Create an animated sprite from a numbered range of frames.
315
+ *
316
+ * The `pattern` string should contain `{i}` as a placeholder for the frame number.
317
+ * Numbers are zero-padded to match the length of `start`.
318
+ *
319
+ * @param sheet - PixiJS Spritesheet instance
320
+ * @param pattern - Frame name pattern, e.g. 'explosion_{i}'
321
+ * @param start - Start frame index (inclusive)
322
+ * @param end - End frame index (inclusive)
323
+ * @param config - Animation options
324
+ * @returns Configured AnimatedSprite
325
+ */
326
+ static fromRange(sheet, pattern, start, end, config = {}) {
327
+ const textures = [];
328
+ const padLength = String(end).length;
329
+ for (let i = start; i <= end; i++) {
330
+ const name = pattern.replace('{i}', String(i).padStart(padLength, '0'));
331
+ const texture = sheet.textures[name];
332
+ if (texture) {
333
+ textures.push(texture);
334
+ }
335
+ else {
336
+ console.warn(`[SpriteAnimation] Missing frame: "${name}"`);
337
+ }
338
+ }
339
+ if (textures.length === 0) {
340
+ console.warn(`[SpriteAnimation] No textures found for pattern "${pattern}" [${start}..${end}]`);
341
+ }
342
+ return SpriteAnimation.create(textures, config);
343
+ }
344
+ /**
345
+ * Create an AnimatedSprite from texture aliases (loaded via AssetManager).
346
+ *
347
+ * @param aliases - Array of texture aliases
348
+ * @param config - Animation options
349
+ * @returns Configured AnimatedSprite
350
+ */
351
+ static fromAliases(aliases, config = {}) {
352
+ const textures = aliases.map((alias) => {
353
+ const tex = Texture.from(alias);
354
+ return tex;
355
+ });
356
+ return SpriteAnimation.create(textures, config);
357
+ }
358
+ /**
359
+ * Play a one-shot animation and auto-destroy when complete.
360
+ * Useful for fire-and-forget effects like coin bursts.
361
+ *
362
+ * @param textures - Array of textures
363
+ * @param config - Animation options (loop will be forced to false)
364
+ * @returns Promise that resolves when animation completes
365
+ */
366
+ static playOnce(textures, config = {}) {
367
+ const finished = new Promise((resolve) => {
368
+ config = {
369
+ ...config,
370
+ loop: false,
371
+ onComplete: () => {
372
+ config.onComplete?.();
373
+ sprite.destroy();
374
+ resolve();
375
+ },
376
+ };
377
+ });
378
+ const sprite = SpriteAnimation.create(textures, config);
379
+ return { sprite, finished };
380
+ }
381
+ // ─── Utility ───────────────────────────────────────────
382
+ /**
383
+ * Get all textures from a spritesheet that start with a given prefix.
384
+ * Results are sorted alphabetically by key.
385
+ */
386
+ static getTexturesByPrefix(sheet, prefix) {
387
+ const keys = Object.keys(sheet.textures)
388
+ .filter((k) => k.startsWith(prefix))
389
+ .sort();
390
+ return keys.map((k) => sheet.textures[k]);
391
+ }
392
+ }
393
+
394
+ /** Built-in SymbolView: a static base sprite with optional idle/win spritesheet frames. */
395
+ class AnimatedSymbol extends Container {
396
+ _base;
397
+ _anim = null;
398
+ _textures;
399
+ _size;
400
+ _fps;
401
+ constructor(config) {
402
+ super();
403
+ this._textures = config.textures;
404
+ this._size = config.size;
405
+ this._fps = config.fps ?? 24;
406
+ this._base = new Sprite(config.textures.base);
407
+ this._base.anchor.set(0.5);
408
+ this.addChild(this._base);
409
+ this.resize(this._size);
410
+ }
411
+ setTextures(t) {
412
+ this._textures = t;
413
+ this._base.texture = t.base;
414
+ this.showStatic();
415
+ }
416
+ resize(size) {
417
+ this._size = size;
418
+ this._base.width = size;
419
+ this._base.height = size;
420
+ if (this._anim) {
421
+ this._anim.width = size;
422
+ this._anim.height = size;
423
+ }
424
+ }
425
+ showStatic() {
426
+ if (this._anim) {
427
+ this._anim.destroy();
428
+ this._anim = null;
429
+ }
430
+ this._base.visible = true;
431
+ }
432
+ playIdle() {
433
+ if (!this._textures.idle?.length)
434
+ return;
435
+ this._swap(this._textures.idle, true);
436
+ }
437
+ playWin() {
438
+ if (!this._textures.win?.length)
439
+ return Promise.resolve();
440
+ return new Promise((resolve) => {
441
+ this._swap(this._textures.win, false, () => { this.showStatic(); resolve(); });
442
+ });
443
+ }
444
+ _swap(frames, loop, onComplete) {
445
+ if (this._anim) {
446
+ this._anim.destroy();
447
+ this._anim = null;
448
+ }
449
+ this._base.visible = false;
450
+ const a = SpriteAnimation.create(frames, { loop, autoPlay: true, onComplete });
451
+ a.anchor.set(0.5);
452
+ a.width = this._size;
453
+ a.height = this._size;
454
+ a.animationSpeed = this._fps / 60;
455
+ this.addChild(a);
456
+ this._anim = a;
457
+ }
458
+ }
459
+
460
+ const DEFAULT_STYLE = {
461
+ radius: 8,
462
+ idle: { color: 0x223047, alpha: 0.34 },
463
+ winning: { color: 0x00d4ff, alpha: 0.95 },
464
+ removed: { color: 0x223047, alpha: 0.12 },
465
+ fresh: { color: 0xffffff, alpha: 0.6 },
466
+ };
467
+ class SymbolCell extends Container {
468
+ __uiComponent = true;
469
+ _size;
470
+ _resolve;
471
+ _style;
472
+ _frame;
473
+ _view = null;
474
+ _badges = new Container();
475
+ _multBadge = null;
476
+ _bonusBadge = null;
477
+ /** Last applied state key — exposed for tests/inspection. */
478
+ frameStyleKey = 'idle';
479
+ constructor(config) {
480
+ super();
481
+ this._size = config.size;
482
+ this._resolve = config.resolve;
483
+ this._style = { ...DEFAULT_STYLE, ...(config.frameStyle ?? {}) };
484
+ this._frame = new Graphics();
485
+ this.addChild(this._frame);
486
+ this.addChild(this._badges);
487
+ this._drawFrame('idle');
488
+ }
489
+ get view() { return this._view; }
490
+ setData(data) {
491
+ // symbol view
492
+ if (data.symbol == null) {
493
+ if (this._view) {
494
+ this._view.destroy();
495
+ this._view = null;
496
+ }
497
+ }
498
+ else {
499
+ if (this._view) {
500
+ this._view.destroy();
501
+ this._view = null;
502
+ }
503
+ const v = this._resolve(data.symbol);
504
+ if (v) {
505
+ v.resize?.(this._size);
506
+ this.addChildAt(v, 1); // above frame, below badges
507
+ this._view = v;
508
+ }
509
+ }
510
+ // badges
511
+ this._setMultiplier(data.multiplier);
512
+ this._setBonus(data.bonus);
513
+ }
514
+ setState(state) {
515
+ const key = state.winning ? 'winning' : state.removed ? 'removed' : state.fresh ? 'fresh' : 'idle';
516
+ this.frameStyleKey = key;
517
+ this._drawFrame(key);
518
+ }
519
+ playWin() {
520
+ if (this._view?.playWin)
521
+ return this._view.playWin();
522
+ // default: scale pop
523
+ const target = this._view ?? this;
524
+ return Tween.to(target, { 'scale.x': 1.15, 'scale.y': 1.15 }, 160, Easing.easeOutBack)
525
+ .then(() => Tween.to(target, { 'scale.x': 1, 'scale.y': 1 }, 140, Easing.easeOutQuad));
526
+ }
527
+ playIdle() { this._view?.playIdle?.(); }
528
+ hasBadge(kind) {
529
+ return kind === 'multiplier' ? this._multBadge != null : this._bonusBadge != null;
530
+ }
531
+ _drawFrame(key) {
532
+ const s = this._style[key];
533
+ this._frame.clear();
534
+ this._frame
535
+ .roundRect(-this._size / 2, -this._size / 2, this._size, this._size, this._style.radius)
536
+ .fill({ color: s.color, alpha: s.alpha });
537
+ // store the colour as tint for cheap inspection/testing
538
+ this._frame.tint = s.color;
539
+ }
540
+ _setMultiplier(value) {
541
+ if (this._multBadge) {
542
+ this._multBadge.destroy();
543
+ this._multBadge = null;
544
+ }
545
+ if (!value || value <= 1)
546
+ return;
547
+ this._multBadge = this._badge(`×${value}`, 0xffd24a);
548
+ this._multBadge.position.set(this._size / 2 - 12, -this._size / 2 + 12);
549
+ this._badges.addChild(this._multBadge);
550
+ }
551
+ _setBonus(value) {
552
+ if (this._bonusBadge) {
553
+ this._bonusBadge.destroy();
554
+ this._bonusBadge = null;
555
+ }
556
+ if (!value || value <= 0)
557
+ return;
558
+ this._bonusBadge = this._badge(`+${value}`, 0x7ad7ff);
559
+ this._bonusBadge.position.set(-this._size / 2 + 12, -this._size / 2 + 12);
560
+ this._badges.addChild(this._bonusBadge);
561
+ }
562
+ _badge(label, color) {
563
+ const c = new Container();
564
+ const t = new Text({ text: label, style: { fontSize: 18, fill: color, fontWeight: '700' } });
565
+ t.anchor.set(0.5);
566
+ c.addChild(t);
567
+ return c;
568
+ }
569
+ }
570
+
571
+ class ReelGrid extends Container {
572
+ __uiComponent = true;
573
+ _cols;
574
+ _rows;
575
+ _cellSize;
576
+ _gap;
577
+ _cells = [];
578
+ _cellLayer = new Container();
579
+ constructor(config) {
580
+ super();
581
+ this._cols = config.cols;
582
+ this._rows = config.rows;
583
+ this._cellSize = config.cellSize;
584
+ this._gap = config.gap ?? 0;
585
+ if (config.decoration) {
586
+ const pad = config.decoration.padding ?? 0;
587
+ const w = this._cols * (this._cellSize + this._gap) - this._gap + pad * 2;
588
+ const h = this._rows * (this._cellSize + this._gap) - this._gap + pad * 2;
589
+ if (config.decoration.texture) {
590
+ const deco = new Sprite(config.decoration.texture);
591
+ deco.width = w;
592
+ deco.height = h;
593
+ deco.position.set(-pad - this._cellSize / 2, -pad - this._cellSize / 2);
594
+ this.addChild(deco);
595
+ }
596
+ }
597
+ this.addChild(this._cellLayer);
598
+ for (let c = 0; c < this._cols; c++) {
599
+ this._cells[c] = [];
600
+ for (let r = 0; r < this._rows; r++) {
601
+ const cell = new SymbolCell({ size: this._cellSize, resolve: config.resolve, frameStyle: config.frameStyle });
602
+ const { x, y } = this.cellPosition(c, r);
603
+ cell.position.set(x, y);
604
+ this._cellLayer.addChild(cell);
605
+ this._cells[c][r] = cell;
606
+ }
607
+ }
608
+ if (config.mask) {
609
+ const w = this._cols * (this._cellSize + this._gap) - this._gap;
610
+ const h = this._rows * (this._cellSize + this._gap) - this._gap;
611
+ const m = new Graphics()
612
+ .rect(-this._cellSize / 2, -this._cellSize / 2, w, h)
613
+ .fill(0xffffff);
614
+ this._cellLayer.mask = m;
615
+ this.addChild(m);
616
+ }
617
+ }
618
+ get cols() { return this._cols; }
619
+ get rows() { return this._rows; }
620
+ cellPosition(col, row) {
621
+ const step = this._cellSize + this._gap;
622
+ return { x: col * step, y: row * step };
623
+ }
624
+ getCell(col, row) { return this._cells[col][row]; }
625
+ setGrid(cells) {
626
+ for (let c = 0; c < this._cols; c++) {
627
+ for (let r = 0; r < this._rows; r++) {
628
+ this._cells[c]?.[r]?.setData(cells[c]?.[r] ?? { symbol: null });
629
+ }
630
+ }
631
+ }
632
+ resize(cellSize) {
633
+ this._cellSize = cellSize;
634
+ for (let c = 0; c < this._cols; c++) {
635
+ for (let r = 0; r < this._rows; r++) {
636
+ const { x, y } = this.cellPosition(c, r);
637
+ this._cells[c][r].position.set(x, y);
638
+ }
639
+ }
640
+ }
641
+ }
642
+
643
+ // packages/game-engine/src/slot/anim/easing-map.ts
644
+ /** Resolve a descriptor's string easing name to the engine's easing function. */
645
+ const EASING_BY_NAME = {
646
+ linear: Easing.linear,
647
+ easeOutQuad: Easing.easeOutQuad,
648
+ easeOutCubic: Easing.easeOutCubic,
649
+ easeOutBack: Easing.easeOutBack,
650
+ easeOutBounce: Easing.easeOutBounce,
651
+ easeInBack: Easing.easeInBack,
652
+ easeInOutCubic: Easing.easeInOutCubic,
653
+ };
654
+
655
+ // packages/game-engine/src/slot/anim/CascadeController.ts
656
+ const DEFAULT_TIMINGS$1 = { reveal: 300, highlight: 400, remove: 250, drop: 200, refill: 220, wait: 150 };
657
+ class CascadeController {
658
+ _grid;
659
+ _t;
660
+ _killed = false;
661
+ constructor(grid, timings) {
662
+ this._grid = grid;
663
+ this._t = { ...DEFAULT_TIMINGS$1, ...(timings ?? {}) };
664
+ }
665
+ /** PURE: ordered animation descriptors for a cascade step. */
666
+ plan(step, opts) {
667
+ const f = opts?.turbo ? 0.5 : 1;
668
+ const out = [];
669
+ for (const w of step.winningCells) {
670
+ out.push({ col: w.col, row: w.row, phase: 'highlight', scale: 1.08, duration: this._t.highlight * f, easing: 'easeOutQuad' });
671
+ }
672
+ for (const w of step.winningCells) {
673
+ out.push({ col: w.col, row: w.row, phase: 'remove', scale: 0, alpha: 0, duration: this._t.remove * f, easing: 'easeInBack' });
674
+ }
675
+ // new cells drop from two row-heights above their target, staggered per column.
676
+ // Row height is derived purely from public geometry (no private grid access).
677
+ const rowStep = this._grid.cellPosition(0, 1).y - this._grid.cellPosition(0, 0).y;
678
+ const perCol = {};
679
+ for (const n of step.newCells) {
680
+ const to = this._grid.cellPosition(n.col, n.row);
681
+ const from = { x: to.x, y: to.y - rowStep * 2 };
682
+ const idx = (perCol[n.col] = (perCol[n.col] ?? 0) + 1);
683
+ out.push({ col: n.col, row: n.row, phase: 'drop', from, to, duration: this._t.drop * f, easing: 'easeOutBounce', delay: idx * 30 * f });
684
+ }
685
+ return out;
686
+ }
687
+ /** Execute the plan via Tween. Not unit-tested (Ticker doesn't tick in node). */
688
+ async run(step, opts) {
689
+ this._killed = false;
690
+ const plan = this.plan(step, opts);
691
+ // highlight + remove first
692
+ for (const a of plan.filter((p) => p.phase === 'highlight')) {
693
+ if (this._killed)
694
+ return;
695
+ const cell = this._grid.getCell(a.col, a.row);
696
+ cell.setState({ winning: true });
697
+ await Tween.to(cell, { 'scale.x': a.scale, 'scale.y': a.scale }, a.duration, EASING_BY_NAME[a.easing ?? 'easeOutQuad']);
698
+ }
699
+ for (const a of plan.filter((p) => p.phase === 'remove')) {
700
+ if (this._killed)
701
+ return;
702
+ const cell = this._grid.getCell(a.col, a.row);
703
+ await Tween.to(cell, { 'scale.x': 0, 'scale.y': 0, alpha: 0 }, a.duration, EASING_BY_NAME[a.easing ?? 'easeInBack']);
704
+ }
705
+ // settle data, then drop new cells in
706
+ this._grid.setGrid(step.settledGrid);
707
+ await Promise.all(plan.filter((p) => p.phase === 'drop').map(async (a) => {
708
+ if (this._killed)
709
+ return;
710
+ const cell = this._grid.getCell(a.col, a.row);
711
+ cell.alpha = 1;
712
+ cell.scale.set(1);
713
+ cell.position.set(a.from.x, a.from.y);
714
+ if (a.delay)
715
+ await Tween.delay(a.delay);
716
+ await Tween.to(cell, { 'position.y': a.to.y }, a.duration, EASING_BY_NAME[a.easing ?? 'easeOutBounce']);
717
+ }));
718
+ }
719
+ _killOwnTweens() {
720
+ for (let c = 0; c < this._grid.cols; c++) {
721
+ for (let r = 0; r < this._grid.rows; r++) {
722
+ Tween.killTweensOf(this._grid.getCell(c, r));
723
+ }
724
+ }
725
+ }
726
+ skip() { this._killOwnTweens(); }
727
+ kill() { this._killed = true; this._killOwnTweens(); }
728
+ }
729
+
730
+ // packages/game-engine/src/slot/anim/ReelSpinController.ts
731
+ const DEFAULT_TIMINGS = { spinUp: 500, hold: 200, stopStagger: 120, settle: 240 };
732
+ class ReelSpinController {
733
+ _grid;
734
+ _t;
735
+ _killed = false;
736
+ constructor(grid, timings) {
737
+ this._grid = grid;
738
+ this._t = { ...DEFAULT_TIMINGS, ...(timings ?? {}) };
739
+ }
740
+ /** PURE: per-reel stop timing + landing window. No Pixi mutation. */
741
+ plan(data, opts) {
742
+ const f = opts?.turbo ? 0.5 : 1;
743
+ const out = [];
744
+ for (let reel = 0; reel < this._grid.cols; reel++) {
745
+ out.push({
746
+ reel,
747
+ stopTime: this._t.spinUp * f + reel * this._t.stopStagger * f,
748
+ landing: data.targetGrid[reel] ?? [],
749
+ settle: { amp: 7, ms: this._t.settle * f },
750
+ });
751
+ }
752
+ return out;
753
+ }
754
+ /** Execute the spin: scroll each reel, decelerate, land on target, settle-bounce. Not unit-tested. */
755
+ async run(data, opts) {
756
+ this._killed = false;
757
+ const plan = this.plan(data, opts);
758
+ await Promise.all(plan.map(async (p) => {
759
+ if (this._killed)
760
+ return;
761
+ const strip = data.strip?.(p.reel) ?? p.landing.map((c) => c.symbol ?? '');
762
+ // texture-swap spin: cycle symbols quickly while decelerating, then land
763
+ const cells = Array.from({ length: this._grid.rows }, (_, r) => this._grid.getCell(p.reel, r));
764
+ const ticks = Math.max(6, Math.floor(p.stopTime / 60));
765
+ for (let i = 0; i < ticks; i++) {
766
+ if (this._killed)
767
+ break;
768
+ for (let r = 0; r < cells.length; r++) {
769
+ const sym = strip[(i + r) % strip.length] || null;
770
+ cells[r].setData({ symbol: sym });
771
+ }
772
+ await Tween.delay(Math.min(60, p.stopTime / ticks));
773
+ }
774
+ // land on the real target
775
+ for (let r = 0; r < cells.length; r++)
776
+ cells[r].setData(p.landing[r] ?? { symbol: null });
777
+ // settle bounce on the column parent
778
+ if (!this._killed && cells[0]?.parent) {
779
+ const colY = cells[0].parent.y;
780
+ await Tween.fromTo(cells[0].parent, { y: colY - p.settle.amp }, { y: colY }, p.settle.ms, Easing.easeOutBack);
781
+ }
782
+ }));
783
+ }
784
+ _killOwnTweens() {
785
+ for (let c = 0; c < this._grid.cols; c++) {
786
+ for (let r = 0; r < this._grid.rows; r++) {
787
+ Tween.killTweensOf(this._grid.getCell(c, r));
788
+ }
789
+ }
790
+ }
791
+ skip() { this._killed = true; this._killOwnTweens(); }
792
+ }
793
+
794
+ /** Highest tier whose minMultiplier <= win/bet, or null if below the lowest. */
795
+ function pickTier(tiers, win, bet) {
796
+ if (bet <= 0)
797
+ return null;
798
+ const mult = win / bet;
799
+ let chosen = null;
800
+ for (const t of tiers) {
801
+ if (mult >= t.minMultiplier && (!chosen || t.minMultiplier >= chosen.minMultiplier))
802
+ chosen = t;
803
+ }
804
+ return chosen;
805
+ }
806
+ /** Index into `tiers` for the running value (or -1 below the lowest tier). */
807
+ function tierIndexAtValue(tiers, runningValue, bet) {
808
+ if (bet <= 0)
809
+ return -1;
810
+ const mult = runningValue / bet;
811
+ let idx = -1;
812
+ for (let i = 0; i < tiers.length; i++)
813
+ if (mult >= tiers[i].minMultiplier)
814
+ idx = i;
815
+ return idx;
816
+ }
817
+
818
+ /** PURE eased interpolation 0→target over duration (ms). Clamped to [0, target]. */
819
+ function valueAt(elapsed, target, duration) {
820
+ if (duration <= 0 || elapsed >= duration)
821
+ return target;
822
+ if (elapsed <= 0)
823
+ return 0;
824
+ const p = elapsed / duration;
825
+ const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
826
+ return target * eased;
827
+ }
828
+ class CountUpDisplay extends Container {
829
+ __uiComponent = true;
830
+ _text;
831
+ _format;
832
+ _value = 0;
833
+ constructor(config) {
834
+ super();
835
+ this._format = config.format;
836
+ this._text = new Text({
837
+ text: this._format(0),
838
+ style: { fontFamily: 'sans-serif', fontSize: 48, fill: 0xffffff, fontWeight: '800', ...config.style },
839
+ });
840
+ this._text.anchor.set(0.5);
841
+ this.addChild(this._text);
842
+ }
843
+ get text() { return this._text.text; }
844
+ setValue(v) {
845
+ this._value = v;
846
+ this._text.text = this._format(v);
847
+ }
848
+ /** Swap the value formatter and immediately re-render the current value. */
849
+ setFormat(format) {
850
+ this._format = format;
851
+ this._text.text = this._format(this._value);
852
+ }
853
+ /** Animate the value to target over duration; fires onTier on each tier-index increase. */
854
+ async countTo(target, duration, onTier) {
855
+ const holder = { v: 0 };
856
+ await Tween.to(holder, { v: target }, duration, Easing.easeOutCubic, () => {
857
+ this.setValue(holder.v);
858
+ });
859
+ this.setValue(target);
860
+ }
861
+ skip() { Tween.killTweensOf(this); }
862
+ }
863
+
864
+ const DEFAULT_DURATION = (win) => Math.min(2500, Math.max(800, win * 20));
865
+ class BigWinOverlay extends Container {
866
+ __uiComponent = true;
867
+ _cfg;
868
+ _dim;
869
+ _banner = null;
870
+ _title;
871
+ _count;
872
+ constructor(config) {
873
+ super();
874
+ this._cfg = config;
875
+ this.visible = false;
876
+ this._dim = new Graphics();
877
+ this.addChild(this._dim);
878
+ this._title = new Text({ text: '', style: { fontFamily: 'sans-serif', fontSize: 72, fill: 0xffffff, fontWeight: '900' } });
879
+ this._title.anchor.set(0.5);
880
+ this.addChild(this._title);
881
+ this._count = new CountUpDisplay({ format: config.formatMoney });
882
+ this.addChild(this._count);
883
+ this.resize(config.width, config.height);
884
+ }
885
+ /** Pure helper (testable): the tier title for a given win/bet, or null below the lowest tier. */
886
+ tierTitleFor(win, bet) {
887
+ return pickTier(this._cfg.tiers, win, bet)?.title ?? null;
888
+ }
889
+ resize(width, height) {
890
+ this._cfg.width = width;
891
+ this._cfg.height = height;
892
+ this._dim.clear();
893
+ this._dim.rect(0, 0, width, height).fill({ color: 0x000000, alpha: 0.72 });
894
+ this._title.position.set(width / 2, height * 0.4);
895
+ this._count.position.set(width / 2, height * 0.56);
896
+ }
897
+ async show(win, bet, format) {
898
+ const tier = pickTier(this._cfg.tiers, win, bet);
899
+ if (!tier)
900
+ return;
901
+ if (format)
902
+ this._count.setFormat(format);
903
+ this.visible = true;
904
+ this.alpha = 0;
905
+ this._title.text = tier.title;
906
+ this._title.style.fill = tier.accentColor;
907
+ if (this._banner) {
908
+ this._banner.destroy();
909
+ this._banner = null;
910
+ }
911
+ if (tier.bannerTexture) {
912
+ this._banner = new Sprite(tier.bannerTexture);
913
+ this._banner.anchor.set(0.5);
914
+ this._banner.position.set(this._cfg.width / 2, this._cfg.height * 0.4);
915
+ this.addChildAt(this._banner, 1);
916
+ }
917
+ await Tween.to(this, { alpha: 1 }, 200, Easing.easeOutQuad);
918
+ const dur = (this._cfg.countUpDuration ?? DEFAULT_DURATION)(win);
919
+ let lastIdx = tierIndexAtValue(this._cfg.tiers, 0, bet);
920
+ await this._count.countTo(win, dur, undefined);
921
+ // tier-promotion title updates as the value climbs (sampled at the end for simplicity)
922
+ const finalIdx = tierIndexAtValue(this._cfg.tiers, win, bet);
923
+ if (finalIdx > lastIdx && this._cfg.tiers[finalIdx]) {
924
+ this._title.text = this._cfg.tiers[finalIdx].title;
925
+ }
926
+ }
927
+ skip() { Tween.killTweensOf(this); this._count.skip(); }
928
+ hide() { this.visible = false; Tween.killTweensOf(this); }
929
+ }
930
+
931
+ /** Headless free-spins state machine. The scene drives it; rendering/HUD reflect it. */
932
+ class FreeSpinsSession {
933
+ remaining;
934
+ total;
935
+ totalWin = 0;
936
+ cfg;
937
+ constructor(cfg) {
938
+ this.cfg = cfg;
939
+ this.remaining = cfg.initialSpins;
940
+ this.total = cfg.initialSpins;
941
+ }
942
+ award(extra) {
943
+ if (extra > 0) {
944
+ this.remaining += extra;
945
+ this.total += extra;
946
+ }
947
+ }
948
+ /** Convenience: award using the configured retrigger rule. */
949
+ applyRetrigger(result) {
950
+ this.award(this.cfg.retrigger?.(result) ?? 0);
951
+ }
952
+ addWin(amount) { this.totalWin += amount; }
953
+ consume() { if (this.remaining > 0)
954
+ this.remaining -= 1; }
955
+ get isComplete() {
956
+ return this.remaining <= 0 || (this.cfg.isMaxWin?.() ?? false);
957
+ }
958
+ }
959
+
960
+ // How long each policy survives: cascade (shortest) < spin < session (longest).
961
+ const RANK = { cascade: 0, spin: 1, session: 2 };
962
+ /**
963
+ * Headless sticky/collector multiplier — the unified abstraction behind
964
+ * kitsunebi / recipe / orb / stage multipliers. reset(boundary) clears the
965
+ * value only when the boundary is at or above the configured policy scope.
966
+ */
967
+ class MultiplierAccumulator {
968
+ value;
969
+ base;
970
+ policy;
971
+ constructor(cfg) {
972
+ this.policy = cfg.policy;
973
+ this.base = cfg.base ?? 1;
974
+ this.value = this.base;
975
+ }
976
+ add(delta) { this.value += delta; }
977
+ set(value) { this.value = value; }
978
+ reset(boundary) {
979
+ if (RANK[boundary] >= RANK[this.policy])
980
+ this.value = this.base;
981
+ }
982
+ }
983
+
984
+ export { AnimatedSymbol, BigWinOverlay, CascadeController, CountUpDisplay, FreeSpinsSession, MultiplierAccumulator, ReelGrid, ReelSpinController, SymbolCell, pickTier, tierIndexAtValue, valueAt };
985
+ //# sourceMappingURL=slot.esm.js.map