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