@energy8platform/game-engine 0.20.0 → 0.22.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 (93) hide show
  1. package/README.md +121 -0
  2. package/dist/animation.cjs.js +18 -3
  3. package/dist/animation.cjs.js.map +1 -1
  4. package/dist/animation.esm.js +18 -3
  5. package/dist/animation.esm.js.map +1 -1
  6. package/dist/core.cjs.js +18 -3
  7. package/dist/core.cjs.js.map +1 -1
  8. package/dist/core.esm.js +18 -3
  9. package/dist/core.esm.js.map +1 -1
  10. package/dist/host.cjs.js +18 -3
  11. package/dist/host.cjs.js.map +1 -1
  12. package/dist/host.d.ts +3 -5
  13. package/dist/host.esm.js +18 -3
  14. package/dist/host.esm.js.map +1 -1
  15. package/dist/index.cjs.js +18 -2535
  16. package/dist/index.cjs.js.map +1 -1
  17. package/dist/index.d.ts +3 -1020
  18. package/dist/index.esm.js +20 -2525
  19. package/dist/index.esm.js.map +1 -1
  20. package/dist/slot.cjs.js +1872 -61
  21. package/dist/slot.cjs.js.map +1 -1
  22. package/dist/slot.d.ts +555 -28
  23. package/dist/slot.esm.js +1857 -62
  24. package/dist/slot.esm.js.map +1 -1
  25. package/dist/vite.cjs.js +0 -5
  26. package/dist/vite.cjs.js.map +1 -1
  27. package/dist/vite.esm.js +0 -5
  28. package/dist/vite.esm.js.map +1 -1
  29. package/package.json +2 -35
  30. package/src/animation/Tween.ts +14 -3
  31. package/src/host/index.ts +0 -1
  32. package/src/host/types.ts +0 -3
  33. package/src/index.ts +0 -28
  34. package/src/slot/anim/easing-map.ts +16 -2
  35. package/src/slot/cascade/TumbleController.ts +230 -0
  36. package/src/slot/config/ReelSystemConfig.ts +559 -0
  37. package/src/slot/config/presets.ts +222 -0
  38. package/src/slot/features/extra.ts +189 -0
  39. package/src/slot/features/index.ts +44 -0
  40. package/src/slot/features/symbols.ts +183 -0
  41. package/src/slot/features/types.ts +136 -0
  42. package/src/slot/features/wilds.ts +151 -0
  43. package/src/slot/grid/ReelGrid.ts +93 -24
  44. package/src/slot/grid/SymbolCell.ts +45 -11
  45. package/src/slot/index.ts +61 -2
  46. package/src/slot/motion/AnticipationController.ts +96 -0
  47. package/src/slot/motion/SpinEngine.ts +384 -0
  48. package/src/slot/system/ReelSystem.ts +295 -0
  49. package/src/vite/index.ts +0 -5
  50. package/dist/react-jsx.cjs.js +0 -3
  51. package/dist/react-jsx.cjs.js.map +0 -1
  52. package/dist/react-jsx.d.ts +0 -602
  53. package/dist/react-jsx.esm.js +0 -2
  54. package/dist/react-jsx.esm.js.map +0 -1
  55. package/dist/react.cjs.js +0 -3787
  56. package/dist/react.cjs.js.map +0 -1
  57. package/dist/react.d.ts +0 -1467
  58. package/dist/react.esm.js +0 -3771
  59. package/dist/react.esm.js.map +0 -1
  60. package/dist/ui.cjs.js +0 -2999
  61. package/dist/ui.cjs.js.map +0 -1
  62. package/dist/ui.d.ts +0 -1116
  63. package/dist/ui.esm.js +0 -2983
  64. package/dist/ui.esm.js.map +0 -1
  65. package/src/react/EngineContext.ts +0 -26
  66. package/src/react/ReactScene.ts +0 -88
  67. package/src/react/applyProps.ts +0 -271
  68. package/src/react/catalogue.ts +0 -17
  69. package/src/react/createPixiRoot.ts +0 -31
  70. package/src/react/extendAll.ts +0 -74
  71. package/src/react/hooks.ts +0 -46
  72. package/src/react/index.ts +0 -29
  73. package/src/react/jsx-runtime.ts +0 -346
  74. package/src/react/reconciler.ts +0 -338
  75. package/src/slot/freeSpins/FreeSpinsSession.ts +0 -40
  76. package/src/state/StateMachine.ts +0 -231
  77. package/src/state/index.ts +0 -1
  78. package/src/ui/BalanceDisplay.ts +0 -159
  79. package/src/ui/Button.ts +0 -304
  80. package/src/ui/FlexContainer.ts +0 -775
  81. package/src/ui/Label.ts +0 -124
  82. package/src/ui/LabelValue.ts +0 -122
  83. package/src/ui/Layout.ts +0 -291
  84. package/src/ui/Modal.ts +0 -170
  85. package/src/ui/Panel.ts +0 -204
  86. package/src/ui/ProgressBar.ts +0 -170
  87. package/src/ui/ScrollContainer.ts +0 -478
  88. package/src/ui/Slider.ts +0 -241
  89. package/src/ui/Toast.ts +0 -150
  90. package/src/ui/Toggle.ts +0 -201
  91. package/src/ui/WinDisplay.ts +0 -145
  92. package/src/ui/index.ts +0 -33
  93. package/src/ui/view.ts +0 -28
package/dist/index.cjs.js CHANGED
@@ -189,6 +189,10 @@ class Tween {
189
189
  * @param onUpdate - Progress callback (0..1)
190
190
  */
191
191
  static to(target, props, duration, easing, onUpdate) {
192
+ // A destroyed (Pixi) target has null transform fields — skip rather than throw. This guards
193
+ // animations whose target is torn down mid-flight (e.g. a reel grid rebuilt during a spin).
194
+ if (target == null || target.destroyed)
195
+ return Promise.resolve();
192
196
  return new Promise((resolve) => {
193
197
  // Capture starting values
194
198
  const from = {};
@@ -214,6 +218,8 @@ class Tween {
214
218
  * Animate properties from given values to current values.
215
219
  */
216
220
  static from(target, props, duration, easing, onUpdate) {
221
+ if (target == null || target.destroyed)
222
+ return Promise.resolve();
217
223
  // Capture current values as "to"
218
224
  const to = {};
219
225
  for (const key of Object.keys(props)) {
@@ -226,6 +232,8 @@ class Tween {
226
232
  * Animate from one set of values to another.
227
233
  */
228
234
  static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
235
+ if (target == null || target.destroyed)
236
+ return Promise.resolve();
229
237
  // Set starting values
230
238
  for (const key of Object.keys(fromProps)) {
231
239
  Tween.setProperty(target, key, fromProps[key]);
@@ -299,6 +307,11 @@ class Tween {
299
307
  const dt = ticker.deltaMS;
300
308
  const completed = [];
301
309
  for (const tw of Tween._tweens) {
310
+ // target torn down mid-tween → finish it quietly
311
+ if (tw.target?.destroyed) {
312
+ completed.push(tw);
313
+ continue;
314
+ }
302
315
  tw.elapsed += dt;
303
316
  if (tw.elapsed < tw.delay)
304
317
  continue;
@@ -336,9 +349,9 @@ class Tween {
336
349
  const parts = key.split('.');
337
350
  let obj = target;
338
351
  for (let i = 0; i < parts.length - 1; i++) {
339
- obj = obj[parts[i]];
352
+ obj = obj?.[parts[i]];
340
353
  }
341
- return obj[parts[parts.length - 1]] ?? 0;
354
+ return obj?.[parts[parts.length - 1]] ?? 0;
342
355
  }
343
356
  /**
344
357
  * Set a potentially nested property.
@@ -347,8 +360,10 @@ class Tween {
347
360
  const parts = key.split('.');
348
361
  let obj = target;
349
362
  for (let i = 0; i < parts.length - 1; i++) {
350
- obj = obj[parts[i]];
363
+ obj = obj?.[parts[i]];
351
364
  }
365
+ if (obj == null)
366
+ return;
352
367
  obj[parts[parts.length - 1]] = value;
353
368
  }
354
369
  }
@@ -2037,204 +2052,6 @@ class GameApplication extends EventEmitter {
2037
2052
  }
2038
2053
  }
2039
2054
 
2040
- /**
2041
- * Generic finite state machine for game flow management.
2042
- *
2043
- * Supports:
2044
- * - Typed context object shared across all states
2045
- * - Async enter/exit hooks
2046
- * - Per-frame update per state
2047
- * - Transition guards
2048
- * - Event emission on state change
2049
- *
2050
- * @example
2051
- * ```ts
2052
- * interface GameContext {
2053
- * balance: number;
2054
- * bet: number;
2055
- * lastWin: number;
2056
- * }
2057
- *
2058
- * const fsm = new StateMachine<GameContext>({ balance: 1000, bet: 10, lastWin: 0 });
2059
- *
2060
- * fsm.addState('idle', {
2061
- * enter: (ctx) => console.log('Waiting for spin...'),
2062
- * update: (ctx, dt) => { // optional per-frame },
2063
- * });
2064
- *
2065
- * fsm.addState('spinning', {
2066
- * enter: async (ctx) => {
2067
- * const result = await sdk.play({ action: 'spin', bet: ctx.bet });
2068
- * ctx.lastWin = result.totalWin;
2069
- * await fsm.transition('presenting');
2070
- * },
2071
- * });
2072
- *
2073
- * fsm.addState('presenting', {
2074
- * enter: async (ctx) => {
2075
- * await showWinPresentation(ctx.lastWin);
2076
- * await fsm.transition('idle');
2077
- * },
2078
- * });
2079
- *
2080
- * // Optional guard
2081
- * fsm.addGuard('idle', 'spinning', (ctx) => ctx.balance >= ctx.bet);
2082
- *
2083
- * await fsm.start('idle');
2084
- * ```
2085
- */
2086
- class StateMachine extends EventEmitter {
2087
- static MAX_TRANSITION_DEPTH = 10;
2088
- _states = new Map();
2089
- _guards = new Map();
2090
- _current = null;
2091
- _transitionDepth = 0;
2092
- _context;
2093
- constructor(context) {
2094
- super();
2095
- this._context = context;
2096
- }
2097
- /** Current state name */
2098
- get current() {
2099
- return this._current;
2100
- }
2101
- /** Whether a transition is in progress */
2102
- get isTransitioning() {
2103
- return this._transitionDepth > 0;
2104
- }
2105
- /** State machine context (shared data) */
2106
- get context() {
2107
- return this._context;
2108
- }
2109
- /**
2110
- * Register a state with optional enter/exit/update hooks.
2111
- */
2112
- addState(name, config) {
2113
- this._states.set(name, config);
2114
- return this;
2115
- }
2116
- /**
2117
- * Add a transition guard.
2118
- * The guard function must return true to allow the transition.
2119
- *
2120
- * @param from - Source state
2121
- * @param to - Target state
2122
- * @param guard - Guard function
2123
- */
2124
- addGuard(from, to, guard) {
2125
- this._guards.set(`${from}->${to}`, guard);
2126
- return this;
2127
- }
2128
- /**
2129
- * Start the state machine in the given initial state.
2130
- */
2131
- async start(initialState, data) {
2132
- if (this._current !== null) {
2133
- throw new Error('[StateMachine] Already started. Use transition() to change states.');
2134
- }
2135
- const state = this._states.get(initialState);
2136
- if (!state) {
2137
- throw new Error(`[StateMachine] State "${initialState}" not registered.`);
2138
- }
2139
- this._current = initialState;
2140
- await state.enter?.(this._context, data);
2141
- this.emit('transition', { from: null, to: initialState });
2142
- }
2143
- /**
2144
- * Transition to a new state.
2145
- *
2146
- * @param to - Target state name
2147
- * @param data - Optional data passed to the new state's enter hook
2148
- * @returns true if the transition succeeded, false if blocked by a guard
2149
- */
2150
- async transition(to, data) {
2151
- if (this._transitionDepth >= StateMachine.MAX_TRANSITION_DEPTH) {
2152
- throw new Error('[StateMachine] Max transition depth exceeded — possible infinite loop');
2153
- }
2154
- const from = this._current;
2155
- // Check guard
2156
- if (from !== null) {
2157
- const guardKey = `${from}->${to}`;
2158
- const guard = this._guards.get(guardKey);
2159
- if (guard && !guard(this._context)) {
2160
- return false;
2161
- }
2162
- }
2163
- const toState = this._states.get(to);
2164
- if (!toState) {
2165
- throw new Error(`[StateMachine] State "${to}" not registered.`);
2166
- }
2167
- this._transitionDepth++;
2168
- try {
2169
- // Exit current state
2170
- if (from !== null) {
2171
- const fromState = this._states.get(from);
2172
- await fromState?.exit?.(this._context);
2173
- }
2174
- // Enter new state
2175
- this._current = to;
2176
- await toState.enter?.(this._context, data);
2177
- this.emit('transition', { from, to });
2178
- }
2179
- catch (err) {
2180
- this.emit('error', err instanceof Error ? err : new Error(String(err)));
2181
- throw err;
2182
- }
2183
- finally {
2184
- this._transitionDepth--;
2185
- }
2186
- return true;
2187
- }
2188
- /**
2189
- * Call the current state's update function.
2190
- * Should be called from the game loop.
2191
- */
2192
- update(dt) {
2193
- if (this._current === null)
2194
- return;
2195
- const state = this._states.get(this._current);
2196
- state?.update?.(this._context, dt);
2197
- }
2198
- /**
2199
- * Check if a state is registered.
2200
- */
2201
- hasState(name) {
2202
- return this._states.has(name);
2203
- }
2204
- /**
2205
- * Check if a transition is allowed (guard passes).
2206
- */
2207
- canTransition(to) {
2208
- if (this._current === null)
2209
- return false;
2210
- const guardKey = `${this._current}->${to}`;
2211
- const guard = this._guards.get(guardKey);
2212
- if (!guard)
2213
- return true;
2214
- return guard(this._context);
2215
- }
2216
- /**
2217
- * Reset the state machine (exit current state, clear current).
2218
- */
2219
- async reset() {
2220
- if (this._current !== null) {
2221
- const state = this._states.get(this._current);
2222
- await state?.exit?.(this._context);
2223
- }
2224
- this._current = null;
2225
- this._transitionDepth = 0;
2226
- }
2227
- /**
2228
- * Destroy the state machine.
2229
- */
2230
- async destroy() {
2231
- await this.reset();
2232
- this._states.clear();
2233
- this._guards.clear();
2234
- this.removeAllListeners();
2235
- }
2236
- }
2237
-
2238
2055
  /**
2239
2056
  * Sequential/parallel animation timeline built on top of Tween.
2240
2057
  *
@@ -2647,2328 +2464,6 @@ class SpriteAnimation {
2647
2464
  }
2648
2465
  }
2649
2466
 
2650
- // ─── Helpers ─────────────────────────────────────────────
2651
- function normalizePadding(p) {
2652
- return typeof p === 'number' ? [p, p, p, p] : p;
2653
- }
2654
- /** Resolve padding from config: individual props override the base `padding` value */
2655
- function resolvePadding(config) {
2656
- const base = normalizePadding(config.padding ?? 0);
2657
- return [
2658
- config.paddingTop ?? base[0],
2659
- config.paddingRight ?? base[1],
2660
- config.paddingBottom ?? base[2],
2661
- config.paddingLeft ?? base[3],
2662
- ];
2663
- }
2664
- /** Resolve a dimension value — number passes through, "50%" resolves against reference */
2665
- function resolveDimension(value, reference) {
2666
- if (value === undefined)
2667
- return undefined;
2668
- if (typeof value === 'number')
2669
- return value;
2670
- if (typeof value === 'string' && value.endsWith('%')) {
2671
- const pct = parseFloat(value);
2672
- if (!isNaN(pct) && reference > 0 && isFinite(reference))
2673
- return (pct / 100) * reference;
2674
- }
2675
- return undefined;
2676
- }
2677
- /** Measure a child's size and bounds offset for layout purposes */
2678
- function measureChild(child, parentContentW = 0, parentContentH = 0) {
2679
- const cfg = child._flexConfig;
2680
- const resolvedLW = resolveDimension(cfg?.layoutWidth, parentContentW);
2681
- const resolvedLH = resolveDimension(cfg?.layoutHeight, parentContentH);
2682
- // FlexContainer children are top-left origin by construction, so `ox/oy = 0`
2683
- // is correct and we can skip the `getLocalBounds()` call entirely.
2684
- if (child instanceof FlexContainer) {
2685
- const fc = child;
2686
- const w = fc._explicitWidth > 0 ? fc._explicitWidth : (fc._computedWidth > 0 ? fc._computedWidth : undefined);
2687
- const h = fc._explicitHeight > 0 ? fc._explicitHeight : (fc._computedHeight > 0 ? fc._computedHeight : undefined);
2688
- if (w !== undefined && h !== undefined) {
2689
- return { w: resolvedLW ?? w, h: resolvedLH ?? h, ox: 0, oy: 0 };
2690
- }
2691
- }
2692
- // Use localBounds to get the true visual origin offset.
2693
- // We take `ox/oy` from bounds unconditionally — a user-supplied `layoutWidth/Height`
2694
- // overrides the SIZE used by flex (protection against pre-paint bounds of 0), but
2695
- // it must NOT erase the center-anchor compensation: Button/Label views are drawn at
2696
- // `(-w/2..w/2)` and their bounds.x = -w/2 is what lets `layoutLine` place the
2697
- // visible rectangle where the layout intends.
2698
- const bounds = child.getLocalBounds();
2699
- return {
2700
- w: resolvedLW ?? bounds.width,
2701
- h: resolvedLH ?? bounds.height,
2702
- ox: bounds.x,
2703
- oy: bounds.y,
2704
- };
2705
- }
2706
- /**
2707
- * Set a child's main or cross dimension.
2708
- * For FlexContainer children, calls resize() to trigger internal relayout
2709
- * instead of the PixiJS scale setter.
2710
- */
2711
- function setChildMainSize(child, isRow, mainSize, item) {
2712
- if (child instanceof FlexContainer) {
2713
- const fc = child;
2714
- fc.resize(isRow ? mainSize : fc._explicitWidth || fc._computedWidth, isRow ? fc._explicitHeight || fc._computedHeight : mainSize);
2715
- }
2716
- else {
2717
- if (isRow) {
2718
- child.width = mainSize;
2719
- }
2720
- else {
2721
- child.height = mainSize;
2722
- }
2723
- }
2724
- if (isRow)
2725
- item.w = mainSize;
2726
- else
2727
- item.h = mainSize;
2728
- }
2729
- function setChildCrossSize(child, isRow, crossSize) {
2730
- if (child instanceof FlexContainer) {
2731
- const fc = child;
2732
- fc.resize(isRow ? fc._explicitWidth || fc._computedWidth : crossSize, isRow ? crossSize : fc._explicitHeight || fc._computedHeight);
2733
- }
2734
- else {
2735
- if (isRow) {
2736
- child.height = crossSize;
2737
- }
2738
- else {
2739
- child.width = crossSize;
2740
- }
2741
- }
2742
- }
2743
- function layoutLine(items, isRow, mainSize, justify, align, gap, crossOffset, crossSize) {
2744
- if (items.length === 0)
2745
- return;
2746
- // Compute total fixed main size and flex grow total
2747
- let totalFixed = 0;
2748
- let totalGrow = 0;
2749
- for (const item of items) {
2750
- const grow = item.child._flexConfig?.flexGrow ?? 0;
2751
- if (grow > 0) {
2752
- totalGrow += grow;
2753
- }
2754
- else {
2755
- totalFixed += isRow ? item.w : item.h;
2756
- }
2757
- }
2758
- const totalGap = gap * (items.length - 1);
2759
- const availableForFlex = Math.max(0, mainSize - totalFixed - totalGap);
2760
- // Resolve flex sizes
2761
- if (totalGrow > 0) {
2762
- for (const item of items) {
2763
- const grow = item.child._flexConfig?.flexGrow ?? 0;
2764
- if (grow > 0) {
2765
- const flexSize = (grow / totalGrow) * availableForFlex;
2766
- setChildMainSize(item.child, isRow, flexSize, item);
2767
- }
2768
- }
2769
- }
2770
- // Shrink: if content overflows and mainSize is finite, shrink eligible items
2771
- if (totalGrow === 0 && mainSize > 0) {
2772
- const overflow = totalFixed + totalGap - mainSize;
2773
- if (overflow > 0) {
2774
- let totalShrinkable = 0;
2775
- for (const item of items) {
2776
- const shrink = item.child._flexConfig?.flexShrink ?? 1;
2777
- if (shrink > 0) {
2778
- totalShrinkable += isRow ? item.w : item.h;
2779
- }
2780
- }
2781
- if (totalShrinkable > 0) {
2782
- for (const item of items) {
2783
- const shrink = item.child._flexConfig?.flexShrink ?? 1;
2784
- if (shrink > 0) {
2785
- const itemMain = isRow ? item.w : item.h;
2786
- const reduction = overflow * (itemMain / totalShrinkable);
2787
- const newSize = Math.max(0, itemMain - reduction);
2788
- setChildMainSize(item.child, isRow, newSize, item);
2789
- }
2790
- }
2791
- }
2792
- }
2793
- }
2794
- // Calculate total main size after flex
2795
- let totalMain = totalGap;
2796
- for (const item of items) {
2797
- totalMain += isRow ? item.w : item.h;
2798
- }
2799
- // Justify: compute starting offset and extra spacing
2800
- let mainOffset = 0;
2801
- let extraGap = 0;
2802
- switch (justify) {
2803
- case 'start':
2804
- break;
2805
- case 'center':
2806
- mainOffset = Math.max(0, (mainSize - totalMain) / 2);
2807
- break;
2808
- case 'end':
2809
- mainOffset = Math.max(0, mainSize - totalMain);
2810
- break;
2811
- case 'space-between':
2812
- if (items.length > 1) {
2813
- extraGap = Math.max(0, (mainSize - totalMain + totalGap) / (items.length - 1)) - gap;
2814
- }
2815
- break;
2816
- case 'space-around':
2817
- if (items.length > 0) {
2818
- const totalSpace = Math.max(0, mainSize - totalMain + totalGap);
2819
- const segment = totalSpace / items.length;
2820
- mainOffset = segment / 2;
2821
- extraGap = segment - gap;
2822
- }
2823
- break;
2824
- }
2825
- // Position each item
2826
- let pos = mainOffset;
2827
- for (const item of items) {
2828
- const mainDim = isRow ? item.w : item.h;
2829
- const crossDim = isRow ? item.h : item.w;
2830
- // Cross-axis alignment (alignSelf overrides align)
2831
- const effectiveAlign = (item.child._flexConfig?.alignSelf && item.child._flexConfig.alignSelf !== 'auto')
2832
- ? item.child._flexConfig.alignSelf
2833
- : align;
2834
- let crossPos = crossOffset;
2835
- switch (effectiveAlign) {
2836
- case 'start':
2837
- break;
2838
- case 'center':
2839
- crossPos += (crossSize - crossDim) / 2;
2840
- break;
2841
- case 'end':
2842
- crossPos += crossSize - crossDim;
2843
- break;
2844
- case 'stretch':
2845
- setChildCrossSize(item.child, isRow, crossSize);
2846
- break;
2847
- }
2848
- // Compensate for local bounds offset (e.g. centered anchors)
2849
- if (isRow) {
2850
- item.child.x = pos - item.ox;
2851
- item.child.y = crossPos - item.oy;
2852
- }
2853
- else {
2854
- item.child.x = crossPos - item.ox;
2855
- item.child.y = pos - item.oy;
2856
- }
2857
- pos += mainDim + gap + extraGap;
2858
- }
2859
- }
2860
- // ─── FlexContainer ───────────────────────────────────────
2861
- /**
2862
- * Lightweight flexbox-like layout container for PixiJS.
2863
- *
2864
- * Supports row/column direction, justify/align, gap, padding, wrapping,
2865
- * and flex-grow distribution. Zero external dependencies.
2866
- *
2867
- * @example
2868
- * ```ts
2869
- * const toolbar = new FlexContainer({
2870
- * direction: 'row',
2871
- * justifyContent: 'space-between',
2872
- * alignItems: 'center',
2873
- * gap: 16,
2874
- * padding: 12,
2875
- * });
2876
- *
2877
- * toolbar.addFlexChild(button1);
2878
- * toolbar.addFlexChild(button2);
2879
- * toolbar.resize(800, 60);
2880
- * ```
2881
- */
2882
- class FlexContainer extends pixi_js.Container {
2883
- __uiComponent = true;
2884
- _config;
2885
- _padding;
2886
- _maxWidth;
2887
- _maxHeight;
2888
- /** @internal */ _explicitWidth;
2889
- /** @internal */ _explicitHeight;
2890
- /** @internal */ _computedWidth = 0;
2891
- /** @internal */ _computedHeight = 0;
2892
- /** @internal */ _availableWidth = 0;
2893
- /** @internal */ _availableHeight = 0;
2894
- /** @internal */ _rawWidth;
2895
- /** @internal */ _rawHeight;
2896
- _layoutChildren = [];
2897
- _layoutDirty = true;
2898
- _layoutSuspended = false;
2899
- constructor(config = {}) {
2900
- super();
2901
- this._config = {
2902
- direction: config.direction ?? 'row',
2903
- justifyContent: config.justifyContent ?? 'start',
2904
- alignItems: config.alignItems ?? 'start',
2905
- gap: config.gap ?? 0,
2906
- flexWrap: config.flexWrap ?? false,
2907
- alignContent: config.alignContent ?? 'start',
2908
- };
2909
- this._padding = resolvePadding(config);
2910
- this._maxWidth = config.maxWidth ?? Infinity;
2911
- this._maxHeight = config.maxHeight ?? Infinity;
2912
- this._rawWidth = config.width ?? 0;
2913
- this._rawHeight = config.height ?? 0;
2914
- this._explicitWidth = typeof this._rawWidth === 'number' ? this._rawWidth : 0;
2915
- this._explicitHeight = typeof this._rawHeight === 'number' ? this._rawHeight : 0;
2916
- }
2917
- // ─── Public API ──────────────────────────────────────
2918
- /** Add a child with optional flex config. Also registers in flex layout. */
2919
- addFlexChild(child, flexConfig) {
2920
- if (flexConfig)
2921
- child._flexConfig = flexConfig;
2922
- if (!this._layoutChildren.includes(child)) {
2923
- this._layoutChildren.push(child);
2924
- this._layoutDirty = true;
2925
- }
2926
- super.addChild(child);
2927
- return this;
2928
- }
2929
- /** Remove a child from flex layout and display list */
2930
- removeFlexChild(child) {
2931
- const idx = this._layoutChildren.indexOf(child);
2932
- if (idx !== -1) {
2933
- this._layoutChildren.splice(idx, 1);
2934
- this._layoutDirty = true;
2935
- }
2936
- super.removeChild(child);
2937
- return this;
2938
- }
2939
- /** Remove all flex children */
2940
- clearFlexChildren() {
2941
- for (const child of this._layoutChildren) {
2942
- super.removeChild(child);
2943
- }
2944
- this._layoutChildren.length = 0;
2945
- this._layoutDirty = true;
2946
- return this;
2947
- }
2948
- /**
2949
- * Override addChild so children automatically participate in flex layout.
2950
- * This enables declarative usage from React JSX.
2951
- */
2952
- addChild(...children) {
2953
- for (const child of children) {
2954
- if (!this._layoutChildren.includes(child)) {
2955
- this._layoutChildren.push(child);
2956
- this._layoutDirty = true;
2957
- }
2958
- }
2959
- const result = super.addChild(...children);
2960
- if (this._layoutDirty && !this._layoutSuspended)
2961
- this.updateLayout();
2962
- return result;
2963
- }
2964
- addChildAt(child, index) {
2965
- if (!this._layoutChildren.includes(child)) {
2966
- // Insert into layout children at matching position
2967
- const layoutIndex = Math.min(index, this._layoutChildren.length);
2968
- this._layoutChildren.splice(layoutIndex, 0, child);
2969
- this._layoutDirty = true;
2970
- }
2971
- const result = super.addChildAt(child, index);
2972
- if (this._layoutDirty && !this._layoutSuspended)
2973
- this.updateLayout();
2974
- return result;
2975
- }
2976
- removeChild(...children) {
2977
- for (const child of children) {
2978
- const idx = this._layoutChildren.indexOf(child);
2979
- if (idx !== -1) {
2980
- this._layoutChildren.splice(idx, 1);
2981
- this._layoutDirty = true;
2982
- }
2983
- }
2984
- return super.removeChild(...children);
2985
- }
2986
- /** Get all flex layout children (read-only) */
2987
- get flexChildren() {
2988
- return this._layoutChildren;
2989
- }
2990
- /** Suspend automatic layout recalculation. Call resumeLayout() to flush. */
2991
- suspendLayout() {
2992
- this._layoutSuspended = true;
2993
- }
2994
- /** Resume automatic layout and flush if dirty. */
2995
- resumeLayout() {
2996
- this._layoutSuspended = false;
2997
- if (this._layoutDirty)
2998
- this.updateLayout();
2999
- }
3000
- /** Update the container size and recalculate layout */
3001
- resize(width, height) {
3002
- this._explicitWidth = width;
3003
- this._explicitHeight = height;
3004
- this._layoutDirty = true;
3005
- if (!this._layoutSuspended)
3006
- this.updateLayout();
3007
- }
3008
- /** Update layout direction */
3009
- setDirection(direction) {
3010
- this._config.direction = direction;
3011
- this._layoutDirty = true;
3012
- }
3013
- /** Update justifyContent */
3014
- setJustifyContent(justify) {
3015
- this._config.justifyContent = justify;
3016
- this._layoutDirty = true;
3017
- }
3018
- /** Update alignItems */
3019
- setAlignItems(align) {
3020
- this._config.alignItems = align;
3021
- this._layoutDirty = true;
3022
- }
3023
- /** Update gap */
3024
- setGap(gap) {
3025
- this._config.gap = gap;
3026
- this._layoutDirty = true;
3027
- }
3028
- /** Update padding */
3029
- setPadding(padding) {
3030
- this._padding = normalizePadding(padding);
3031
- this._layoutDirty = true;
3032
- }
3033
- /**
3034
- * Recalculate and apply layout positions for all children.
3035
- * Called automatically by `resize()`. Call manually after
3036
- * adding/removing children without resize.
3037
- */
3038
- updateLayout() {
3039
- if (this._layoutSuspended) {
3040
- this._layoutDirty = true;
3041
- return;
3042
- }
3043
- this._layoutDirty = false;
3044
- const { direction, justifyContent, alignItems, gap, flexWrap, alignContent } = this._config;
3045
- const [pt, pr, pb, pl] = this._padding;
3046
- const isRow = direction === 'row';
3047
- // Resolve percentage width/height against parent's available space
3048
- if (typeof this._rawWidth === 'string') {
3049
- this._explicitWidth = resolveDimension(this._rawWidth, this._availableWidth) ?? 0;
3050
- }
3051
- if (typeof this._rawHeight === 'string') {
3052
- this._explicitHeight = resolveDimension(this._rawHeight, this._availableHeight) ?? 0;
3053
- }
3054
- const contentW = this._explicitWidth > 0 ? this._explicitWidth - pl - pr : Infinity;
3055
- const contentH = this._explicitHeight > 0 ? this._explicitHeight - pt - pb : Infinity;
3056
- const mainLimit = isRow ? contentW : contentH;
3057
- const crossLimit = isRow ? contentH : contentW;
3058
- // Pass content area to measureChild for percentage resolution
3059
- const pctRefW = contentW < Infinity ? contentW : 0;
3060
- const pctRefH = contentH < Infinity ? contentH : 0;
3061
- // Propagate available size to child FlexContainers and resolve their percentages
3062
- for (const child of this._layoutChildren) {
3063
- if (child instanceof FlexContainer) {
3064
- const fc = child;
3065
- fc._availableWidth = pctRefW;
3066
- fc._availableHeight = pctRefH;
3067
- // If child has percentage dimensions, trigger its layout to resolve them
3068
- if (typeof fc._rawWidth === 'string' || typeof fc._rawHeight === 'string') {
3069
- fc.updateLayout();
3070
- }
3071
- }
3072
- }
3073
- // Measure children (skip flexExclude — they position themselves)
3074
- const measured = [];
3075
- for (const child of this._layoutChildren) {
3076
- if (child._flexConfig?.flexExclude)
3077
- continue;
3078
- const { w, h, ox, oy } = measureChild(child, pctRefW, pctRefH);
3079
- measured.push({ child, w, h, ox, oy });
3080
- }
3081
- // Split into lines (if wrapping)
3082
- const lines = [];
3083
- if (flexWrap && mainLimit < Infinity) {
3084
- let currentLine = [];
3085
- let lineMain = 0;
3086
- for (const item of measured) {
3087
- const itemMain = isRow ? item.w : item.h;
3088
- const wouldBe = lineMain + (currentLine.length > 0 ? gap : 0) + itemMain;
3089
- if (currentLine.length > 0 && wouldBe > mainLimit) {
3090
- lines.push(currentLine);
3091
- currentLine = [item];
3092
- lineMain = itemMain;
3093
- }
3094
- else {
3095
- currentLine.push(item);
3096
- lineMain = wouldBe;
3097
- }
3098
- }
3099
- if (currentLine.length > 0)
3100
- lines.push(currentLine);
3101
- }
3102
- else {
3103
- lines.push(measured);
3104
- }
3105
- // Compute cross size per line
3106
- const lineCrossSizes = lines.map((line) => {
3107
- let maxCross = 0;
3108
- for (const item of line) {
3109
- const cross = isRow ? item.h : item.w;
3110
- if (cross > maxCross)
3111
- maxCross = cross;
3112
- }
3113
- return maxCross;
3114
- });
3115
- // Compute natural main size (for auto-sizing when no explicit size given)
3116
- let naturalMainSize = 0;
3117
- if (mainLimit === Infinity) {
3118
- for (const line of lines) {
3119
- let lineMain = 0;
3120
- for (const item of line) {
3121
- lineMain += isRow ? item.w : item.h;
3122
- }
3123
- lineMain += gap * Math.max(0, line.length - 1);
3124
- naturalMainSize = Math.max(naturalMainSize, lineMain);
3125
- }
3126
- }
3127
- // Effective main size: explicit if set, otherwise natural content size
3128
- const effectiveMainSize = mainLimit < Infinity ? mainLimit : naturalMainSize;
3129
- // Compute alignContent offsets for multi-line layouts
3130
- const totalLinesCross = lineCrossSizes.reduce((s, v) => s + v, 0) + gap * Math.max(0, lines.length - 1);
3131
- let acOffset = 0;
3132
- let acExtraGap = 0;
3133
- if (lines.length > 1 && crossLimit < Infinity) {
3134
- const freeSpace = Math.max(0, crossLimit - totalLinesCross);
3135
- switch (alignContent) {
3136
- case 'center':
3137
- acOffset = freeSpace / 2;
3138
- break;
3139
- case 'end':
3140
- acOffset = freeSpace;
3141
- break;
3142
- case 'space-between':
3143
- if (lines.length > 1) {
3144
- acExtraGap = freeSpace / (lines.length - 1);
3145
- }
3146
- break;
3147
- case 'stretch':
3148
- if (lines.length > 0) {
3149
- const extra = freeSpace / lines.length;
3150
- for (let i = 0; i < lineCrossSizes.length; i++) {
3151
- lineCrossSizes[i] += extra;
3152
- }
3153
- }
3154
- break;
3155
- // 'start' — no adjustment
3156
- }
3157
- }
3158
- // Layout each line
3159
- let crossOffset = (isRow ? pt : pl) + acOffset;
3160
- for (let i = 0; i < lines.length; i++) {
3161
- const line = lines[i];
3162
- const lineCross = lineCrossSizes[i];
3163
- const mainStart = isRow ? pl : pt;
3164
- // Offset items by padding
3165
- const tempItems = line.map((item) => ({ ...item }));
3166
- // Cross size for alignment: use container cross size for single-line, line cross for multi-line
3167
- const effectiveCross = lines.length === 1 && crossLimit < Infinity ? crossLimit : lineCross;
3168
- layoutLine(tempItems, isRow, effectiveMainSize, mainLimit < Infinity ? justifyContent : 'start', alignItems, gap, crossOffset, effectiveCross);
3169
- // Apply main-axis padding offset
3170
- for (const item of tempItems) {
3171
- const origChild = line.find((l) => l.child === item.child);
3172
- origChild.child.x = item.child.x + (isRow ? mainStart : 0);
3173
- origChild.child.y = item.child.y + (isRow ? 0 : mainStart);
3174
- }
3175
- crossOffset += lineCross + gap + acExtraGap;
3176
- }
3177
- // Compute and store actual dimensions for measureChild() and getContentSize()
3178
- let totalCrossNatural = 0;
3179
- for (let i = 0; i < lineCrossSizes.length; i++) {
3180
- totalCrossNatural += lineCrossSizes[i];
3181
- if (i < lineCrossSizes.length - 1)
3182
- totalCrossNatural += gap;
3183
- }
3184
- if (isRow) {
3185
- this._computedWidth = this._explicitWidth > 0 ? this._explicitWidth : (pl + naturalMainSize + pr);
3186
- this._computedHeight = this._explicitHeight > 0 ? this._explicitHeight : (pt + totalCrossNatural + pb);
3187
- }
3188
- else {
3189
- this._computedWidth = this._explicitWidth > 0 ? this._explicitWidth : (pl + totalCrossNatural + pr);
3190
- this._computedHeight = this._explicitHeight > 0 ? this._explicitHeight : (pt + naturalMainSize + pb);
3191
- }
3192
- // Position flexExclude children (absolute positioning).
3193
- // All position props describe the visual (bounds) rectangle. We derive the
3194
- // visual rectangle directly from `getLocalBounds()` rather than the
3195
- // layout-config `w`/`h` returned by measureChild — those may differ from
3196
- // actual bounds when the user pins `layoutWidth`/`layoutHeight` to a value
3197
- // that doesn't match the child's real visual extent (e.g. a Button whose
3198
- // text overflows its configured `width`). Mixing layout size with real
3199
- // bounds-origin would shift the visual center off the requested point.
3200
- // `centerX/centerY` take precedence over `left/right` / `top/bottom` on each axis.
3201
- for (const child of this._layoutChildren) {
3202
- if (!child._flexConfig?.flexExclude)
3203
- continue;
3204
- const cfg = child._flexConfig;
3205
- const bounds = child.getLocalBounds();
3206
- const cw = this._computedWidth;
3207
- const ch = this._computedHeight;
3208
- const centerX = resolveDimension(cfg.centerX, pctRefW);
3209
- if (centerX !== undefined)
3210
- child.x = centerX - bounds.x - bounds.width / 2;
3211
- else if (cfg.left !== undefined)
3212
- child.x = cfg.left - bounds.x;
3213
- else if (cfg.right !== undefined)
3214
- child.x = cw - cfg.right - bounds.x - bounds.width;
3215
- const centerY = resolveDimension(cfg.centerY, pctRefH);
3216
- if (centerY !== undefined)
3217
- child.y = centerY - bounds.y - bounds.height / 2;
3218
- else if (cfg.top !== undefined)
3219
- child.y = cfg.top - bounds.y;
3220
- else if (cfg.bottom !== undefined)
3221
- child.y = ch - cfg.bottom - bounds.y - bounds.height;
3222
- }
3223
- }
3224
- /** Computed content size (after layout) */
3225
- getContentSize() {
3226
- if (this._layoutDirty)
3227
- this.updateLayout();
3228
- return { width: this._computedWidth, height: this._computedHeight };
3229
- }
3230
- /** React reconciler update hook — applies changed config props */
3231
- updateConfig(changed) {
3232
- if ('direction' in changed)
3233
- this.setDirection(changed.direction);
3234
- if ('justifyContent' in changed)
3235
- this.setJustifyContent(changed.justifyContent);
3236
- if ('alignItems' in changed)
3237
- this.setAlignItems(changed.alignItems);
3238
- if ('gap' in changed)
3239
- this.setGap(changed.gap);
3240
- if ('padding' in changed || 'paddingTop' in changed || 'paddingRight' in changed || 'paddingBottom' in changed || 'paddingLeft' in changed) {
3241
- this._padding = resolvePadding(changed);
3242
- this._layoutDirty = true;
3243
- }
3244
- if ('flexWrap' in changed) {
3245
- this._config.flexWrap = changed.flexWrap;
3246
- this._layoutDirty = true;
3247
- }
3248
- if ('alignContent' in changed) {
3249
- this._config.alignContent = changed.alignContent;
3250
- this._layoutDirty = true;
3251
- }
3252
- if ('width' in changed || 'height' in changed) {
3253
- const w = changed.width ?? this._rawWidth;
3254
- const h = changed.height ?? this._rawHeight;
3255
- this._rawWidth = w;
3256
- this._rawHeight = h;
3257
- if (typeof w === 'number' && typeof h === 'number') {
3258
- this.resize(w, h);
3259
- }
3260
- else {
3261
- // Percentage — will resolve in updateLayout
3262
- this._explicitWidth = typeof w === 'number' ? w : 0;
3263
- this._explicitHeight = typeof h === 'number' ? h : 0;
3264
- this._layoutDirty = true;
3265
- if (!this._layoutSuspended)
3266
- this.updateLayout();
3267
- }
3268
- return;
3269
- }
3270
- if (this._layoutDirty && !this._layoutSuspended)
3271
- this.updateLayout();
3272
- }
3273
- destroy(options) {
3274
- this._layoutChildren.length = 0;
3275
- super.destroy(options);
3276
- }
3277
- }
3278
-
3279
- /**
3280
- * Resolve a ViewInput to a Container instance.
3281
- *
3282
- * @example
3283
- * ```ts
3284
- * resolveView('btn-idle') // → Sprite.from('btn-idle')
3285
- * resolveView(someTexture) // → new Sprite(someTexture)
3286
- * resolveView(myCustomContainer) // → myCustomContainer (as-is)
3287
- * resolveView(undefined) // → null
3288
- * ```
3289
- */
3290
- function resolveView(input) {
3291
- if (input == null)
3292
- return null;
3293
- if (typeof input === 'string')
3294
- return pixi_js.Sprite.from(input);
3295
- if (input instanceof pixi_js.Texture)
3296
- return new pixi_js.Sprite(input);
3297
- return input;
3298
- }
3299
-
3300
- const DEFAULT_COLORS = {
3301
- default: 0xffd700,
3302
- hover: 0xffe44d,
3303
- pressed: 0xccac00,
3304
- disabled: 0x666666,
3305
- };
3306
- function makeGraphicsView(w, h, radius, color) {
3307
- const g = new pixi_js.Graphics();
3308
- g.roundRect(-w / 2, -h / 2, w, h, radius).fill(color);
3309
- return g;
3310
- }
3311
- /**
3312
- * Interactive button with per-state custom views and animations.
3313
- *
3314
- * Each visual state accepts a `ViewInput`: texture name, Texture, or any Container
3315
- * (Sprite, NineSliceSprite, AnimatedSprite, custom artwork, etc).
3316
- * Falls back to colored Graphics when no custom view is provided.
3317
- *
3318
- * @example
3319
- * ```ts
3320
- * // Graphics-based (quick prototyping)
3321
- * const btn = new Button({
3322
- * width: 200, height: 60, borderRadius: 12,
3323
- * colors: { default: 0x22aa22, hover: 0x33cc33 },
3324
- * text: 'SPIN',
3325
- * onPress: () => spin(),
3326
- * });
3327
- *
3328
- * // Asset-based (production art)
3329
- * const btn = new Button({
3330
- * defaultView: 'btn-idle',
3331
- * hoverView: 'btn-hover',
3332
- * pressedView: 'btn-pressed',
3333
- * disabledView: 'btn-disabled',
3334
- * text: 'SPIN',
3335
- * onPress: () => spin(),
3336
- * });
3337
- *
3338
- * // Custom Container view
3339
- * const btn = new Button({
3340
- * defaultView: myAnimatedSprite,
3341
- * text: 'SPIN',
3342
- * });
3343
- * ```
3344
- */
3345
- class Button extends pixi_js.Container {
3346
- __uiComponent = true;
3347
- _views = new Map();
3348
- _state = 'default';
3349
- _enabled = true;
3350
- _config;
3351
- _textObj = null;
3352
- /** Press callback */
3353
- onPress;
3354
- constructor(config = {}) {
3355
- super();
3356
- this._config = {
3357
- width: config.width ?? 200,
3358
- height: config.height ?? 60,
3359
- borderRadius: config.borderRadius ?? 8,
3360
- pressScale: config.pressScale ?? 0.95,
3361
- animationDuration: config.animationDuration ?? 100,
3362
- ...config,
3363
- };
3364
- this.onPress = config.onPress;
3365
- this._buildViews(config);
3366
- // Text
3367
- if (config.text) {
3368
- this._textObj = new pixi_js.Text({
3369
- text: config.text,
3370
- style: {
3371
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
3372
- fontSize: 20,
3373
- fill: 0xffffff,
3374
- fontWeight: 'bold',
3375
- ...config.textStyle,
3376
- },
3377
- });
3378
- this._textObj.anchor.set(0.5);
3379
- this.addChild(this._textObj);
3380
- }
3381
- // Interaction
3382
- this.eventMode = 'static';
3383
- this.cursor = 'pointer';
3384
- this.on('pointerover', this._onPointerOver, this);
3385
- this.on('pointerout', this._onPointerOut, this);
3386
- this.on('pointerdown', this._onPointerDown, this);
3387
- this.on('pointerup', this._onPointerUp, this);
3388
- this.on('pointerupoutside', this._onPointerUpOutside, this);
3389
- if (config.disabled) {
3390
- this.enabled = false;
3391
- }
3392
- }
3393
- /** Current button state */
3394
- get state() {
3395
- return this._state;
3396
- }
3397
- /** Enable the button */
3398
- enable() {
3399
- this.enabled = true;
3400
- }
3401
- /** Disable the button */
3402
- disable() {
3403
- this.enabled = false;
3404
- }
3405
- /** Whether the button is enabled */
3406
- get enabled() {
3407
- return this._enabled;
3408
- }
3409
- set enabled(value) {
3410
- this._enabled = value;
3411
- this.cursor = value ? 'pointer' : 'default';
3412
- this.eventMode = value ? 'static' : 'none';
3413
- this._setState(value ? 'default' : 'disabled');
3414
- }
3415
- /** Whether the button is disabled */
3416
- get disabled() {
3417
- return !this._enabled;
3418
- }
3419
- /** Update button text */
3420
- set text(value) {
3421
- if (this._textObj) {
3422
- this._textObj.text = value;
3423
- }
3424
- }
3425
- // ─── View building ──────────────────────────────────
3426
- _buildViews(config) {
3427
- const colorMap = { ...DEFAULT_COLORS, ...config.colors };
3428
- const { width, height, borderRadius } = this._config;
3429
- const stateViews = {
3430
- default: config.defaultView,
3431
- hover: config.hoverView,
3432
- pressed: config.pressedView,
3433
- disabled: config.disabledView,
3434
- };
3435
- const states = ['default', 'hover', 'pressed', 'disabled'];
3436
- for (const state of states) {
3437
- const customView = resolveView(stateViews[state]);
3438
- const view = customView ?? makeGraphicsView(width, height, borderRadius, colorMap[state]);
3439
- view.visible = state === 'default';
3440
- this._views.set(state, view);
3441
- this.addChild(view);
3442
- }
3443
- }
3444
- _rebuildViews() {
3445
- for (const [, view] of this._views) {
3446
- this.removeChild(view);
3447
- view.destroy();
3448
- }
3449
- this._views.clear();
3450
- this._buildViews(this._config);
3451
- // Re-insert views before text
3452
- if (this._textObj && this._textObj.parent === this) {
3453
- this.setChildIndex(this._textObj, this.children.length - 1);
3454
- }
3455
- }
3456
- // ─── State management ───────────────────────────────
3457
- _setState(state) {
3458
- if (this._state === state)
3459
- return;
3460
- this._state = state;
3461
- for (const [s, view] of this._views) {
3462
- view.visible = s === state;
3463
- }
3464
- }
3465
- _onPointerOver() {
3466
- if (!this._enabled)
3467
- return;
3468
- this._setState('hover');
3469
- Tween.killTweensOf(this);
3470
- Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
3471
- }
3472
- _onPointerOut() {
3473
- if (!this._enabled)
3474
- return;
3475
- this._setState('default');
3476
- Tween.killTweensOf(this);
3477
- Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
3478
- }
3479
- _onPointerDown() {
3480
- if (!this._enabled)
3481
- return;
3482
- this._setState('pressed');
3483
- Tween.killTweensOf(this);
3484
- const s = this._config.pressScale;
3485
- Tween.to(this, { 'scale.x': s, 'scale.y': s }, this._config.animationDuration, Easing.easeOutQuad);
3486
- }
3487
- _onPointerUp() {
3488
- if (!this._enabled)
3489
- return;
3490
- this._setState('hover');
3491
- Tween.killTweensOf(this);
3492
- Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
3493
- this.onPress?.();
3494
- }
3495
- _onPointerUpOutside() {
3496
- if (!this._enabled)
3497
- return;
3498
- this._setState('default');
3499
- Tween.killTweensOf(this);
3500
- Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
3501
- }
3502
- /** React reconciler update hook */
3503
- updateConfig(changed) {
3504
- if ('text' in changed && this._textObj)
3505
- this._textObj.text = changed.text;
3506
- if ('disabled' in changed)
3507
- this.enabled = !changed.disabled;
3508
- if ('onPress' in changed)
3509
- this.onPress = changed.onPress;
3510
- const structural = [
3511
- 'colors', 'width', 'height', 'borderRadius', 'textStyle',
3512
- 'defaultView', 'hoverView', 'pressedView', 'disabledView',
3513
- ];
3514
- const needsRebuild = structural.some((k) => k in changed);
3515
- if (needsRebuild) {
3516
- Object.assign(this._config, changed);
3517
- this._rebuildViews();
3518
- }
3519
- }
3520
- destroy(options) {
3521
- Tween.killTweensOf(this);
3522
- this.off('pointerover', this._onPointerOver, this);
3523
- this.off('pointerout', this._onPointerOut, this);
3524
- this.off('pointerdown', this._onPointerDown, this);
3525
- this.off('pointerup', this._onPointerUp, this);
3526
- this.off('pointerupoutside', this._onPointerUpOutside, this);
3527
- this._views.clear();
3528
- this._textObj = null;
3529
- super.destroy(options);
3530
- }
3531
- }
3532
-
3533
- /**
3534
- * Horizontal progress bar with optional custom track/fill views.
3535
- *
3536
- * Supports asset-based skinning: provide `trackView` and/or `fillView`
3537
- * as texture names, Textures, or any Container (NineSliceSprite, custom artwork, etc).
3538
- * Falls back to colored Graphics when no custom views are provided.
3539
- *
3540
- * @example
3541
- * ```ts
3542
- * // Graphics-based (quick prototyping)
3543
- * const bar = new ProgressBar({ width: 300, height: 20, fillColor: 0x22cc22 });
3544
- * bar.progress = 0.5;
3545
- *
3546
- * // Asset-based (production art)
3547
- * const bar = new ProgressBar({
3548
- * width: 300, height: 20,
3549
- * trackView: 'bar-track',
3550
- * fillView: new NineSliceSprite({ texture: 'bar-fill', ... }),
3551
- * });
3552
- * bar.progress = 0.75;
3553
- * ```
3554
- */
3555
- class ProgressBar extends pixi_js.Container {
3556
- __uiComponent = true;
3557
- _track;
3558
- _fill;
3559
- _fillMask;
3560
- _borderGfx;
3561
- _config;
3562
- _progress = 0;
3563
- _displayedProgress = 0;
3564
- constructor(config = {}) {
3565
- super();
3566
- this._config = {
3567
- width: config.width ?? 300,
3568
- height: config.height ?? 16,
3569
- borderRadius: config.borderRadius ?? 8,
3570
- fillColor: config.fillColor ?? 0xffd700,
3571
- trackColor: config.trackColor ?? 0x333333,
3572
- borderColor: config.borderColor ?? 0x555555,
3573
- borderWidth: config.borderWidth ?? 1,
3574
- animated: config.animated ?? true,
3575
- animationSpeed: config.animationSpeed ?? 0.1,
3576
- };
3577
- const { width, height, borderRadius, fillColor, trackColor, borderColor, borderWidth } = this._config;
3578
- // Track background — custom view or Graphics
3579
- const customTrack = resolveView(config.trackView);
3580
- if (customTrack) {
3581
- customTrack.width = width;
3582
- customTrack.height = height;
3583
- this._track = customTrack;
3584
- }
3585
- else {
3586
- const g = new pixi_js.Graphics();
3587
- g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
3588
- this._track = g;
3589
- }
3590
- this.addChild(this._track);
3591
- // Fill bar — custom view or Graphics
3592
- const customFill = resolveView(config.fillView);
3593
- if (customFill) {
3594
- customFill.x = borderWidth;
3595
- customFill.y = borderWidth;
3596
- customFill.width = width - borderWidth * 2;
3597
- customFill.height = height - borderWidth * 2;
3598
- this._fill = customFill;
3599
- }
3600
- else {
3601
- const g = new pixi_js.Graphics();
3602
- g.roundRect(borderWidth, borderWidth, width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1)).fill(fillColor);
3603
- this._fill = g;
3604
- }
3605
- this.addChild(this._fill);
3606
- // Mask for the fill (controls visible width)
3607
- this._fillMask = new pixi_js.Graphics();
3608
- this._fillMask.rect(0, 0, 0, height).fill(0xffffff);
3609
- this.addChild(this._fillMask);
3610
- this._fill.mask = this._fillMask;
3611
- // Border overlay
3612
- this._borderGfx = new pixi_js.Graphics();
3613
- if (borderColor !== undefined && borderWidth > 0) {
3614
- this._borderGfx
3615
- .roundRect(0, 0, width, height, borderRadius)
3616
- .stroke({ color: borderColor, width: borderWidth });
3617
- }
3618
- this.addChild(this._borderGfx);
3619
- }
3620
- /** Get/set progress (0..1) */
3621
- get progress() {
3622
- return this._progress;
3623
- }
3624
- set progress(value) {
3625
- this._progress = Math.max(0, Math.min(1, value));
3626
- if (!this._config.animated) {
3627
- this._displayedProgress = this._progress;
3628
- this.updateMask();
3629
- }
3630
- }
3631
- /**
3632
- * Call each frame if animated is true.
3633
- */
3634
- update(_dt) {
3635
- if (!this._config.animated)
3636
- return;
3637
- if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
3638
- this._displayedProgress = this._progress;
3639
- this.updateMask();
3640
- return;
3641
- }
3642
- this._displayedProgress +=
3643
- (this._progress - this._displayedProgress) * this._config.animationSpeed;
3644
- this.updateMask();
3645
- }
3646
- /** React reconciler update hook */
3647
- updateConfig(changed) {
3648
- if ('progress' in changed)
3649
- this.progress = changed.progress;
3650
- if ('animated' in changed)
3651
- this._config.animated = changed.animated;
3652
- if ('animationSpeed' in changed)
3653
- this._config.animationSpeed = changed.animationSpeed;
3654
- }
3655
- updateMask() {
3656
- const w = this._config.width * this._displayedProgress;
3657
- this._fillMask.clear();
3658
- this._fillMask.rect(0, 0, w, this._config.height).fill(0xffffff);
3659
- }
3660
- }
3661
-
3662
- /**
3663
- * Enhanced text label with auto-fit scaling and currency formatting.
3664
- *
3665
- * @example
3666
- * ```ts
3667
- * const label = new Label({
3668
- * text: 'BALANCE',
3669
- * style: { fontSize: 24, fill: 0xffd700 },
3670
- * maxWidth: 200,
3671
- * autoFit: true,
3672
- * });
3673
- * ```
3674
- */
3675
- class Label extends pixi_js.Container {
3676
- __uiComponent = true;
3677
- _text;
3678
- _maxWidth;
3679
- _autoFit;
3680
- constructor(config = {}) {
3681
- super();
3682
- this._maxWidth = config.maxWidth ?? Infinity;
3683
- this._autoFit = config.autoFit ?? false;
3684
- this._text = new pixi_js.Text({
3685
- text: config.text ?? '',
3686
- style: {
3687
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
3688
- fontSize: 24,
3689
- fill: 0xffffff,
3690
- ...config.style,
3691
- },
3692
- });
3693
- this._text.anchor.set(0.5);
3694
- this.addChild(this._text);
3695
- this.fitText();
3696
- }
3697
- /** Get/set the displayed text */
3698
- get text() {
3699
- return this._text.text;
3700
- }
3701
- set text(value) {
3702
- this._text.text = value;
3703
- this.fitText();
3704
- }
3705
- /** Get/set the text style */
3706
- get style() {
3707
- return this._text.style;
3708
- }
3709
- /** Set max width constraint */
3710
- set maxWidth(value) {
3711
- this._maxWidth = value;
3712
- this.fitText();
3713
- }
3714
- /**
3715
- * Format and display a number as currency.
3716
- *
3717
- * @param amount - The numeric amount
3718
- * @param currency - Currency code (e.g., 'USD', 'EUR')
3719
- * @param locale - Locale string (default: 'en-US')
3720
- */
3721
- setCurrency(amount, currency, locale = 'en-US') {
3722
- try {
3723
- this.text = new Intl.NumberFormat(locale, {
3724
- style: 'currency',
3725
- currency,
3726
- minimumFractionDigits: 2,
3727
- maximumFractionDigits: 2,
3728
- }).format(amount);
3729
- }
3730
- catch {
3731
- this.text = `${amount.toFixed(2)} ${currency}`;
3732
- }
3733
- }
3734
- /**
3735
- * Format a number with thousands separators.
3736
- */
3737
- setNumber(value, decimals = 0, locale = 'en-US') {
3738
- this.text = new Intl.NumberFormat(locale, {
3739
- minimumFractionDigits: decimals,
3740
- maximumFractionDigits: decimals,
3741
- }).format(value);
3742
- }
3743
- /** React reconciler update hook */
3744
- updateConfig(changed) {
3745
- if ('text' in changed)
3746
- this.text = changed.text;
3747
- if ('maxWidth' in changed)
3748
- this.maxWidth = changed.maxWidth;
3749
- if ('autoFit' in changed) {
3750
- this._autoFit = changed.autoFit;
3751
- this.fitText();
3752
- }
3753
- if ('style' in changed && typeof changed.style === 'object') {
3754
- Object.assign(this._text.style, changed.style);
3755
- this.fitText();
3756
- }
3757
- }
3758
- fitText() {
3759
- if (!this._autoFit || this._maxWidth === Infinity)
3760
- return;
3761
- this._text.scale.set(1);
3762
- if (this._text.width > this._maxWidth) {
3763
- const scale = this._maxWidth / this._text.width;
3764
- this._text.scale.set(scale);
3765
- }
3766
- }
3767
- }
3768
-
3769
- /**
3770
- * Background panel with optional flexbox content layout.
3771
- *
3772
- * Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
3773
- * Children added via `addContent()` participate in flex layout automatically.
3774
- *
3775
- * @example
3776
- * ```ts
3777
- * // Simple colored panel
3778
- * const panel = new Panel({ width: 400, height: 300, backgroundColor: 0x222222, borderRadius: 12 });
3779
- *
3780
- * // 9-slice panel (texture-based)
3781
- * const panel = new Panel({
3782
- * nineSliceTexture: 'panel-bg',
3783
- * nineSliceBorders: [20, 20, 20, 20],
3784
- * width: 400, height: 300,
3785
- * });
3786
- * ```
3787
- */
3788
- class Panel extends pixi_js.Container {
3789
- __uiComponent = true;
3790
- _bg;
3791
- _content;
3792
- _internalSetup = true;
3793
- _panelConfig;
3794
- constructor(config = {}) {
3795
- super();
3796
- const resolvedConfig = {
3797
- width: config.width ?? 400,
3798
- height: config.height ?? 300,
3799
- padding: config.padding ?? 16,
3800
- backgroundAlpha: config.backgroundAlpha ?? 1,
3801
- ...config,
3802
- };
3803
- this._panelConfig = resolvedConfig;
3804
- // Create background
3805
- if (config.nineSliceTexture) {
3806
- const texture = typeof config.nineSliceTexture === 'string'
3807
- ? pixi_js.Texture.from(config.nineSliceTexture)
3808
- : config.nineSliceTexture;
3809
- const [left, top, right, bottom] = config.nineSliceBorders ?? [10, 10, 10, 10];
3810
- const nineSlice = new pixi_js.NineSliceSprite({
3811
- texture,
3812
- leftWidth: left,
3813
- topHeight: top,
3814
- rightWidth: right,
3815
- bottomHeight: bottom,
3816
- });
3817
- nineSlice.width = resolvedConfig.width;
3818
- nineSlice.height = resolvedConfig.height;
3819
- nineSlice.alpha = resolvedConfig.backgroundAlpha;
3820
- this._bg = nineSlice;
3821
- }
3822
- else {
3823
- const g = new pixi_js.Graphics();
3824
- const bgColor = config.backgroundColor ?? 0x1a1a2e;
3825
- const radius = config.borderRadius ?? 0;
3826
- g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius).fill(bgColor);
3827
- if (config.borderColor !== undefined && config.borderWidth) {
3828
- g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius)
3829
- .stroke({ color: config.borderColor, width: config.borderWidth });
3830
- }
3831
- g.alpha = resolvedConfig.backgroundAlpha;
3832
- this._bg = g;
3833
- }
3834
- this.addChild(this._bg);
3835
- // Create content flex container
3836
- this._content = new FlexContainer({
3837
- ...config.layout,
3838
- direction: config.layout?.direction ?? 'column',
3839
- justifyContent: config.layout?.justifyContent ?? 'start',
3840
- alignItems: config.layout?.alignItems ?? 'start',
3841
- gap: config.layout?.gap ?? 0,
3842
- padding: resolvedConfig.padding,
3843
- width: resolvedConfig.width,
3844
- height: resolvedConfig.height,
3845
- });
3846
- this.addChild(this._content);
3847
- this._internalSetup = false;
3848
- }
3849
- /** Access the content flex container — add children here for layout */
3850
- get content() {
3851
- return this._content;
3852
- }
3853
- /** Suspend content layout recalculation. Call resumeLayout() to flush. */
3854
- suspendLayout() {
3855
- this._content.suspendLayout();
3856
- }
3857
- /** Resume content layout and flush if dirty. */
3858
- resumeLayout() {
3859
- this._content.resumeLayout();
3860
- }
3861
- /** Convenience: add a child to the content layout */
3862
- addContent(child) {
3863
- this._content.addFlexChild(child);
3864
- this._content.updateLayout();
3865
- return this;
3866
- }
3867
- /** Resize the panel */
3868
- setSize(width, height) {
3869
- this._panelConfig.width = width;
3870
- this._panelConfig.height = height;
3871
- // Resize background
3872
- if (this._bg instanceof pixi_js.NineSliceSprite) {
3873
- this._bg.width = width;
3874
- this._bg.height = height;
3875
- }
3876
- else if (this._bg instanceof pixi_js.Graphics) {
3877
- const radius = this._panelConfig.borderRadius ?? 0;
3878
- const bgColor = this._panelConfig.backgroundColor ?? 0x1a1a2e;
3879
- this._bg.clear();
3880
- this._bg.roundRect(0, 0, width, height, radius).fill(bgColor);
3881
- if (this._panelConfig.borderColor !== undefined && this._panelConfig.borderWidth) {
3882
- this._bg.roundRect(0, 0, width, height, radius)
3883
- .stroke({ color: this._panelConfig.borderColor, width: this._panelConfig.borderWidth });
3884
- }
3885
- this._bg.alpha = this._panelConfig.backgroundAlpha;
3886
- }
3887
- this._content.resize(width, height);
3888
- }
3889
- /**
3890
- * Override addChild so external children are routed to content FlexContainer.
3891
- * Enables `<panel><label /><button /></panel>` in React JSX.
3892
- */
3893
- addChild(...children) {
3894
- if (this._internalSetup) {
3895
- return super.addChild(...children);
3896
- }
3897
- for (const child of children) {
3898
- this._content.addFlexChild(child);
3899
- }
3900
- this._content.updateLayout();
3901
- return children[0];
3902
- }
3903
- removeChild(...children) {
3904
- if (this._internalSetup) {
3905
- return super.removeChild(...children);
3906
- }
3907
- for (const child of children) {
3908
- this._content.removeFlexChild(child);
3909
- }
3910
- return children[0];
3911
- }
3912
- /** React reconciler update hook */
3913
- updateConfig(changed) {
3914
- if ('width' in changed || 'height' in changed) {
3915
- this.setSize(changed.width ?? this._panelConfig.width, changed.height ?? this._panelConfig.height);
3916
- }
3917
- if ('backgroundAlpha' in changed) {
3918
- this._panelConfig.backgroundAlpha = changed.backgroundAlpha;
3919
- this._bg.alpha = changed.backgroundAlpha;
3920
- }
3921
- }
3922
- destroy(options) {
3923
- super.destroy(options);
3924
- }
3925
- }
3926
-
3927
- /**
3928
- * Reactive balance display component.
3929
- *
3930
- * Automatically formats currency and can animate value changes
3931
- * with a smooth countup/countdown effect using engine Tween.
3932
- *
3933
- * @example
3934
- * ```ts
3935
- * const balance = new BalanceDisplay({ currency: 'USD', animated: true });
3936
- * balance.setValue(1000);
3937
- *
3938
- * // Wire to SDK
3939
- * sdk.on('balanceUpdate', ({ balance: val }) => balance.setValue(val));
3940
- * ```
3941
- */
3942
- class BalanceDisplay extends pixi_js.Container {
3943
- __uiComponent = true;
3944
- _prefixLabel = null;
3945
- _valueLabel;
3946
- _config;
3947
- _currentValue = 0;
3948
- _displayedValue = 0;
3949
- /** Internal target for Tween animation */
3950
- _tweenTarget = { value: 0 };
3951
- constructor(config = {}) {
3952
- super();
3953
- this._config = {
3954
- currency: config.currency ?? 'USD',
3955
- locale: config.locale ?? 'en-US',
3956
- animated: config.animated ?? true,
3957
- animationDuration: config.animationDuration ?? 500,
3958
- };
3959
- // Prefix label
3960
- if (config.prefix) {
3961
- this._prefixLabel = new Label({
3962
- text: config.prefix,
3963
- style: {
3964
- fontSize: 16,
3965
- fill: 0xaaaaaa,
3966
- ...config.style,
3967
- },
3968
- });
3969
- this.addChild(this._prefixLabel);
3970
- }
3971
- // Value label
3972
- this._valueLabel = new Label({
3973
- text: '0.00',
3974
- style: {
3975
- fontSize: 28,
3976
- fontWeight: 'bold',
3977
- fill: 0xffffff,
3978
- ...config.style,
3979
- },
3980
- maxWidth: config.maxWidth,
3981
- autoFit: !!config.maxWidth,
3982
- });
3983
- this.addChild(this._valueLabel);
3984
- this.layoutLabels();
3985
- }
3986
- /** Current displayed value */
3987
- get value() {
3988
- return this._currentValue;
3989
- }
3990
- /**
3991
- * Set the balance value. If animated, smoothly counts to the new value.
3992
- */
3993
- setValue(value) {
3994
- const oldValue = this._currentValue;
3995
- this._currentValue = value;
3996
- if (this._config.animated && oldValue !== value) {
3997
- this.animateValue(oldValue, value);
3998
- }
3999
- else {
4000
- this._displayedValue = value;
4001
- this.updateDisplay();
4002
- }
4003
- }
4004
- /**
4005
- * Set the currency code.
4006
- */
4007
- setCurrency(currency) {
4008
- this._config.currency = currency;
4009
- this.updateDisplay();
4010
- }
4011
- animateValue(from, to) {
4012
- // Cancel any running animation
4013
- Tween.killTweensOf(this._tweenTarget);
4014
- this._tweenTarget.value = from;
4015
- Tween.to(this._tweenTarget, { value: to }, this._config.animationDuration, Easing.easeOutCubic, () => {
4016
- this._displayedValue = this._tweenTarget.value;
4017
- this.updateDisplay();
4018
- });
4019
- }
4020
- updateDisplay() {
4021
- this._valueLabel.setCurrency(this._displayedValue, this._config.currency, this._config.locale);
4022
- }
4023
- layoutLabels() {
4024
- if (this._prefixLabel) {
4025
- this._prefixLabel.y = -14;
4026
- this._valueLabel.y = 14;
4027
- }
4028
- }
4029
- /** React reconciler update hook */
4030
- updateConfig(changed) {
4031
- if ('value' in changed)
4032
- this.setValue(changed.value);
4033
- if ('currency' in changed)
4034
- this.setCurrency(changed.currency);
4035
- }
4036
- destroy(options) {
4037
- Tween.killTweensOf(this._tweenTarget);
4038
- super.destroy(options);
4039
- }
4040
- }
4041
-
4042
- /**
4043
- * Win amount display with countup animation.
4044
- *
4045
- * Shows a dramatic countup from 0 to the win amount, with optional
4046
- * scale pop effect — typical of slot games. Uses engine Tween system.
4047
- *
4048
- * @example
4049
- * ```ts
4050
- * const winDisplay = new WinDisplay({ currency: 'USD' });
4051
- * scene.container.addChild(winDisplay);
4052
- * await winDisplay.showWin(150.50); // countup animation
4053
- * winDisplay.hide();
4054
- * ```
4055
- */
4056
- class WinDisplay extends pixi_js.Container {
4057
- __uiComponent = true;
4058
- _label;
4059
- _config;
4060
- /** Internal target for Tween countup */
4061
- _tweenTarget = { value: 0 };
4062
- constructor(config = {}) {
4063
- super();
4064
- this._config = {
4065
- currency: config.currency ?? 'USD',
4066
- locale: config.locale ?? 'en-US',
4067
- countupDuration: config.countupDuration ?? 1500,
4068
- popScale: config.popScale ?? 1.2,
4069
- };
4070
- this._label = new Label({
4071
- text: '',
4072
- style: {
4073
- fontSize: 48,
4074
- fontWeight: 'bold',
4075
- fill: 0xffd700,
4076
- stroke: { color: 0x000000, width: 3 },
4077
- ...config.style,
4078
- },
4079
- });
4080
- this.addChild(this._label);
4081
- this.visible = false;
4082
- }
4083
- /**
4084
- * Show a win with countup animation.
4085
- *
4086
- * @param amount - Win amount
4087
- * @returns Promise that resolves when the animation completes
4088
- */
4089
- async showWin(amount) {
4090
- this.visible = true;
4091
- this.alpha = 1;
4092
- // Cancel any running animation
4093
- Tween.killTweensOf(this._tweenTarget);
4094
- Tween.killTweensOf(this);
4095
- // Setup countup
4096
- this._tweenTarget.value = 0;
4097
- this.scale.set(0.5);
4098
- // Scale pop animation
4099
- const scalePromise = Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, 300, Easing.easeOutBack);
4100
- // Countup animation
4101
- const countupPromise = Tween.to(this._tweenTarget, { value: amount }, this._config.countupDuration, Easing.easeOutCubic, () => {
4102
- this.displayAmount(this._tweenTarget.value);
4103
- });
4104
- await Promise.all([scalePromise, countupPromise]);
4105
- // Ensure final value is exact
4106
- this.displayAmount(amount);
4107
- this.scale.set(1);
4108
- }
4109
- /**
4110
- * Skip the countup animation and show the final amount immediately.
4111
- */
4112
- skipCountup(amount) {
4113
- Tween.killTweensOf(this._tweenTarget);
4114
- Tween.killTweensOf(this);
4115
- this.displayAmount(amount);
4116
- this.scale.set(1);
4117
- }
4118
- /**
4119
- * Hide the win display.
4120
- */
4121
- hide() {
4122
- Tween.killTweensOf(this._tweenTarget);
4123
- Tween.killTweensOf(this);
4124
- this.visible = false;
4125
- this._label.text = '';
4126
- }
4127
- displayAmount(amount) {
4128
- this._label.setCurrency(amount, this._config.currency, this._config.locale);
4129
- }
4130
- /** React reconciler update hook */
4131
- updateConfig(changed) {
4132
- if ('currency' in changed)
4133
- this._config.currency = changed.currency;
4134
- if ('locale' in changed)
4135
- this._config.locale = changed.locale;
4136
- }
4137
- destroy(options) {
4138
- Tween.killTweensOf(this._tweenTarget);
4139
- Tween.killTweensOf(this);
4140
- super.destroy(options);
4141
- }
4142
- }
4143
-
4144
- /**
4145
- * Modal overlay component.
4146
- * Shows content on top of a dark overlay with enter/exit animations.
4147
- *
4148
- * Content is automatically centered via position calculations.
4149
- *
4150
- * @example
4151
- * ```ts
4152
- * const modal = new Modal({ closeOnOverlay: true });
4153
- * modal.content.addChild(settingsPanel);
4154
- * modal.onClose = () => console.log('Closed');
4155
- * await modal.show(1920, 1080);
4156
- * ```
4157
- */
4158
- class Modal extends pixi_js.Container {
4159
- __uiComponent = true;
4160
- _overlay;
4161
- _contentContainer;
4162
- _config;
4163
- _showing = false;
4164
- _internalSetup = true;
4165
- /** Called when the modal is closed */
4166
- onClose;
4167
- constructor(config = {}) {
4168
- super();
4169
- this._config = {
4170
- overlayColor: config.overlayColor ?? 0x000000,
4171
- overlayAlpha: config.overlayAlpha ?? 0.7,
4172
- closeOnOverlay: config.closeOnOverlay ?? true,
4173
- animationDuration: config.animationDuration ?? 300,
4174
- };
4175
- // Overlay
4176
- this._overlay = new pixi_js.Graphics();
4177
- this._overlay.eventMode = 'static';
4178
- this._overlay.on('pointertap', () => {
4179
- if (this._config.closeOnOverlay)
4180
- this.hide();
4181
- });
4182
- this.addChild(this._overlay);
4183
- // Content container
4184
- this._contentContainer = new pixi_js.Container();
4185
- this.addChild(this._contentContainer);
4186
- this.visible = false;
4187
- this._internalSetup = false;
4188
- }
4189
- /** Content container — add your UI here */
4190
- get content() {
4191
- return this._contentContainer;
4192
- }
4193
- /**
4194
- * Override addChild so external children are routed to _contentContainer.
4195
- * Enables `<modal><flexContainer>...</flexContainer></modal>` in React JSX.
4196
- */
4197
- addChild(...children) {
4198
- if (this._internalSetup) {
4199
- return super.addChild(...children);
4200
- }
4201
- for (const child of children) {
4202
- this._contentContainer.addChild(child);
4203
- }
4204
- return children[0];
4205
- }
4206
- removeChild(...children) {
4207
- if (this._internalSetup) {
4208
- return super.removeChild(...children);
4209
- }
4210
- for (const child of children) {
4211
- this._contentContainer.removeChild(child);
4212
- }
4213
- return children[0];
4214
- }
4215
- /** Whether the modal is currently showing */
4216
- get isShowing() {
4217
- return this._showing;
4218
- }
4219
- /**
4220
- * Show the modal with animation.
4221
- */
4222
- async show(viewWidth, viewHeight) {
4223
- this._showing = true;
4224
- this.visible = true;
4225
- // Draw overlay to cover full screen
4226
- this._overlay.clear();
4227
- this._overlay.rect(0, 0, viewWidth, viewHeight).fill(this._config.overlayColor);
4228
- this._overlay.alpha = 0;
4229
- // Center content
4230
- this._contentContainer.x = viewWidth / 2;
4231
- this._contentContainer.y = viewHeight / 2;
4232
- this._contentContainer.alpha = 0;
4233
- this._contentContainer.scale.set(0.8);
4234
- // Animate in
4235
- await Promise.all([
4236
- Tween.to(this._overlay, { alpha: this._config.overlayAlpha }, this._config.animationDuration, Easing.easeOutCubic),
4237
- Tween.to(this._contentContainer, { alpha: 1, 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutBack),
4238
- ]);
4239
- }
4240
- /**
4241
- * Hide the modal with animation.
4242
- */
4243
- async hide() {
4244
- if (!this._showing)
4245
- return;
4246
- await Promise.all([
4247
- Tween.to(this._overlay, { alpha: 0 }, this._config.animationDuration * 0.7, Easing.easeInCubic),
4248
- Tween.to(this._contentContainer, { alpha: 0, 'scale.x': 0.8, 'scale.y': 0.8 }, this._config.animationDuration * 0.7, Easing.easeInCubic),
4249
- ]);
4250
- this.visible = false;
4251
- this._showing = false;
4252
- this.onClose?.();
4253
- }
4254
- /** React reconciler update hook */
4255
- updateConfig(changed) {
4256
- if ('overlayAlpha' in changed)
4257
- this._config.overlayAlpha = changed.overlayAlpha;
4258
- if ('closeOnOverlay' in changed)
4259
- this._config.closeOnOverlay = changed.closeOnOverlay;
4260
- if ('animationDuration' in changed)
4261
- this._config.animationDuration = changed.animationDuration;
4262
- if ('onClose' in changed)
4263
- this.onClose = changed.onClose;
4264
- }
4265
- }
4266
-
4267
- const TOAST_COLORS = {
4268
- info: 0x3498db,
4269
- success: 0x27ae60,
4270
- warning: 0xf39c12,
4271
- error: 0xe74c3c,
4272
- };
4273
- /**
4274
- * Toast notification component for displaying transient messages.
4275
- *
4276
- * @example
4277
- * ```ts
4278
- * const toast = new Toast();
4279
- * scene.container.addChild(toast);
4280
- * await toast.show('Connection lost', 'error', 1920, 1080);
4281
- * ```
4282
- */
4283
- class Toast extends pixi_js.Container {
4284
- __uiComponent = true;
4285
- _bg;
4286
- _customBg;
4287
- _text;
4288
- _config;
4289
- _dismissPending = false;
4290
- constructor(config = {}) {
4291
- super();
4292
- this._config = {
4293
- duration: config.duration ?? 3000,
4294
- bottomOffset: config.bottomOffset ?? 60,
4295
- };
4296
- const customBg = resolveView(config.backgroundView);
4297
- this._customBg = !!customBg;
4298
- this._bg = customBg ?? new pixi_js.Graphics();
4299
- this.addChild(this._bg);
4300
- this._text = new pixi_js.Text({
4301
- text: '',
4302
- style: {
4303
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
4304
- fontSize: 16,
4305
- fill: 0xffffff,
4306
- },
4307
- });
4308
- this._text.anchor.set(0.5);
4309
- this.addChild(this._text);
4310
- this.visible = false;
4311
- }
4312
- /**
4313
- * Show a toast message.
4314
- */
4315
- async show(message, type = 'info', viewWidth, viewHeight) {
4316
- // Cancel any pending dismiss
4317
- Tween.killTweensOf(this);
4318
- this._dismissPending = false;
4319
- this._text.text = message;
4320
- const padding = 20;
4321
- const width = Math.max(200, this._text.width + padding * 2);
4322
- const height = 44;
4323
- const radius = 8;
4324
- // Draw the background
4325
- if (this._customBg) {
4326
- this._bg.width = width;
4327
- this._bg.height = height;
4328
- this._bg.x = -width / 2;
4329
- this._bg.y = -height / 2;
4330
- }
4331
- else {
4332
- const g = this._bg;
4333
- g.clear();
4334
- g.roundRect(-width / 2, -height / 2, width, height, radius);
4335
- g.fill(TOAST_COLORS[type]);
4336
- }
4337
- // Position
4338
- if (viewWidth && viewHeight) {
4339
- this.x = viewWidth / 2;
4340
- this.y = viewHeight - this._config.bottomOffset;
4341
- }
4342
- this.visible = true;
4343
- this.alpha = 0;
4344
- this.y += 20;
4345
- await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
4346
- if (this._config.duration > 0) {
4347
- this._dismissPending = true;
4348
- await Tween.delay(this._config.duration);
4349
- if (this._dismissPending) {
4350
- this._dismissPending = false;
4351
- await this.dismiss();
4352
- }
4353
- }
4354
- }
4355
- /**
4356
- * Dismiss the toast.
4357
- */
4358
- async dismiss() {
4359
- if (!this.visible)
4360
- return;
4361
- this._dismissPending = false;
4362
- Tween.killTweensOf(this);
4363
- await Tween.to(this, { alpha: 0, y: this.y + 20 }, 200, Easing.easeInCubic);
4364
- this.visible = false;
4365
- }
4366
- /** React reconciler update hook */
4367
- updateConfig(changed) {
4368
- if ('duration' in changed)
4369
- this._config.duration = changed.duration;
4370
- if ('bottomOffset' in changed)
4371
- this._config.bottomOffset = changed.bottomOffset;
4372
- }
4373
- destroy(options) {
4374
- this._dismissPending = false;
4375
- Tween.killTweensOf(this);
4376
- super.destroy(options);
4377
- }
4378
- }
4379
-
4380
- // ─── Helpers ─────────────────────────────────────────────
4381
- function directionToFlex(direction) {
4382
- switch (direction) {
4383
- case 'horizontal': return { direction: 'row', wrap: false };
4384
- case 'vertical': return { direction: 'column', wrap: false };
4385
- case 'grid': return { direction: 'row', wrap: true };
4386
- case 'wrap': return { direction: 'row', wrap: true };
4387
- }
4388
- }
4389
- /**
4390
- * Responsive layout container powered by a lightweight built-in flex layout solver.
4391
- *
4392
- * Supports horizontal, vertical, grid, and wrap layout modes with
4393
- * alignment, padding, gap, and viewport-anchor positioning.
4394
- * Breakpoints allow different layouts for different screen sizes.
4395
- *
4396
- * @example
4397
- * ```ts
4398
- * const toolbar = new Layout({
4399
- * direction: 'horizontal',
4400
- * gap: 20,
4401
- * alignment: 'center',
4402
- * anchor: 'bottom-center',
4403
- * padding: 16,
4404
- * breakpoints: {
4405
- * 768: { direction: 'vertical', gap: 10 },
4406
- * },
4407
- * });
4408
- *
4409
- * toolbar.addItem(spinButton);
4410
- * toolbar.addItem(betLabel);
4411
- * scene.container.addChild(toolbar);
4412
- *
4413
- * toolbar.updateViewport(width, height);
4414
- * ```
4415
- */
4416
- class Layout extends pixi_js.Container {
4417
- __uiComponent = true;
4418
- _layoutConfig;
4419
- _padding;
4420
- _anchor;
4421
- _maxWidth;
4422
- _breakpoints;
4423
- _items = [];
4424
- _viewportWidth = 0;
4425
- _viewportHeight = 0;
4426
- _flex;
4427
- constructor(config = {}) {
4428
- super();
4429
- this._layoutConfig = {
4430
- direction: config.direction ?? 'vertical',
4431
- gap: config.gap ?? 0,
4432
- alignment: config.alignment ?? 'start',
4433
- autoLayout: config.autoLayout ?? true,
4434
- columns: config.columns ?? 2,
4435
- };
4436
- this._padding = config.padding ?? 0;
4437
- this._anchor = config.anchor ?? 'top-left';
4438
- this._maxWidth = config.maxWidth ?? Infinity;
4439
- this._breakpoints = config.breakpoints
4440
- ? Object.entries(config.breakpoints)
4441
- .map(([w, cfg]) => [Number(w), cfg])
4442
- .sort((a, b) => a[0] - b[0])
4443
- : [];
4444
- // Create internal FlexContainer
4445
- this._flex = new FlexContainer();
4446
- super.addChild(this._flex);
4447
- this.applyLayoutStyles();
4448
- }
4449
- /** Add an item to the layout */
4450
- addItem(child) {
4451
- this._items.push(child);
4452
- const flexConfig = this.buildFlexItemConfig(child);
4453
- this._flex.addFlexChild(child, flexConfig);
4454
- if (this._layoutConfig.autoLayout) {
4455
- this.applyLayoutStyles();
4456
- }
4457
- return this;
4458
- }
4459
- /** Remove an item from the layout */
4460
- removeItem(child) {
4461
- const idx = this._items.indexOf(child);
4462
- if (idx !== -1) {
4463
- this._items.splice(idx, 1);
4464
- this._flex.removeFlexChild(child);
4465
- }
4466
- return this;
4467
- }
4468
- /** Remove all items */
4469
- clearItems() {
4470
- this._flex.clearFlexChildren();
4471
- this._items.length = 0;
4472
- return this;
4473
- }
4474
- /** Get all layout items */
4475
- get items() {
4476
- return this._items;
4477
- }
4478
- /**
4479
- * Update the viewport size and recalculate layout.
4480
- * Should be called from `Scene.onResize()`.
4481
- */
4482
- updateViewport(width, height) {
4483
- this._viewportWidth = width;
4484
- this._viewportHeight = height;
4485
- this.applyLayoutStyles();
4486
- this.applyAnchor();
4487
- }
4488
- applyLayoutStyles() {
4489
- const effective = this.resolveConfig();
4490
- const direction = effective.direction ?? this._layoutConfig.direction;
4491
- const gap = effective.gap ?? this._layoutConfig.gap;
4492
- const alignment = effective.alignment ?? this._layoutConfig.alignment;
4493
- const padding = effective.padding ?? this._padding;
4494
- const maxWidth = effective.maxWidth ?? this._maxWidth;
4495
- const { direction: flexDir, wrap } = directionToFlex(direction);
4496
- this._flex.setDirection(flexDir);
4497
- this._flex.setJustifyContent('start');
4498
- this._flex.setAlignItems(alignment);
4499
- this._flex.setGap(gap);
4500
- this._flex.setPadding(padding);
4501
- // Wrap and maxWidth
4502
- if (wrap) {
4503
- this._flex._config.flexWrap = true;
4504
- if (direction === 'grid' && maxWidth < Infinity) {
4505
- this._flex._maxWidth = maxWidth;
4506
- }
4507
- if (maxWidth < Infinity) {
4508
- this._flex._maxWidth = maxWidth;
4509
- }
4510
- }
4511
- else {
4512
- this._flex._config.flexWrap = false;
4513
- }
4514
- // Update grid child widths
4515
- if (direction === 'grid') {
4516
- for (const item of this._items) {
4517
- const flexConfig = this.buildFlexItemConfig(item);
4518
- item._flexConfig = flexConfig;
4519
- }
4520
- }
4521
- // Set explicit size if we have viewport dimensions
4522
- if (this._viewportWidth > 0 && this._viewportHeight > 0) {
4523
- this._flex.resize(this._viewportWidth, this._viewportHeight);
4524
- }
4525
- else {
4526
- this._flex.updateLayout();
4527
- }
4528
- }
4529
- buildFlexItemConfig(_child) {
4530
- const effective = this.resolveConfig();
4531
- const direction = effective.direction ?? this._layoutConfig.direction;
4532
- const columns = effective.columns ?? this._layoutConfig.columns;
4533
- if (direction === 'grid' && columns > 0) {
4534
- // For grid, give each item a proportional width
4535
- // The actual pixel width will be computed during layout
4536
- return { flexGrow: 1 };
4537
- }
4538
- return undefined;
4539
- }
4540
- applyAnchor() {
4541
- const anchor = this.resolveConfig().anchor ?? this._anchor;
4542
- if (this._viewportWidth === 0 || this._viewportHeight === 0)
4543
- return;
4544
- const { width: contentW, height: contentH } = this._flex.getContentSize();
4545
- const vw = this._viewportWidth;
4546
- const vh = this._viewportHeight;
4547
- let anchorX = 0;
4548
- let anchorY = 0;
4549
- if (anchor.includes('left')) {
4550
- anchorX = 0;
4551
- }
4552
- else if (anchor.includes('right')) {
4553
- anchorX = vw - contentW;
4554
- }
4555
- else {
4556
- anchorX = (vw - contentW) / 2;
4557
- }
4558
- if (anchor.startsWith('top')) {
4559
- anchorY = 0;
4560
- }
4561
- else if (anchor.startsWith('bottom')) {
4562
- anchorY = vh - contentH;
4563
- }
4564
- else {
4565
- anchorY = (vh - contentH) / 2;
4566
- }
4567
- this.x = anchorX;
4568
- this.y = anchorY;
4569
- }
4570
- resolveConfig() {
4571
- if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
4572
- return {};
4573
- }
4574
- for (const [maxWidth, overrides] of this._breakpoints) {
4575
- if (this._viewportWidth <= maxWidth) {
4576
- return overrides;
4577
- }
4578
- }
4579
- return {};
4580
- }
4581
- /** React reconciler update hook */
4582
- updateConfig(changed) {
4583
- if ('direction' in changed)
4584
- this._layoutConfig.direction = changed.direction;
4585
- if ('gap' in changed)
4586
- this._layoutConfig.gap = changed.gap;
4587
- if ('alignment' in changed)
4588
- this._layoutConfig.alignment = changed.alignment;
4589
- if ('anchor' in changed)
4590
- this._anchor = changed.anchor;
4591
- if ('padding' in changed)
4592
- this._padding = changed.padding;
4593
- if ('columns' in changed)
4594
- this._layoutConfig.columns = changed.columns;
4595
- this.applyLayoutStyles();
4596
- if (this._viewportWidth > 0)
4597
- this.applyAnchor();
4598
- }
4599
- destroy(options) {
4600
- this._items.length = 0;
4601
- super.destroy(options);
4602
- }
4603
- }
4604
-
4605
- const DECELERATION = 0.95;
4606
- const MIN_VELOCITY = 0.5;
4607
- /**
4608
- * Scrollable container with touch/drag, mouse wheel, and inertia.
4609
- *
4610
- * @example
4611
- * ```ts
4612
- * const scroll = new ScrollContainer({
4613
- * width: 600,
4614
- * height: 400,
4615
- * direction: 'vertical',
4616
- * elementsMargin: 8,
4617
- * });
4618
- *
4619
- * for (let i = 0; i < 50; i++) {
4620
- * scroll.addItem(createRow(i));
4621
- * }
4622
- *
4623
- * scene.container.addChild(scroll);
4624
- * ```
4625
- */
4626
- class ScrollContainer extends pixi_js.Container {
4627
- __uiComponent = true;
4628
- _viewport;
4629
- _internalSetup = true;
4630
- _content;
4631
- _maskGfx;
4632
- _bg = null;
4633
- _scrollConfig;
4634
- _items = [];
4635
- // Scrollbar
4636
- _scrollbar = null;
4637
- _scrollbarConfig;
4638
- // Drag state
4639
- _dragging = false;
4640
- _dragStart = { x: 0, y: 0 };
4641
- _contentStart = { x: 0, y: 0 };
4642
- _velocity = { x: 0, y: 0 };
4643
- _lastDragPos = { x: 0, y: 0 };
4644
- _lastDragTime = 0;
4645
- _inertiaActive = false;
4646
- // Bound handlers for cleanup
4647
- _onTickBound = null;
4648
- _onWheelBound = null;
4649
- constructor(config) {
4650
- super();
4651
- this._viewport = { width: config.width, height: config.height };
4652
- this._scrollConfig = {
4653
- direction: config.direction ?? 'vertical',
4654
- elementsMargin: config.elementsMargin ?? 0,
4655
- padding: config.padding ?? 0,
4656
- borderRadius: config.borderRadius ?? 0,
4657
- disableEasing: config.disableEasing ?? false,
4658
- };
4659
- // Background
4660
- if (config.backgroundColor !== undefined) {
4661
- this._bg = new pixi_js.Graphics();
4662
- this._bg.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
4663
- .fill(config.backgroundColor);
4664
- this.addChild(this._bg);
4665
- }
4666
- // Mask
4667
- this._maskGfx = new pixi_js.Graphics();
4668
- this._maskGfx.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
4669
- .fill(0xffffff);
4670
- this.addChild(this._maskGfx);
4671
- // Content container
4672
- this._content = new pixi_js.Container();
4673
- this._content.mask = this._maskGfx;
4674
- this.addChild(this._content);
4675
- // Interaction
4676
- this.eventMode = 'static';
4677
- this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
4678
- this.on('pointerdown', this._onPointerDown, this);
4679
- this.on('pointermove', this._onPointerMove, this);
4680
- this.on('pointerup', this._onPointerUp, this);
4681
- this.on('pointerupoutside', this._onPointerUp, this);
4682
- // Mouse wheel
4683
- this._onWheelBound = this._onWheel.bind(this);
4684
- // Scrollbar
4685
- const sbWidth = config.scrollbarWidth ?? 6;
4686
- const sbPadding = config.scrollbarPadding ?? 4;
4687
- this._scrollbarConfig = { width: sbWidth, padding: sbPadding };
4688
- if (config.scrollbar) {
4689
- const customThumb = resolveView(config.thumbView);
4690
- if (customThumb) {
4691
- this._scrollbar = customThumb;
4692
- }
4693
- else {
4694
- const g = new pixi_js.Graphics();
4695
- g.roundRect(0, 0, sbWidth, 40, sbWidth / 2).fill(config.scrollbarColor ?? 0xaaaaaa);
4696
- g.alpha = config.scrollbarAlpha ?? 0.5;
4697
- this._scrollbar = g;
4698
- }
4699
- this._scrollbar.visible = false;
4700
- super.addChild(this._scrollbar);
4701
- }
4702
- this._internalSetup = false;
4703
- }
4704
- /**
4705
- * Override addChild so external children are routed to scroll content.
4706
- * Enables `<scrollContainer><label /><panel /></scrollContainer>` in React JSX.
4707
- */
4708
- addChild(...children) {
4709
- if (this._internalSetup) {
4710
- return super.addChild(...children);
4711
- }
4712
- for (const child of children) {
4713
- this.addItem(child);
4714
- }
4715
- return children[0];
4716
- }
4717
- removeChild(...children) {
4718
- if (this._internalSetup) {
4719
- return super.removeChild(...children);
4720
- }
4721
- for (const child of children) {
4722
- const idx = this._items.indexOf(child);
4723
- if (idx !== -1) {
4724
- this._items.splice(idx, 1);
4725
- this._content.removeChild(child);
4726
- }
4727
- }
4728
- this.layoutItems();
4729
- return children[0];
4730
- }
4731
- /** React reconciler update hook */
4732
- updateConfig(changed) {
4733
- if ('width' in changed || 'height' in changed) {
4734
- this.setViewportSize(changed.width ?? this._viewport.width, changed.height ?? this._viewport.height);
4735
- }
4736
- }
4737
- /** Enable mouse wheel scrolling (call after adding to stage) */
4738
- enableWheel(canvas) {
4739
- if (this._onWheelBound) {
4740
- canvas.addEventListener('wheel', this._onWheelBound, { passive: false });
4741
- }
4742
- }
4743
- /** Set scrollable content. Replaces any existing items. */
4744
- setContent(content) {
4745
- this.clearItems();
4746
- const children = [...content.children];
4747
- for (const child of children) {
4748
- this.addItem(child);
4749
- }
4750
- }
4751
- /** Add a single item */
4752
- addItem(child) {
4753
- this._items.push(child);
4754
- this._content.addChild(child);
4755
- this.layoutItems();
4756
- return this;
4757
- }
4758
- /** Remove all items */
4759
- clearItems() {
4760
- for (const item of this._items) {
4761
- this._content.removeChild(item);
4762
- }
4763
- this._items.length = 0;
4764
- }
4765
- /** Get items */
4766
- get items() {
4767
- return this._items;
4768
- }
4769
- /** Scroll to make a specific item index visible */
4770
- scrollToItem(index) {
4771
- if (index < 0 || index >= this._items.length)
4772
- return;
4773
- const item = this._items[index];
4774
- const isVert = this._scrollConfig.direction !== 'horizontal';
4775
- if (isVert) {
4776
- this._content.y = -item.y + this._scrollConfig.padding;
4777
- }
4778
- else {
4779
- this._content.x = -item.x + this._scrollConfig.padding;
4780
- }
4781
- this.clampScroll();
4782
- }
4783
- /** Current scroll position */
4784
- get scrollPosition() {
4785
- return { x: this._content.x, y: this._content.y };
4786
- }
4787
- /** Resize the scroll viewport */
4788
- setViewportSize(width, height) {
4789
- this._viewport.width = width;
4790
- this._viewport.height = height;
4791
- this._maskGfx.clear();
4792
- this._maskGfx.roundRect(0, 0, width, height, this._scrollConfig.borderRadius).fill(0xffffff);
4793
- if (this._bg) {
4794
- this._bg.clear();
4795
- this._bg.roundRect(0, 0, width, height, this._scrollConfig.borderRadius)
4796
- .fill(0xffffff); // color will be overridden if needed
4797
- }
4798
- this.clampScroll();
4799
- }
4800
- // ─── Layout ──────────────────────────────────────────
4801
- layoutItems() {
4802
- const { direction, elementsMargin, padding } = this._scrollConfig;
4803
- const isVert = direction !== 'horizontal';
4804
- let pos = padding;
4805
- for (const item of this._items) {
4806
- if (isVert) {
4807
- item.x = padding;
4808
- item.y = pos;
4809
- pos += item.height + elementsMargin;
4810
- }
4811
- else {
4812
- item.x = pos;
4813
- item.y = padding;
4814
- pos += item.width + elementsMargin;
4815
- }
4816
- }
4817
- }
4818
- // ─── Drag handling ───────────────────────────────────
4819
- _onPointerDown(e) {
4820
- this._dragging = true;
4821
- this._inertiaActive = false;
4822
- this._dragStart.x = e.globalX;
4823
- this._dragStart.y = e.globalY;
4824
- this._contentStart.x = this._content.x;
4825
- this._contentStart.y = this._content.y;
4826
- this._lastDragPos.x = e.globalX;
4827
- this._lastDragPos.y = e.globalY;
4828
- this._lastDragTime = Date.now();
4829
- this._velocity.x = 0;
4830
- this._velocity.y = 0;
4831
- this.stopInertia();
4832
- }
4833
- _onPointerMove(e) {
4834
- if (!this._dragging)
4835
- return;
4836
- const dx = e.globalX - this._dragStart.x;
4837
- const dy = e.globalY - this._dragStart.y;
4838
- const { direction } = this._scrollConfig;
4839
- if (direction !== 'horizontal') {
4840
- this._content.y = this._contentStart.y + dy;
4841
- }
4842
- if (direction !== 'vertical') {
4843
- this._content.x = this._contentStart.x + dx;
4844
- }
4845
- // Track velocity
4846
- const now = Date.now();
4847
- const dt = now - this._lastDragTime;
4848
- if (dt > 0) {
4849
- this._velocity.x = (e.globalX - this._lastDragPos.x) / dt * 16;
4850
- this._velocity.y = (e.globalY - this._lastDragPos.y) / dt * 16;
4851
- }
4852
- this._lastDragPos.x = e.globalX;
4853
- this._lastDragPos.y = e.globalY;
4854
- this._lastDragTime = now;
4855
- this.clampScroll();
4856
- }
4857
- _onPointerUp() {
4858
- if (!this._dragging)
4859
- return;
4860
- this._dragging = false;
4861
- if (!this._scrollConfig.disableEasing &&
4862
- (Math.abs(this._velocity.x) > MIN_VELOCITY || Math.abs(this._velocity.y) > MIN_VELOCITY)) {
4863
- this.startInertia();
4864
- }
4865
- }
4866
- // ─── Inertia ─────────────────────────────────────────
4867
- startInertia() {
4868
- this._inertiaActive = true;
4869
- this._onTickBound = this._inertiaTick.bind(this);
4870
- pixi_js.Ticker.shared.add(this._onTickBound);
4871
- }
4872
- stopInertia() {
4873
- if (this._onTickBound && this._inertiaActive) {
4874
- pixi_js.Ticker.shared.remove(this._onTickBound);
4875
- this._inertiaActive = false;
4876
- }
4877
- }
4878
- _inertiaTick() {
4879
- const { direction } = this._scrollConfig;
4880
- if (direction !== 'horizontal') {
4881
- this._content.y += this._velocity.y;
4882
- this._velocity.y *= DECELERATION;
4883
- }
4884
- if (direction !== 'vertical') {
4885
- this._content.x += this._velocity.x;
4886
- this._velocity.x *= DECELERATION;
4887
- }
4888
- this.clampScroll();
4889
- if (Math.abs(this._velocity.x) < MIN_VELOCITY && Math.abs(this._velocity.y) < MIN_VELOCITY) {
4890
- this.stopInertia();
4891
- }
4892
- }
4893
- // ─── Mouse wheel ─────────────────────────────────────
4894
- _onWheel(e) {
4895
- const { direction } = this._scrollConfig;
4896
- e.preventDefault();
4897
- if (direction !== 'horizontal') {
4898
- this._content.y -= e.deltaY;
4899
- }
4900
- if (direction !== 'vertical') {
4901
- this._content.x -= e.deltaX;
4902
- }
4903
- this.clampScroll();
4904
- }
4905
- // ─── Scroll bounds ───────────────────────────────────
4906
- clampScroll() {
4907
- const { direction } = this._scrollConfig;
4908
- const bounds = this._content.getLocalBounds();
4909
- if (direction !== 'horizontal') {
4910
- const contentHeight = bounds.height + bounds.y;
4911
- const maxScroll = Math.min(0, this._viewport.height - contentHeight);
4912
- this._content.y = Math.max(maxScroll, Math.min(0, this._content.y));
4913
- }
4914
- if (direction !== 'vertical') {
4915
- const contentWidth = bounds.width + bounds.x;
4916
- const maxScroll = Math.min(0, this._viewport.width - contentWidth);
4917
- this._content.x = Math.max(maxScroll, Math.min(0, this._content.x));
4918
- }
4919
- this.updateScrollbar();
4920
- }
4921
- updateScrollbar() {
4922
- if (!this._scrollbar)
4923
- return;
4924
- const { direction } = this._scrollConfig;
4925
- const { width: sbW, padding: sbPad } = this._scrollbarConfig;
4926
- const bounds = this._content.getLocalBounds();
4927
- const isVert = direction !== 'horizontal';
4928
- if (isVert) {
4929
- const contentH = bounds.height + bounds.y;
4930
- if (contentH <= this._viewport.height) {
4931
- this._scrollbar.visible = false;
4932
- return;
4933
- }
4934
- this._scrollbar.visible = true;
4935
- const ratio = this._viewport.height / contentH;
4936
- const thumbH = Math.max(20, this._viewport.height * ratio);
4937
- const scrollRange = this._viewport.height - thumbH;
4938
- const scrollProgress = -this._content.y / (contentH - this._viewport.height);
4939
- this._scrollbar.x = this._viewport.width - sbW - sbPad;
4940
- this._scrollbar.y = scrollProgress * scrollRange;
4941
- this._scrollbar.height = thumbH;
4942
- this._scrollbar.width = sbW;
4943
- }
4944
- else {
4945
- const contentW = bounds.width + bounds.x;
4946
- if (contentW <= this._viewport.width) {
4947
- this._scrollbar.visible = false;
4948
- return;
4949
- }
4950
- this._scrollbar.visible = true;
4951
- const ratio = this._viewport.width / contentW;
4952
- const thumbW = Math.max(20, this._viewport.width * ratio);
4953
- const scrollRange = this._viewport.width - thumbW;
4954
- const scrollProgress = -this._content.x / (contentW - this._viewport.width);
4955
- this._scrollbar.y = this._viewport.height - sbW - sbPad;
4956
- this._scrollbar.x = scrollProgress * scrollRange;
4957
- this._scrollbar.width = thumbW;
4958
- this._scrollbar.height = sbW;
4959
- }
4960
- }
4961
- destroy(options) {
4962
- this.stopInertia();
4963
- this.off('pointerdown', this._onPointerDown, this);
4964
- this.off('pointermove', this._onPointerMove, this);
4965
- this.off('pointerup', this._onPointerUp, this);
4966
- this.off('pointerupoutside', this._onPointerUp, this);
4967
- this._items.length = 0;
4968
- super.destroy(options);
4969
- }
4970
- }
4971
-
4972
2467
  Object.defineProperty(exports, "BridgeDestroyedError", {
4973
2468
  enumerable: true,
4974
2469
  get: function () { return gameSdk.BridgeDestroyedError; }
@@ -4991,29 +2486,17 @@ Object.defineProperty(exports, "DevBridge", {
4991
2486
  });
4992
2487
  exports.AssetManager = AssetManager;
4993
2488
  exports.AudioManager = AudioManager;
4994
- exports.BalanceDisplay = BalanceDisplay;
4995
- exports.Button = Button;
4996
2489
  exports.Easing = Easing;
4997
2490
  exports.EventEmitter = EventEmitter;
4998
2491
  exports.FPSOverlay = FPSOverlay;
4999
- exports.FlexContainer = FlexContainer;
5000
2492
  exports.GameApplication = GameApplication;
5001
2493
  exports.InputManager = InputManager;
5002
- exports.Label = Label;
5003
- exports.Layout = Layout;
5004
2494
  exports.LoadingScene = LoadingScene;
5005
- exports.Modal = Modal;
5006
- exports.Panel = Panel;
5007
- exports.ProgressBar = ProgressBar;
5008
2495
  exports.Scene = Scene;
5009
2496
  exports.SceneManager = SceneManager;
5010
- exports.ScrollContainer = ScrollContainer;
5011
2497
  exports.SpineHelper = SpineHelper;
5012
2498
  exports.SpriteAnimation = SpriteAnimation;
5013
- exports.StateMachine = StateMachine;
5014
2499
  exports.Timeline = Timeline;
5015
- exports.Toast = Toast;
5016
2500
  exports.Tween = Tween;
5017
2501
  exports.ViewportManager = ViewportManager;
5018
- exports.WinDisplay = WinDisplay;
5019
2502
  //# sourceMappingURL=index.cjs.js.map