@energy8platform/game-engine 0.10.11 → 0.12.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 (50) hide show
  1. package/README.md +272 -74
  2. package/dist/index.cjs.js +1322 -296
  3. package/dist/index.cjs.js.map +1 -1
  4. package/dist/index.d.ts +369 -46
  5. package/dist/index.esm.js +1323 -298
  6. package/dist/index.esm.js.map +1 -1
  7. package/dist/lua.cjs.js +8 -18
  8. package/dist/lua.cjs.js.map +1 -1
  9. package/dist/lua.d.ts +0 -2
  10. package/dist/lua.esm.js +8 -18
  11. package/dist/lua.esm.js.map +1 -1
  12. package/dist/react.cjs.js +2848 -35
  13. package/dist/react.cjs.js.map +1 -1
  14. package/dist/react.d.ts +17 -6
  15. package/dist/react.esm.js +2848 -36
  16. package/dist/react.esm.js.map +1 -1
  17. package/dist/ui.cjs.js +1913 -592
  18. package/dist/ui.cjs.js.map +1 -1
  19. package/dist/ui.d.ts +528 -46
  20. package/dist/ui.esm.js +1911 -594
  21. package/dist/ui.esm.js.map +1 -1
  22. package/dist/vite.cjs.js +1 -11
  23. package/dist/vite.cjs.js.map +1 -1
  24. package/dist/vite.d.ts +1 -1
  25. package/dist/vite.esm.js +1 -11
  26. package/dist/vite.esm.js.map +1 -1
  27. package/package.json +3 -18
  28. package/src/index.ts +3 -3
  29. package/src/lua/LuaEngine.ts +8 -18
  30. package/src/react/applyProps.ts +90 -2
  31. package/src/react/extendAll.ts +29 -6
  32. package/src/react/index.ts +1 -1
  33. package/src/react/jsx.d.ts +249 -0
  34. package/src/react/reconciler.ts +80 -7
  35. package/src/ui/BalanceDisplay.ts +31 -38
  36. package/src/ui/Button.ts +217 -53
  37. package/src/ui/FlexContainer.ts +529 -0
  38. package/src/ui/Label.ts +13 -0
  39. package/src/ui/Layout.ts +86 -87
  40. package/src/ui/Modal.ts +11 -1
  41. package/src/ui/Panel.ts +108 -36
  42. package/src/ui/ProgressBar.ts +85 -31
  43. package/src/ui/ScrollContainer.ts +397 -45
  44. package/src/ui/Slider.ts +241 -0
  45. package/src/ui/Toast.ts +47 -17
  46. package/src/ui/Toggle.ts +201 -0
  47. package/src/ui/WinDisplay.ts +51 -39
  48. package/src/ui/index.ts +9 -11
  49. package/src/ui/view.ts +28 -0
  50. package/src/vite/index.ts +1 -11
package/dist/react.cjs.js CHANGED
@@ -23,6 +23,84 @@ function extend(components) {
23
23
  }
24
24
 
25
25
  const RESERVED = new Set(['children', 'key', 'ref']);
26
+ /** Props handled by the reconciler as flex item config, not forwarded to components */
27
+ const FLEX_ITEM_PROPS$1 = new Set(['flexGrow', 'flexShrink', 'layoutWidth', 'layoutHeight', 'alignSelf', 'flexExclude']);
28
+ // ─── UI Component helpers ────────────────────────────────
29
+ /**
30
+ * Extract a config object from React props.
31
+ * - Strips reserved keys (children, key, ref) and event props
32
+ * - Unfolds dash-notation into nested objects: `colors-default` → `{ colors: { default: ... } }`
33
+ */
34
+ function extractConfig(props) {
35
+ const config = {};
36
+ for (const key in props) {
37
+ if (RESERVED.has(key) || FLEX_ITEM_PROPS$1.has(key) || isEventProp(key))
38
+ continue;
39
+ if (key.includes('-')) {
40
+ const parts = key.split('-');
41
+ const root = parts[0];
42
+ const nested = parts.slice(1).join('-');
43
+ if (!config[root] || typeof config[root] !== 'object') {
44
+ config[root] = {};
45
+ }
46
+ config[root][nested] = props[key];
47
+ }
48
+ else {
49
+ config[key] = props[key];
50
+ }
51
+ }
52
+ return config;
53
+ }
54
+ /**
55
+ * Diff two prop sets and return a config object with only changed values.
56
+ * Uses extractConfig format (dash-notation unfolded).
57
+ */
58
+ function diffConfig(newProps, oldProps) {
59
+ const changed = {};
60
+ // New or changed props
61
+ for (const key in newProps) {
62
+ if (RESERVED.has(key) || FLEX_ITEM_PROPS$1.has(key) || isEventProp(key))
63
+ continue;
64
+ if (newProps[key] !== oldProps[key]) {
65
+ if (key.includes('-')) {
66
+ const parts = key.split('-');
67
+ const root = parts[0];
68
+ const nested = parts.slice(1).join('-');
69
+ if (!changed[root] || typeof changed[root] !== 'object') {
70
+ changed[root] = {};
71
+ }
72
+ changed[root][nested] = newProps[key];
73
+ }
74
+ else {
75
+ changed[key] = newProps[key];
76
+ }
77
+ }
78
+ }
79
+ return changed;
80
+ }
81
+ /**
82
+ * Apply only event props from React props to a PixiJS instance.
83
+ */
84
+ function applyEventProps(instance, newProps, oldProps = {}) {
85
+ // Remove old event handlers
86
+ for (const key in oldProps) {
87
+ if (!isEventProp(key) || key in newProps)
88
+ continue;
89
+ instance[REACT_TO_PIXI_EVENTS[key]] = null;
90
+ }
91
+ // Apply new/changed event handlers + onPress (component-level callback)
92
+ for (const key in newProps) {
93
+ if (key === 'onPress') {
94
+ instance.onPress = newProps[key];
95
+ continue;
96
+ }
97
+ if (!isEventProp(key))
98
+ continue;
99
+ if (newProps[key] !== oldProps[key]) {
100
+ instance[REACT_TO_PIXI_EVENTS[key]] = newProps[key];
101
+ }
102
+ }
103
+ }
26
104
  const REACT_TO_PIXI_EVENTS = {
27
105
  onClick: 'onclick',
28
106
  onPointerDown: 'onpointerdown',
@@ -80,7 +158,7 @@ function setNestedValue(target, path, value) {
80
158
  function applyProps(instance, newProps, oldProps = {}) {
81
159
  // Remove old props not in newProps
82
160
  for (const key in oldProps) {
83
- if (RESERVED.has(key) || key in newProps)
161
+ if (RESERVED.has(key) || FLEX_ITEM_PROPS$1.has(key) || key in newProps)
84
162
  continue;
85
163
  const pixiEvent = REACT_TO_PIXI_EVENTS[key];
86
164
  if (pixiEvent) {
@@ -99,7 +177,7 @@ function applyProps(instance, newProps, oldProps = {}) {
99
177
  }
100
178
  // Apply new props
101
179
  for (const key in newProps) {
102
- if (RESERVED.has(key))
180
+ if (RESERVED.has(key) || FLEX_ITEM_PROPS$1.has(key))
103
181
  continue;
104
182
  const value = newProps[key];
105
183
  const pixiEvent = REACT_TO_PIXI_EVENTS[key];
@@ -125,6 +203,452 @@ function applyProps(instance, newProps, oldProps = {}) {
125
203
  }
126
204
  }
127
205
 
206
+ // ─── Helpers ─────────────────────────────────────────────
207
+ function normalizePadding(p) {
208
+ return typeof p === 'number' ? [p, p, p, p] : p;
209
+ }
210
+ /** Measure a child's size and bounds offset for layout purposes */
211
+ function measureChild(child) {
212
+ const cfg = child._flexConfig;
213
+ if (cfg?.layoutWidth !== undefined && cfg?.layoutHeight !== undefined) {
214
+ return { w: cfg.layoutWidth, h: cfg.layoutHeight, ox: 0, oy: 0 };
215
+ }
216
+ // For FlexContainers, use their explicit size if set
217
+ if (child instanceof FlexContainer) {
218
+ const fc = child;
219
+ if (fc._explicitWidth > 0 && fc._explicitHeight > 0) {
220
+ return { w: fc._explicitWidth, h: fc._explicitHeight, ox: 0, oy: 0 };
221
+ }
222
+ }
223
+ // Use localBounds to get the true visual extent and origin offset.
224
+ // This handles children with non-zero anchors (e.g. Button, Label with centered text).
225
+ const bounds = child.getLocalBounds();
226
+ const w = cfg?.layoutWidth ?? bounds.width;
227
+ const h = cfg?.layoutHeight ?? bounds.height;
228
+ return { w, h, ox: bounds.x, oy: bounds.y };
229
+ }
230
+ function layoutLine(items, isRow, mainSize, justify, align, gap, crossOffset, crossSize) {
231
+ if (items.length === 0)
232
+ return;
233
+ // Compute total fixed main size and flex grow total
234
+ let totalFixed = 0;
235
+ let totalGrow = 0;
236
+ for (const item of items) {
237
+ const grow = item.child._flexConfig?.flexGrow ?? 0;
238
+ if (grow > 0) {
239
+ totalGrow += grow;
240
+ }
241
+ else {
242
+ totalFixed += isRow ? item.w : item.h;
243
+ }
244
+ }
245
+ const totalGap = gap * (items.length - 1);
246
+ const availableForFlex = Math.max(0, mainSize - totalFixed - totalGap);
247
+ // Resolve flex sizes
248
+ if (totalGrow > 0) {
249
+ for (const item of items) {
250
+ const grow = item.child._flexConfig?.flexGrow ?? 0;
251
+ if (grow > 0) {
252
+ const flexSize = (grow / totalGrow) * availableForFlex;
253
+ if (isRow) {
254
+ item.w = flexSize;
255
+ item.child.width = flexSize;
256
+ }
257
+ else {
258
+ item.h = flexSize;
259
+ item.child.height = flexSize;
260
+ }
261
+ }
262
+ }
263
+ }
264
+ // Shrink: if content overflows and mainSize is finite, shrink eligible items
265
+ if (totalGrow === 0 && mainSize > 0) {
266
+ const overflow = totalFixed + totalGap - mainSize;
267
+ if (overflow > 0) {
268
+ let totalShrinkable = 0;
269
+ for (const item of items) {
270
+ const shrink = item.child._flexConfig?.flexShrink ?? 1;
271
+ if (shrink > 0) {
272
+ totalShrinkable += isRow ? item.w : item.h;
273
+ }
274
+ }
275
+ if (totalShrinkable > 0) {
276
+ for (const item of items) {
277
+ const shrink = item.child._flexConfig?.flexShrink ?? 1;
278
+ if (shrink > 0) {
279
+ const itemMain = isRow ? item.w : item.h;
280
+ const reduction = overflow * (itemMain / totalShrinkable);
281
+ const newSize = Math.max(0, itemMain - reduction);
282
+ if (isRow) {
283
+ item.w = newSize;
284
+ item.child.width = newSize;
285
+ }
286
+ else {
287
+ item.h = newSize;
288
+ item.child.height = newSize;
289
+ }
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+ // Calculate total main size after flex
296
+ let totalMain = totalGap;
297
+ for (const item of items) {
298
+ totalMain += isRow ? item.w : item.h;
299
+ }
300
+ // Justify: compute starting offset and extra spacing
301
+ let mainOffset = 0;
302
+ let extraGap = 0;
303
+ switch (justify) {
304
+ case 'start':
305
+ break;
306
+ case 'center':
307
+ mainOffset = Math.max(0, (mainSize - totalMain) / 2);
308
+ break;
309
+ case 'end':
310
+ mainOffset = Math.max(0, mainSize - totalMain);
311
+ break;
312
+ case 'space-between':
313
+ if (items.length > 1) {
314
+ extraGap = Math.max(0, (mainSize - totalMain + totalGap) / (items.length - 1)) - gap;
315
+ }
316
+ break;
317
+ case 'space-around':
318
+ if (items.length > 0) {
319
+ const totalSpace = Math.max(0, mainSize - totalMain + totalGap);
320
+ const segment = totalSpace / items.length;
321
+ mainOffset = segment / 2;
322
+ extraGap = segment - gap;
323
+ }
324
+ break;
325
+ }
326
+ // Position each item
327
+ let pos = mainOffset;
328
+ for (const item of items) {
329
+ const mainDim = isRow ? item.w : item.h;
330
+ const crossDim = isRow ? item.h : item.w;
331
+ // Cross-axis alignment (alignSelf overrides align)
332
+ const effectiveAlign = (item.child._flexConfig?.alignSelf && item.child._flexConfig.alignSelf !== 'auto')
333
+ ? item.child._flexConfig.alignSelf
334
+ : align;
335
+ let crossPos = crossOffset;
336
+ switch (effectiveAlign) {
337
+ case 'start':
338
+ break;
339
+ case 'center':
340
+ crossPos += (crossSize - crossDim) / 2;
341
+ break;
342
+ case 'end':
343
+ crossPos += crossSize - crossDim;
344
+ break;
345
+ case 'stretch':
346
+ if (isRow) {
347
+ item.child.height = crossSize;
348
+ }
349
+ else {
350
+ item.child.width = crossSize;
351
+ }
352
+ break;
353
+ }
354
+ // Compensate for local bounds offset (e.g. centered anchors)
355
+ if (isRow) {
356
+ item.child.x = pos - item.ox;
357
+ item.child.y = crossPos - item.oy;
358
+ }
359
+ else {
360
+ item.child.x = crossPos - item.ox;
361
+ item.child.y = pos - item.oy;
362
+ }
363
+ pos += mainDim + gap + extraGap;
364
+ }
365
+ }
366
+ // ─── FlexContainer ───────────────────────────────────────
367
+ /**
368
+ * Lightweight flexbox-like layout container for PixiJS.
369
+ *
370
+ * Supports row/column direction, justify/align, gap, padding, wrapping,
371
+ * and flex-grow distribution. Zero external dependencies.
372
+ *
373
+ * @example
374
+ * ```ts
375
+ * const toolbar = new FlexContainer({
376
+ * direction: 'row',
377
+ * justifyContent: 'space-between',
378
+ * alignItems: 'center',
379
+ * gap: 16,
380
+ * padding: 12,
381
+ * });
382
+ *
383
+ * toolbar.addFlexChild(button1);
384
+ * toolbar.addFlexChild(button2);
385
+ * toolbar.resize(800, 60);
386
+ * ```
387
+ */
388
+ class FlexContainer extends pixi_js.Container {
389
+ __uiComponent = true;
390
+ _config;
391
+ _padding;
392
+ _maxWidth;
393
+ _maxHeight;
394
+ /** @internal */ _explicitWidth;
395
+ /** @internal */ _explicitHeight;
396
+ _layoutChildren = [];
397
+ _layoutDirty = true;
398
+ constructor(config = {}) {
399
+ super();
400
+ this._config = {
401
+ direction: config.direction ?? 'row',
402
+ justifyContent: config.justifyContent ?? 'start',
403
+ alignItems: config.alignItems ?? 'start',
404
+ gap: config.gap ?? 0,
405
+ flexWrap: config.flexWrap ?? false,
406
+ };
407
+ this._padding = normalizePadding(config.padding ?? 0);
408
+ this._maxWidth = config.maxWidth ?? Infinity;
409
+ this._maxHeight = config.maxHeight ?? Infinity;
410
+ this._explicitWidth = config.width ?? 0;
411
+ this._explicitHeight = config.height ?? 0;
412
+ }
413
+ // ─── Public API ──────────────────────────────────────
414
+ /** Add a child with optional flex config. Also registers in flex layout. */
415
+ addFlexChild(child, flexConfig) {
416
+ if (flexConfig)
417
+ child._flexConfig = flexConfig;
418
+ if (!this._layoutChildren.includes(child)) {
419
+ this._layoutChildren.push(child);
420
+ this._layoutDirty = true;
421
+ }
422
+ super.addChild(child);
423
+ return this;
424
+ }
425
+ /** Remove a child from flex layout and display list */
426
+ removeFlexChild(child) {
427
+ const idx = this._layoutChildren.indexOf(child);
428
+ if (idx !== -1) {
429
+ this._layoutChildren.splice(idx, 1);
430
+ this._layoutDirty = true;
431
+ }
432
+ super.removeChild(child);
433
+ return this;
434
+ }
435
+ /** Remove all flex children */
436
+ clearFlexChildren() {
437
+ for (const child of this._layoutChildren) {
438
+ super.removeChild(child);
439
+ }
440
+ this._layoutChildren.length = 0;
441
+ this._layoutDirty = true;
442
+ return this;
443
+ }
444
+ /**
445
+ * Override addChild so children automatically participate in flex layout.
446
+ * This enables declarative usage from React JSX.
447
+ */
448
+ addChild(...children) {
449
+ for (const child of children) {
450
+ if (!this._layoutChildren.includes(child)) {
451
+ this._layoutChildren.push(child);
452
+ this._layoutDirty = true;
453
+ }
454
+ }
455
+ const result = super.addChild(...children);
456
+ if (this._layoutDirty)
457
+ this.updateLayout();
458
+ return result;
459
+ }
460
+ removeChild(...children) {
461
+ for (const child of children) {
462
+ const idx = this._layoutChildren.indexOf(child);
463
+ if (idx !== -1) {
464
+ this._layoutChildren.splice(idx, 1);
465
+ this._layoutDirty = true;
466
+ }
467
+ }
468
+ return super.removeChild(...children);
469
+ }
470
+ /** Get all flex layout children (read-only) */
471
+ get flexChildren() {
472
+ return this._layoutChildren;
473
+ }
474
+ /** Update the container size and recalculate layout */
475
+ resize(width, height) {
476
+ this._explicitWidth = width;
477
+ this._explicitHeight = height;
478
+ this._layoutDirty = true;
479
+ this.updateLayout();
480
+ }
481
+ /** Update layout direction */
482
+ setDirection(direction) {
483
+ this._config.direction = direction;
484
+ this._layoutDirty = true;
485
+ }
486
+ /** Update justifyContent */
487
+ setJustifyContent(justify) {
488
+ this._config.justifyContent = justify;
489
+ this._layoutDirty = true;
490
+ }
491
+ /** Update alignItems */
492
+ setAlignItems(align) {
493
+ this._config.alignItems = align;
494
+ this._layoutDirty = true;
495
+ }
496
+ /** Update gap */
497
+ setGap(gap) {
498
+ this._config.gap = gap;
499
+ this._layoutDirty = true;
500
+ }
501
+ /** Update padding */
502
+ setPadding(padding) {
503
+ this._padding = normalizePadding(padding);
504
+ this._layoutDirty = true;
505
+ }
506
+ /**
507
+ * Recalculate and apply layout positions for all children.
508
+ * Called automatically by `resize()`. Call manually after
509
+ * adding/removing children without resize.
510
+ */
511
+ updateLayout() {
512
+ this._layoutDirty = false;
513
+ const { direction, justifyContent, alignItems, gap, flexWrap } = this._config;
514
+ const [pt, pr, pb, pl] = this._padding;
515
+ const isRow = direction === 'row';
516
+ const contentW = this._explicitWidth > 0 ? this._explicitWidth - pl - pr : Infinity;
517
+ const contentH = this._explicitHeight > 0 ? this._explicitHeight - pt - pb : Infinity;
518
+ const mainLimit = isRow ? contentW : contentH;
519
+ const crossLimit = isRow ? contentH : contentW;
520
+ // Measure children (skip flexExclude — they position themselves)
521
+ const measured = [];
522
+ for (const child of this._layoutChildren) {
523
+ if (child._flexConfig?.flexExclude)
524
+ continue;
525
+ const { w, h, ox, oy } = measureChild(child);
526
+ measured.push({ child, w, h, ox, oy });
527
+ }
528
+ // Split into lines (if wrapping)
529
+ const lines = [];
530
+ if (flexWrap && mainLimit < Infinity) {
531
+ let currentLine = [];
532
+ let lineMain = 0;
533
+ for (const item of measured) {
534
+ const itemMain = isRow ? item.w : item.h;
535
+ const wouldBe = lineMain + (currentLine.length > 0 ? gap : 0) + itemMain;
536
+ if (currentLine.length > 0 && wouldBe > mainLimit) {
537
+ lines.push(currentLine);
538
+ currentLine = [item];
539
+ lineMain = itemMain;
540
+ }
541
+ else {
542
+ currentLine.push(item);
543
+ lineMain = wouldBe;
544
+ }
545
+ }
546
+ if (currentLine.length > 0)
547
+ lines.push(currentLine);
548
+ }
549
+ else {
550
+ lines.push(measured);
551
+ }
552
+ // Compute cross size per line
553
+ const lineCrossSizes = lines.map((line) => {
554
+ let maxCross = 0;
555
+ for (const item of line) {
556
+ const cross = isRow ? item.h : item.w;
557
+ if (cross > maxCross)
558
+ maxCross = cross;
559
+ }
560
+ return maxCross;
561
+ });
562
+ // Layout each line
563
+ let crossOffset = isRow ? pt : pl;
564
+ for (let i = 0; i < lines.length; i++) {
565
+ const line = lines[i];
566
+ const lineCross = lineCrossSizes[i];
567
+ const mainStart = isRow ? pl : pt;
568
+ // Offset items by padding
569
+ const tempItems = line.map((item) => ({ ...item }));
570
+ // For single-line layouts, use the full available cross space for alignment;
571
+ // for multi-line (wrapping), each line gets its own measured cross size.
572
+ const effectiveCross = lines.length === 1 && crossLimit < Infinity
573
+ ? crossLimit
574
+ : (crossLimit < Infinity ? Math.min(lineCross, crossLimit) : lineCross);
575
+ layoutLine(tempItems, isRow, mainLimit < Infinity ? mainLimit : 0, mainLimit < Infinity ? justifyContent : 'start', alignItems, gap, crossOffset, effectiveCross);
576
+ // Apply main-axis padding offset
577
+ for (const item of tempItems) {
578
+ const origChild = line.find((l) => l.child === item.child);
579
+ origChild.child.x = item.child.x + (isRow ? mainStart : 0);
580
+ origChild.child.y = item.child.y + (isRow ? 0 : mainStart);
581
+ }
582
+ crossOffset += lineCross + gap;
583
+ }
584
+ }
585
+ /** Computed content size (after layout) */
586
+ getContentSize() {
587
+ if (this._layoutDirty)
588
+ this.updateLayout();
589
+ let maxX = 0;
590
+ let maxY = 0;
591
+ for (const child of this._layoutChildren) {
592
+ const { w, h } = measureChild(child);
593
+ maxX = Math.max(maxX, child.x + w);
594
+ maxY = Math.max(maxY, child.y + h);
595
+ }
596
+ const [, pr, pb] = this._padding;
597
+ return { width: maxX + pr, height: maxY + pb };
598
+ }
599
+ /** React reconciler update hook — applies changed config props */
600
+ updateConfig(changed) {
601
+ if ('direction' in changed)
602
+ this.setDirection(changed.direction);
603
+ if ('justifyContent' in changed)
604
+ this.setJustifyContent(changed.justifyContent);
605
+ if ('alignItems' in changed)
606
+ this.setAlignItems(changed.alignItems);
607
+ if ('gap' in changed)
608
+ this.setGap(changed.gap);
609
+ if ('padding' in changed)
610
+ this.setPadding(changed.padding);
611
+ if ('flexWrap' in changed) {
612
+ this._config.flexWrap = changed.flexWrap;
613
+ this._layoutDirty = true;
614
+ }
615
+ if ('width' in changed || 'height' in changed) {
616
+ this.resize(changed.width ?? this._explicitWidth, changed.height ?? this._explicitHeight);
617
+ return; // resize calls updateLayout
618
+ }
619
+ if (this._layoutDirty)
620
+ this.updateLayout();
621
+ }
622
+ destroy(options) {
623
+ this._layoutChildren.length = 0;
624
+ super.destroy(options);
625
+ }
626
+ }
627
+
628
+ /** Flex item prop names that should be forwarded to _flexConfig on the child */
629
+ const FLEX_ITEM_PROPS = ['flexGrow', 'flexShrink', 'layoutWidth', 'layoutHeight', 'alignSelf', 'flexExclude'];
630
+ /** Extract FlexItemConfig from props if any flex item props are present */
631
+ function extractFlexItemConfig(props) {
632
+ let config;
633
+ for (const key of FLEX_ITEM_PROPS) {
634
+ if (key in props) {
635
+ if (!config)
636
+ config = {};
637
+ config[key] = props[key];
638
+ }
639
+ }
640
+ return config;
641
+ }
642
+ /** Apply flex item config to a child being added to a FlexContainer */
643
+ function addChildToFlex(parent, child) {
644
+ const flexConfig = child._flexConfig;
645
+ if (flexConfig && Object.keys(flexConfig).length > 0) {
646
+ parent.addFlexChild(child, flexConfig);
647
+ }
648
+ else {
649
+ parent.addChild(child);
650
+ }
651
+ }
128
652
  function toPascalCase(str) {
129
653
  return str.charAt(0).toUpperCase() + str.slice(1);
130
654
  }
@@ -140,24 +664,50 @@ const hostConfig = {
140
664
  throw new Error(`[PixiReconciler] Unknown element "<${type}>". ` +
141
665
  `Call extend({ ${name} }) before rendering.`);
142
666
  }
143
- const instance = new Ctor();
144
- applyProps(instance, props);
145
- // Enable interactivity if any event prop is present
667
+ let instance;
668
+ if (Ctor.prototype.__uiComponent) {
669
+ // Config-based UI component: pass props as constructor config
670
+ const config = extractConfig(props);
671
+ instance = new Ctor(config);
672
+ applyEventProps(instance, props);
673
+ }
674
+ else {
675
+ // Standard PixiJS element
676
+ instance = new Ctor();
677
+ applyProps(instance, props);
678
+ }
146
679
  if (hasEventProps(props) && instance.eventMode === 'auto') {
147
680
  instance.eventMode = 'static';
148
681
  }
682
+ // Store flex item config for when this child is added to a FlexContainer parent
683
+ const flexItemConfig = extractFlexItemConfig(props);
684
+ if (flexItemConfig) {
685
+ instance._flexConfig = { ...instance._flexConfig, ...flexItemConfig };
686
+ }
149
687
  return instance;
150
688
  },
151
689
  createTextInstance() {
152
690
  throw new Error('[PixiReconciler] Text strings are not supported. Use a <text> element.');
153
691
  },
154
692
  appendInitialChild(parent, child) {
155
- if (child instanceof pixi_js.Container)
156
- parent.addChild(child);
693
+ if (child instanceof pixi_js.Container) {
694
+ if (parent instanceof FlexContainer) {
695
+ addChildToFlex(parent, child);
696
+ }
697
+ else {
698
+ parent.addChild(child);
699
+ }
700
+ }
157
701
  },
158
702
  appendChild(parent, child) {
159
- if (child instanceof pixi_js.Container)
160
- parent.addChild(child);
703
+ if (child instanceof pixi_js.Container) {
704
+ if (parent instanceof FlexContainer) {
705
+ addChildToFlex(parent, child);
706
+ }
707
+ else {
708
+ parent.addChild(child);
709
+ }
710
+ }
161
711
  },
162
712
  appendChildToContainer(container, child) {
163
713
  if (child instanceof pixi_js.Container)
@@ -192,7 +742,26 @@ const hostConfig = {
192
742
  }
193
743
  },
194
744
  commitUpdate(instance, _updatePayload, _type, oldProps, newProps) {
195
- applyProps(instance, newProps, oldProps);
745
+ if (instance.__uiComponent && typeof instance.updateConfig === 'function') {
746
+ const changed = diffConfig(newProps, oldProps);
747
+ if (Object.keys(changed).length > 0) {
748
+ instance.updateConfig(changed);
749
+ }
750
+ applyEventProps(instance, newProps, oldProps);
751
+ }
752
+ else {
753
+ applyProps(instance, newProps, oldProps);
754
+ }
755
+ // Update flex item config if parent is FlexContainer
756
+ const newFlexConfig = extractFlexItemConfig(newProps);
757
+ const oldFlexConfig = extractFlexItemConfig(oldProps);
758
+ if (newFlexConfig || oldFlexConfig) {
759
+ instance._flexConfig = { ...instance._flexConfig, ...newFlexConfig };
760
+ // Trigger parent relayout
761
+ if (instance.parent instanceof FlexContainer) {
762
+ instance.parent.updateLayout();
763
+ }
764
+ }
196
765
  if (hasEventProps(newProps) && instance.eventMode === 'auto') {
197
766
  instance.eventMode = 'static';
198
767
  }
@@ -263,37 +832,2280 @@ function createPixiRoot(container) {
263
832
  }
264
833
 
265
834
  /**
266
- * Register all standard PixiJS display objects for JSX use.
267
- * Call once at app startup before rendering any React scenes.
835
+ * Resolve a ViewInput to a Container instance.
836
+ *
837
+ * @example
838
+ * ```ts
839
+ * resolveView('btn-idle') // → Sprite.from('btn-idle')
840
+ * resolveView(someTexture) // → new Sprite(someTexture)
841
+ * resolveView(myCustomContainer) // → myCustomContainer (as-is)
842
+ * resolveView(undefined) // → null
843
+ * ```
268
844
  */
269
- function extendPixiElements() {
270
- extend({
271
- Container: pixi_js.Container,
272
- Sprite: pixi_js.Sprite,
273
- Graphics: pixi_js.Graphics,
274
- Text: pixi_js.Text,
275
- AnimatedSprite: pixi_js.AnimatedSprite,
276
- NineSliceSprite: pixi_js.NineSliceSprite,
277
- TilingSprite: pixi_js.TilingSprite,
278
- Mesh: pixi_js.Mesh,
279
- MeshPlane: pixi_js.MeshPlane,
280
- MeshRope: pixi_js.MeshRope,
281
- MeshSimple: pixi_js.MeshSimple,
282
- BitmapText: pixi_js.BitmapText,
283
- HTMLText: pixi_js.HTMLText,
284
- });
845
+ function resolveView(input) {
846
+ if (input == null)
847
+ return null;
848
+ if (typeof input === 'string')
849
+ return pixi_js.Sprite.from(input);
850
+ if (input instanceof pixi_js.Texture)
851
+ return new pixi_js.Sprite(input);
852
+ return input;
853
+ }
854
+
855
+ /**
856
+ * Collection of easing functions for use with Tween and Timeline.
857
+ *
858
+ * All functions take a progress value t (0..1) and return the eased value.
859
+ */
860
+ const Easing = {
861
+ easeOutQuad: (t) => t * (2 - t),
862
+ easeInCubic: (t) => t * t * t,
863
+ easeOutCubic: (t) => --t * t * t + 1,
864
+ easeOutBack: (t) => {
865
+ const c1 = 1.70158;
866
+ const c3 = c1 + 1;
867
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
868
+ }};
869
+
870
+ /**
871
+ * Lightweight tween system integrated with PixiJS Ticker.
872
+ * Zero external dependencies — no GSAP required.
873
+ *
874
+ * All tweens return a Promise that resolves on completion.
875
+ *
876
+ * @example
877
+ * ```ts
878
+ * // Fade in a sprite
879
+ * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
880
+ *
881
+ * // Move and wait
882
+ * await Tween.to(sprite, { x: 500 }, 300);
883
+ *
884
+ * // From a starting value
885
+ * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
886
+ * ```
887
+ */
888
+ class Tween {
889
+ static _tweens = [];
890
+ static _tickerAdded = false;
891
+ /**
892
+ * Animate properties from current values to target values.
893
+ *
894
+ * @param target - Object to animate (Sprite, Container, etc.)
895
+ * @param props - Target property values
896
+ * @param duration - Duration in milliseconds
897
+ * @param easing - Easing function (default: easeOutQuad)
898
+ * @param onUpdate - Progress callback (0..1)
899
+ */
900
+ static to(target, props, duration, easing, onUpdate) {
901
+ return new Promise((resolve) => {
902
+ // Capture starting values
903
+ const from = {};
904
+ for (const key of Object.keys(props)) {
905
+ from[key] = Tween.getProperty(target, key);
906
+ }
907
+ const tween = {
908
+ target,
909
+ from,
910
+ to: { ...props },
911
+ duration: Math.max(1, duration),
912
+ easing: easing ?? Easing.easeOutQuad,
913
+ elapsed: 0,
914
+ delay: 0,
915
+ resolve,
916
+ onUpdate,
917
+ };
918
+ Tween._tweens.push(tween);
919
+ Tween.ensureTicker();
920
+ });
921
+ }
922
+ /**
923
+ * Animate properties from given values to current values.
924
+ */
925
+ static from(target, props, duration, easing, onUpdate) {
926
+ // Capture current values as "to"
927
+ const to = {};
928
+ for (const key of Object.keys(props)) {
929
+ to[key] = Tween.getProperty(target, key);
930
+ Tween.setProperty(target, key, props[key]);
931
+ }
932
+ return Tween.to(target, to, duration, easing, onUpdate);
933
+ }
934
+ /**
935
+ * Animate from one set of values to another.
936
+ */
937
+ static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
938
+ // Set starting values
939
+ for (const key of Object.keys(fromProps)) {
940
+ Tween.setProperty(target, key, fromProps[key]);
941
+ }
942
+ return Tween.to(target, toProps, duration, easing, onUpdate);
943
+ }
944
+ /**
945
+ * Wait for a given duration (useful in timelines).
946
+ * Uses PixiJS Ticker for consistent timing with other tweens.
947
+ */
948
+ static delay(ms) {
949
+ return new Promise((resolve) => {
950
+ let elapsed = 0;
951
+ const onTick = (ticker) => {
952
+ elapsed += ticker.deltaMS;
953
+ if (elapsed >= ms) {
954
+ pixi_js.Ticker.shared.remove(onTick);
955
+ resolve();
956
+ }
957
+ };
958
+ pixi_js.Ticker.shared.add(onTick);
959
+ });
960
+ }
961
+ /**
962
+ * Kill all tweens on a target.
963
+ */
964
+ static killTweensOf(target) {
965
+ Tween._tweens = Tween._tweens.filter((tw) => {
966
+ if (tw.target === target) {
967
+ tw.resolve();
968
+ return false;
969
+ }
970
+ return true;
971
+ });
972
+ }
973
+ /**
974
+ * Kill all active tweens.
975
+ */
976
+ static killAll() {
977
+ for (const tw of Tween._tweens) {
978
+ tw.resolve();
979
+ }
980
+ Tween._tweens.length = 0;
981
+ }
982
+ /** Number of active tweens */
983
+ static get activeTweens() {
984
+ return Tween._tweens.length;
985
+ }
986
+ /**
987
+ * Reset the tween system — kill all tweens and remove the ticker.
988
+ * Useful for cleanup between game instances, tests, or hot-reload.
989
+ */
990
+ static reset() {
991
+ for (const tw of Tween._tweens) {
992
+ tw.resolve();
993
+ }
994
+ Tween._tweens.length = 0;
995
+ if (Tween._tickerAdded) {
996
+ pixi_js.Ticker.shared.remove(Tween.tick);
997
+ Tween._tickerAdded = false;
998
+ }
999
+ }
1000
+ // ─── Internal ──────────────────────────────────────────
1001
+ static ensureTicker() {
1002
+ if (Tween._tickerAdded)
1003
+ return;
1004
+ Tween._tickerAdded = true;
1005
+ pixi_js.Ticker.shared.add(Tween.tick);
1006
+ }
1007
+ static tick = (ticker) => {
1008
+ const dt = ticker.deltaMS;
1009
+ const completed = [];
1010
+ for (const tw of Tween._tweens) {
1011
+ tw.elapsed += dt;
1012
+ if (tw.elapsed < tw.delay)
1013
+ continue;
1014
+ const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
1015
+ const t = tw.easing(raw);
1016
+ // Interpolate each property
1017
+ for (const key of Object.keys(tw.to)) {
1018
+ const start = tw.from[key];
1019
+ const end = tw.to[key];
1020
+ const value = start + (end - start) * t;
1021
+ Tween.setProperty(tw.target, key, value);
1022
+ }
1023
+ tw.onUpdate?.(raw);
1024
+ if (raw >= 1) {
1025
+ completed.push(tw);
1026
+ }
1027
+ }
1028
+ // Remove completed tweens
1029
+ for (const tw of completed) {
1030
+ const idx = Tween._tweens.indexOf(tw);
1031
+ if (idx !== -1)
1032
+ Tween._tweens.splice(idx, 1);
1033
+ tw.resolve();
1034
+ }
1035
+ // Remove ticker when no active tweens
1036
+ if (Tween._tweens.length === 0 && Tween._tickerAdded) {
1037
+ pixi_js.Ticker.shared.remove(Tween.tick);
1038
+ Tween._tickerAdded = false;
1039
+ }
1040
+ };
1041
+ /**
1042
+ * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
1043
+ */
1044
+ static getProperty(target, key) {
1045
+ const parts = key.split('.');
1046
+ let obj = target;
1047
+ for (let i = 0; i < parts.length - 1; i++) {
1048
+ obj = obj[parts[i]];
1049
+ }
1050
+ return obj[parts[parts.length - 1]] ?? 0;
1051
+ }
1052
+ /**
1053
+ * Set a potentially nested property.
1054
+ */
1055
+ static setProperty(target, key, value) {
1056
+ const parts = key.split('.');
1057
+ let obj = target;
1058
+ for (let i = 0; i < parts.length - 1; i++) {
1059
+ obj = obj[parts[i]];
1060
+ }
1061
+ obj[parts[parts.length - 1]] = value;
1062
+ }
1063
+ }
1064
+
1065
+ const DEFAULT_COLORS = {
1066
+ default: 0xffd700,
1067
+ hover: 0xffe44d,
1068
+ pressed: 0xccac00,
1069
+ disabled: 0x666666,
1070
+ };
1071
+ function makeGraphicsView(w, h, radius, color) {
1072
+ const g = new pixi_js.Graphics();
1073
+ g.roundRect(-w / 2, -h / 2, w, h, radius).fill(color);
1074
+ return g;
1075
+ }
1076
+ /**
1077
+ * Interactive button with per-state custom views and animations.
1078
+ *
1079
+ * Each visual state accepts a `ViewInput`: texture name, Texture, or any Container
1080
+ * (Sprite, NineSliceSprite, AnimatedSprite, custom artwork, etc).
1081
+ * Falls back to colored Graphics when no custom view is provided.
1082
+ *
1083
+ * @example
1084
+ * ```ts
1085
+ * // Graphics-based (quick prototyping)
1086
+ * const btn = new Button({
1087
+ * width: 200, height: 60, borderRadius: 12,
1088
+ * colors: { default: 0x22aa22, hover: 0x33cc33 },
1089
+ * text: 'SPIN',
1090
+ * onPress: () => spin(),
1091
+ * });
1092
+ *
1093
+ * // Asset-based (production art)
1094
+ * const btn = new Button({
1095
+ * defaultView: 'btn-idle',
1096
+ * hoverView: 'btn-hover',
1097
+ * pressedView: 'btn-pressed',
1098
+ * disabledView: 'btn-disabled',
1099
+ * text: 'SPIN',
1100
+ * onPress: () => spin(),
1101
+ * });
1102
+ *
1103
+ * // Custom Container view
1104
+ * const btn = new Button({
1105
+ * defaultView: myAnimatedSprite,
1106
+ * text: 'SPIN',
1107
+ * });
1108
+ * ```
1109
+ */
1110
+ class Button extends pixi_js.Container {
1111
+ __uiComponent = true;
1112
+ _views = new Map();
1113
+ _state = 'default';
1114
+ _enabled = true;
1115
+ _config;
1116
+ _textObj = null;
1117
+ /** Press callback */
1118
+ onPress;
1119
+ constructor(config = {}) {
1120
+ super();
1121
+ this._config = {
1122
+ width: config.width ?? 200,
1123
+ height: config.height ?? 60,
1124
+ borderRadius: config.borderRadius ?? 8,
1125
+ pressScale: config.pressScale ?? 0.95,
1126
+ animationDuration: config.animationDuration ?? 100,
1127
+ ...config,
1128
+ };
1129
+ this.onPress = config.onPress;
1130
+ this._buildViews(config);
1131
+ // Text
1132
+ if (config.text) {
1133
+ this._textObj = new pixi_js.Text({
1134
+ text: config.text,
1135
+ style: {
1136
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
1137
+ fontSize: 20,
1138
+ fill: 0xffffff,
1139
+ fontWeight: 'bold',
1140
+ ...config.textStyle,
1141
+ },
1142
+ });
1143
+ this._textObj.anchor.set(0.5);
1144
+ this.addChild(this._textObj);
1145
+ }
1146
+ // Interaction
1147
+ this.eventMode = 'static';
1148
+ this.cursor = 'pointer';
1149
+ this.on('pointerover', this._onPointerOver, this);
1150
+ this.on('pointerout', this._onPointerOut, this);
1151
+ this.on('pointerdown', this._onPointerDown, this);
1152
+ this.on('pointerup', this._onPointerUp, this);
1153
+ this.on('pointerupoutside', this._onPointerUpOutside, this);
1154
+ if (config.disabled) {
1155
+ this.enabled = false;
1156
+ }
1157
+ }
1158
+ /** Current button state */
1159
+ get state() {
1160
+ return this._state;
1161
+ }
1162
+ /** Enable the button */
1163
+ enable() {
1164
+ this.enabled = true;
1165
+ }
1166
+ /** Disable the button */
1167
+ disable() {
1168
+ this.enabled = false;
1169
+ }
1170
+ /** Whether the button is enabled */
1171
+ get enabled() {
1172
+ return this._enabled;
1173
+ }
1174
+ set enabled(value) {
1175
+ this._enabled = value;
1176
+ this.cursor = value ? 'pointer' : 'default';
1177
+ this.eventMode = value ? 'static' : 'none';
1178
+ this._setState(value ? 'default' : 'disabled');
1179
+ }
1180
+ /** Whether the button is disabled */
1181
+ get disabled() {
1182
+ return !this._enabled;
1183
+ }
1184
+ /** Update button text */
1185
+ set text(value) {
1186
+ if (this._textObj) {
1187
+ this._textObj.text = value;
1188
+ }
1189
+ }
1190
+ // ─── View building ──────────────────────────────────
1191
+ _buildViews(config) {
1192
+ const colorMap = { ...DEFAULT_COLORS, ...config.colors };
1193
+ const { width, height, borderRadius } = this._config;
1194
+ const stateViews = {
1195
+ default: config.defaultView,
1196
+ hover: config.hoverView,
1197
+ pressed: config.pressedView,
1198
+ disabled: config.disabledView,
1199
+ };
1200
+ const states = ['default', 'hover', 'pressed', 'disabled'];
1201
+ for (const state of states) {
1202
+ const customView = resolveView(stateViews[state]);
1203
+ const view = customView ?? makeGraphicsView(width, height, borderRadius, colorMap[state]);
1204
+ view.visible = state === 'default';
1205
+ this._views.set(state, view);
1206
+ this.addChild(view);
1207
+ }
1208
+ }
1209
+ _rebuildViews() {
1210
+ for (const [, view] of this._views) {
1211
+ this.removeChild(view);
1212
+ view.destroy();
1213
+ }
1214
+ this._views.clear();
1215
+ this._buildViews(this._config);
1216
+ // Re-insert views before text
1217
+ if (this._textObj && this._textObj.parent === this) {
1218
+ this.setChildIndex(this._textObj, this.children.length - 1);
1219
+ }
1220
+ }
1221
+ // ─── State management ───────────────────────────────
1222
+ _setState(state) {
1223
+ if (this._state === state)
1224
+ return;
1225
+ this._state = state;
1226
+ for (const [s, view] of this._views) {
1227
+ view.visible = s === state;
1228
+ }
1229
+ }
1230
+ _onPointerOver() {
1231
+ if (!this._enabled)
1232
+ return;
1233
+ this._setState('hover');
1234
+ Tween.killTweensOf(this);
1235
+ Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
1236
+ }
1237
+ _onPointerOut() {
1238
+ if (!this._enabled)
1239
+ return;
1240
+ this._setState('default');
1241
+ Tween.killTweensOf(this);
1242
+ Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
1243
+ }
1244
+ _onPointerDown() {
1245
+ if (!this._enabled)
1246
+ return;
1247
+ this._setState('pressed');
1248
+ Tween.killTweensOf(this);
1249
+ const s = this._config.pressScale;
1250
+ Tween.to(this, { 'scale.x': s, 'scale.y': s }, this._config.animationDuration, Easing.easeOutQuad);
1251
+ }
1252
+ _onPointerUp() {
1253
+ if (!this._enabled)
1254
+ return;
1255
+ this._setState('hover');
1256
+ Tween.killTweensOf(this);
1257
+ Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
1258
+ this.onPress?.();
1259
+ }
1260
+ _onPointerUpOutside() {
1261
+ if (!this._enabled)
1262
+ return;
1263
+ this._setState('default');
1264
+ Tween.killTweensOf(this);
1265
+ Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
1266
+ }
1267
+ /** React reconciler update hook */
1268
+ updateConfig(changed) {
1269
+ if ('text' in changed && this._textObj)
1270
+ this._textObj.text = changed.text;
1271
+ if ('disabled' in changed)
1272
+ this.enabled = !changed.disabled;
1273
+ if ('onPress' in changed)
1274
+ this.onPress = changed.onPress;
1275
+ const structural = [
1276
+ 'colors', 'width', 'height', 'borderRadius', 'textStyle',
1277
+ 'defaultView', 'hoverView', 'pressedView', 'disabledView',
1278
+ ];
1279
+ const needsRebuild = structural.some((k) => k in changed);
1280
+ if (needsRebuild) {
1281
+ Object.assign(this._config, changed);
1282
+ this._rebuildViews();
1283
+ }
1284
+ }
1285
+ destroy(options) {
1286
+ Tween.killTweensOf(this);
1287
+ this.off('pointerover', this._onPointerOver, this);
1288
+ this.off('pointerout', this._onPointerOut, this);
1289
+ this.off('pointerdown', this._onPointerDown, this);
1290
+ this.off('pointerup', this._onPointerUp, this);
1291
+ this.off('pointerupoutside', this._onPointerUpOutside, this);
1292
+ this._views.clear();
1293
+ this._textObj = null;
1294
+ super.destroy(options);
1295
+ }
285
1296
  }
1297
+
286
1298
  /**
287
- * Register @pixi/layout components for JSX use.
288
- * Pass the dynamically imported module:
1299
+ * Horizontal progress bar with optional custom track/fill views.
289
1300
  *
1301
+ * Supports asset-based skinning: provide `trackView` and/or `fillView`
1302
+ * as texture names, Textures, or any Container (NineSliceSprite, custom artwork, etc).
1303
+ * Falls back to colored Graphics when no custom views are provided.
1304
+ *
1305
+ * @example
290
1306
  * ```ts
291
- * const layout = await import('@pixi/layout/components');
292
- * extendLayoutElements(layout);
1307
+ * // Graphics-based (quick prototyping)
1308
+ * const bar = new ProgressBar({ width: 300, height: 20, fillColor: 0x22cc22 });
1309
+ * bar.progress = 0.5;
1310
+ *
1311
+ * // Asset-based (production art)
1312
+ * const bar = new ProgressBar({
1313
+ * width: 300, height: 20,
1314
+ * trackView: 'bar-track',
1315
+ * fillView: new NineSliceSprite({ texture: 'bar-fill', ... }),
1316
+ * });
1317
+ * bar.progress = 0.75;
293
1318
  * ```
294
1319
  */
295
- function extendLayoutElements(layoutModule) {
296
- extend(layoutModule);
1320
+ class ProgressBar extends pixi_js.Container {
1321
+ __uiComponent = true;
1322
+ _track;
1323
+ _fill;
1324
+ _fillMask;
1325
+ _borderGfx;
1326
+ _config;
1327
+ _progress = 0;
1328
+ _displayedProgress = 0;
1329
+ constructor(config = {}) {
1330
+ super();
1331
+ this._config = {
1332
+ width: config.width ?? 300,
1333
+ height: config.height ?? 16,
1334
+ borderRadius: config.borderRadius ?? 8,
1335
+ fillColor: config.fillColor ?? 0xffd700,
1336
+ trackColor: config.trackColor ?? 0x333333,
1337
+ borderColor: config.borderColor ?? 0x555555,
1338
+ borderWidth: config.borderWidth ?? 1,
1339
+ animated: config.animated ?? true,
1340
+ animationSpeed: config.animationSpeed ?? 0.1,
1341
+ };
1342
+ const { width, height, borderRadius, fillColor, trackColor, borderColor, borderWidth } = this._config;
1343
+ // Track background — custom view or Graphics
1344
+ const customTrack = resolveView(config.trackView);
1345
+ if (customTrack) {
1346
+ customTrack.width = width;
1347
+ customTrack.height = height;
1348
+ this._track = customTrack;
1349
+ }
1350
+ else {
1351
+ const g = new pixi_js.Graphics();
1352
+ g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
1353
+ this._track = g;
1354
+ }
1355
+ this.addChild(this._track);
1356
+ // Fill bar — custom view or Graphics
1357
+ const customFill = resolveView(config.fillView);
1358
+ if (customFill) {
1359
+ customFill.x = borderWidth;
1360
+ customFill.y = borderWidth;
1361
+ customFill.width = width - borderWidth * 2;
1362
+ customFill.height = height - borderWidth * 2;
1363
+ this._fill = customFill;
1364
+ }
1365
+ else {
1366
+ const g = new pixi_js.Graphics();
1367
+ g.roundRect(borderWidth, borderWidth, width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1)).fill(fillColor);
1368
+ this._fill = g;
1369
+ }
1370
+ this.addChild(this._fill);
1371
+ // Mask for the fill (controls visible width)
1372
+ this._fillMask = new pixi_js.Graphics();
1373
+ this._fillMask.rect(0, 0, 0, height).fill(0xffffff);
1374
+ this.addChild(this._fillMask);
1375
+ this._fill.mask = this._fillMask;
1376
+ // Border overlay
1377
+ this._borderGfx = new pixi_js.Graphics();
1378
+ if (borderColor !== undefined && borderWidth > 0) {
1379
+ this._borderGfx
1380
+ .roundRect(0, 0, width, height, borderRadius)
1381
+ .stroke({ color: borderColor, width: borderWidth });
1382
+ }
1383
+ this.addChild(this._borderGfx);
1384
+ }
1385
+ /** Get/set progress (0..1) */
1386
+ get progress() {
1387
+ return this._progress;
1388
+ }
1389
+ set progress(value) {
1390
+ this._progress = Math.max(0, Math.min(1, value));
1391
+ if (!this._config.animated) {
1392
+ this._displayedProgress = this._progress;
1393
+ this.updateMask();
1394
+ }
1395
+ }
1396
+ /**
1397
+ * Call each frame if animated is true.
1398
+ */
1399
+ update(_dt) {
1400
+ if (!this._config.animated)
1401
+ return;
1402
+ if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
1403
+ this._displayedProgress = this._progress;
1404
+ this.updateMask();
1405
+ return;
1406
+ }
1407
+ this._displayedProgress +=
1408
+ (this._progress - this._displayedProgress) * this._config.animationSpeed;
1409
+ this.updateMask();
1410
+ }
1411
+ /** React reconciler update hook */
1412
+ updateConfig(changed) {
1413
+ if ('progress' in changed)
1414
+ this.progress = changed.progress;
1415
+ if ('animated' in changed)
1416
+ this._config.animated = changed.animated;
1417
+ if ('animationSpeed' in changed)
1418
+ this._config.animationSpeed = changed.animationSpeed;
1419
+ }
1420
+ updateMask() {
1421
+ const w = this._config.width * this._displayedProgress;
1422
+ this._fillMask.clear();
1423
+ this._fillMask.rect(0, 0, w, this._config.height).fill(0xffffff);
1424
+ }
1425
+ }
1426
+
1427
+ /**
1428
+ * Enhanced text label with auto-fit scaling and currency formatting.
1429
+ *
1430
+ * @example
1431
+ * ```ts
1432
+ * const label = new Label({
1433
+ * text: 'BALANCE',
1434
+ * style: { fontSize: 24, fill: 0xffd700 },
1435
+ * maxWidth: 200,
1436
+ * autoFit: true,
1437
+ * });
1438
+ * ```
1439
+ */
1440
+ class Label extends pixi_js.Container {
1441
+ __uiComponent = true;
1442
+ _text;
1443
+ _maxWidth;
1444
+ _autoFit;
1445
+ constructor(config = {}) {
1446
+ super();
1447
+ this._maxWidth = config.maxWidth ?? Infinity;
1448
+ this._autoFit = config.autoFit ?? false;
1449
+ this._text = new pixi_js.Text({
1450
+ text: config.text ?? '',
1451
+ style: {
1452
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
1453
+ fontSize: 24,
1454
+ fill: 0xffffff,
1455
+ ...config.style,
1456
+ },
1457
+ });
1458
+ this._text.anchor.set(0.5);
1459
+ this.addChild(this._text);
1460
+ this.fitText();
1461
+ }
1462
+ /** Get/set the displayed text */
1463
+ get text() {
1464
+ return this._text.text;
1465
+ }
1466
+ set text(value) {
1467
+ this._text.text = value;
1468
+ this.fitText();
1469
+ }
1470
+ /** Get/set the text style */
1471
+ get style() {
1472
+ return this._text.style;
1473
+ }
1474
+ /** Set max width constraint */
1475
+ set maxWidth(value) {
1476
+ this._maxWidth = value;
1477
+ this.fitText();
1478
+ }
1479
+ /**
1480
+ * Format and display a number as currency.
1481
+ *
1482
+ * @param amount - The numeric amount
1483
+ * @param currency - Currency code (e.g., 'USD', 'EUR')
1484
+ * @param locale - Locale string (default: 'en-US')
1485
+ */
1486
+ setCurrency(amount, currency, locale = 'en-US') {
1487
+ try {
1488
+ this.text = new Intl.NumberFormat(locale, {
1489
+ style: 'currency',
1490
+ currency,
1491
+ minimumFractionDigits: 2,
1492
+ maximumFractionDigits: 2,
1493
+ }).format(amount);
1494
+ }
1495
+ catch {
1496
+ this.text = `${amount.toFixed(2)} ${currency}`;
1497
+ }
1498
+ }
1499
+ /**
1500
+ * Format a number with thousands separators.
1501
+ */
1502
+ setNumber(value, decimals = 0, locale = 'en-US') {
1503
+ this.text = new Intl.NumberFormat(locale, {
1504
+ minimumFractionDigits: decimals,
1505
+ maximumFractionDigits: decimals,
1506
+ }).format(value);
1507
+ }
1508
+ /** React reconciler update hook */
1509
+ updateConfig(changed) {
1510
+ if ('text' in changed)
1511
+ this.text = changed.text;
1512
+ if ('maxWidth' in changed)
1513
+ this.maxWidth = changed.maxWidth;
1514
+ if ('autoFit' in changed) {
1515
+ this._autoFit = changed.autoFit;
1516
+ this.fitText();
1517
+ }
1518
+ if ('style' in changed && typeof changed.style === 'object') {
1519
+ Object.assign(this._text.style, changed.style);
1520
+ this.fitText();
1521
+ }
1522
+ }
1523
+ fitText() {
1524
+ if (!this._autoFit || this._maxWidth === Infinity)
1525
+ return;
1526
+ this._text.scale.set(1);
1527
+ if (this._text.width > this._maxWidth) {
1528
+ const scale = this._maxWidth / this._text.width;
1529
+ this._text.scale.set(scale);
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ /**
1535
+ * Background panel with optional flexbox content layout.
1536
+ *
1537
+ * Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
1538
+ * Children added via `addContent()` participate in flex layout automatically.
1539
+ *
1540
+ * @example
1541
+ * ```ts
1542
+ * // Simple colored panel
1543
+ * const panel = new Panel({ width: 400, height: 300, backgroundColor: 0x222222, borderRadius: 12 });
1544
+ *
1545
+ * // 9-slice panel (texture-based)
1546
+ * const panel = new Panel({
1547
+ * nineSliceTexture: 'panel-bg',
1548
+ * nineSliceBorders: [20, 20, 20, 20],
1549
+ * width: 400, height: 300,
1550
+ * });
1551
+ * ```
1552
+ */
1553
+ class Panel extends pixi_js.Container {
1554
+ __uiComponent = true;
1555
+ _bg;
1556
+ _content;
1557
+ _internalSetup = true;
1558
+ _panelConfig;
1559
+ constructor(config = {}) {
1560
+ super();
1561
+ const resolvedConfig = {
1562
+ width: config.width ?? 400,
1563
+ height: config.height ?? 300,
1564
+ padding: config.padding ?? 16,
1565
+ backgroundAlpha: config.backgroundAlpha ?? 1,
1566
+ ...config,
1567
+ };
1568
+ this._panelConfig = resolvedConfig;
1569
+ // Create background
1570
+ if (config.nineSliceTexture) {
1571
+ const texture = typeof config.nineSliceTexture === 'string'
1572
+ ? pixi_js.Texture.from(config.nineSliceTexture)
1573
+ : config.nineSliceTexture;
1574
+ const [left, top, right, bottom] = config.nineSliceBorders ?? [10, 10, 10, 10];
1575
+ const nineSlice = new pixi_js.NineSliceSprite({
1576
+ texture,
1577
+ leftWidth: left,
1578
+ topHeight: top,
1579
+ rightWidth: right,
1580
+ bottomHeight: bottom,
1581
+ });
1582
+ nineSlice.width = resolvedConfig.width;
1583
+ nineSlice.height = resolvedConfig.height;
1584
+ nineSlice.alpha = resolvedConfig.backgroundAlpha;
1585
+ this._bg = nineSlice;
1586
+ }
1587
+ else {
1588
+ const g = new pixi_js.Graphics();
1589
+ const bgColor = config.backgroundColor ?? 0x1a1a2e;
1590
+ const radius = config.borderRadius ?? 0;
1591
+ g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius).fill(bgColor);
1592
+ if (config.borderColor !== undefined && config.borderWidth) {
1593
+ g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius)
1594
+ .stroke({ color: config.borderColor, width: config.borderWidth });
1595
+ }
1596
+ g.alpha = resolvedConfig.backgroundAlpha;
1597
+ this._bg = g;
1598
+ }
1599
+ this.addChild(this._bg);
1600
+ // Create content flex container
1601
+ this._content = new FlexContainer({
1602
+ ...config.layout,
1603
+ direction: config.layout?.direction ?? 'column',
1604
+ justifyContent: config.layout?.justifyContent ?? 'start',
1605
+ alignItems: config.layout?.alignItems ?? 'start',
1606
+ gap: config.layout?.gap ?? 0,
1607
+ padding: resolvedConfig.padding,
1608
+ width: resolvedConfig.width,
1609
+ height: resolvedConfig.height,
1610
+ });
1611
+ this.addChild(this._content);
1612
+ this._internalSetup = false;
1613
+ }
1614
+ /** Access the content flex container — add children here for layout */
1615
+ get content() {
1616
+ return this._content;
1617
+ }
1618
+ /** Convenience: add a child to the content layout */
1619
+ addContent(child) {
1620
+ this._content.addFlexChild(child);
1621
+ this._content.updateLayout();
1622
+ return this;
1623
+ }
1624
+ /** Resize the panel */
1625
+ setSize(width, height) {
1626
+ this._panelConfig.width = width;
1627
+ this._panelConfig.height = height;
1628
+ // Resize background
1629
+ if (this._bg instanceof pixi_js.NineSliceSprite) {
1630
+ this._bg.width = width;
1631
+ this._bg.height = height;
1632
+ }
1633
+ else if (this._bg instanceof pixi_js.Graphics) {
1634
+ const radius = this._panelConfig.borderRadius ?? 0;
1635
+ const bgColor = this._panelConfig.backgroundColor ?? 0x1a1a2e;
1636
+ this._bg.clear();
1637
+ this._bg.roundRect(0, 0, width, height, radius).fill(bgColor);
1638
+ if (this._panelConfig.borderColor !== undefined && this._panelConfig.borderWidth) {
1639
+ this._bg.roundRect(0, 0, width, height, radius)
1640
+ .stroke({ color: this._panelConfig.borderColor, width: this._panelConfig.borderWidth });
1641
+ }
1642
+ this._bg.alpha = this._panelConfig.backgroundAlpha;
1643
+ }
1644
+ this._content.resize(width, height);
1645
+ }
1646
+ /**
1647
+ * Override addChild so external children are routed to content FlexContainer.
1648
+ * Enables `<panel><label /><button /></panel>` in React JSX.
1649
+ */
1650
+ addChild(...children) {
1651
+ if (this._internalSetup) {
1652
+ return super.addChild(...children);
1653
+ }
1654
+ for (const child of children) {
1655
+ this._content.addFlexChild(child);
1656
+ }
1657
+ this._content.updateLayout();
1658
+ return children[0];
1659
+ }
1660
+ removeChild(...children) {
1661
+ if (this._internalSetup) {
1662
+ return super.removeChild(...children);
1663
+ }
1664
+ for (const child of children) {
1665
+ this._content.removeFlexChild(child);
1666
+ }
1667
+ return children[0];
1668
+ }
1669
+ /** React reconciler update hook */
1670
+ updateConfig(changed) {
1671
+ if ('width' in changed || 'height' in changed) {
1672
+ this.setSize(changed.width ?? this._panelConfig.width, changed.height ?? this._panelConfig.height);
1673
+ }
1674
+ if ('backgroundAlpha' in changed) {
1675
+ this._panelConfig.backgroundAlpha = changed.backgroundAlpha;
1676
+ this._bg.alpha = changed.backgroundAlpha;
1677
+ }
1678
+ }
1679
+ destroy(options) {
1680
+ super.destroy(options);
1681
+ }
1682
+ }
1683
+
1684
+ /**
1685
+ * Reactive balance display component.
1686
+ *
1687
+ * Automatically formats currency and can animate value changes
1688
+ * with a smooth countup/countdown effect using engine Tween.
1689
+ *
1690
+ * @example
1691
+ * ```ts
1692
+ * const balance = new BalanceDisplay({ currency: 'USD', animated: true });
1693
+ * balance.setValue(1000);
1694
+ *
1695
+ * // Wire to SDK
1696
+ * sdk.on('balanceUpdate', ({ balance: val }) => balance.setValue(val));
1697
+ * ```
1698
+ */
1699
+ class BalanceDisplay extends pixi_js.Container {
1700
+ __uiComponent = true;
1701
+ _prefixLabel = null;
1702
+ _valueLabel;
1703
+ _config;
1704
+ _currentValue = 0;
1705
+ _displayedValue = 0;
1706
+ /** Internal target for Tween animation */
1707
+ _tweenTarget = { value: 0 };
1708
+ constructor(config = {}) {
1709
+ super();
1710
+ this._config = {
1711
+ currency: config.currency ?? 'USD',
1712
+ locale: config.locale ?? 'en-US',
1713
+ animated: config.animated ?? true,
1714
+ animationDuration: config.animationDuration ?? 500,
1715
+ };
1716
+ // Prefix label
1717
+ if (config.prefix) {
1718
+ this._prefixLabel = new Label({
1719
+ text: config.prefix,
1720
+ style: {
1721
+ fontSize: 16,
1722
+ fill: 0xaaaaaa,
1723
+ ...config.style,
1724
+ },
1725
+ });
1726
+ this.addChild(this._prefixLabel);
1727
+ }
1728
+ // Value label
1729
+ this._valueLabel = new Label({
1730
+ text: '0.00',
1731
+ style: {
1732
+ fontSize: 28,
1733
+ fontWeight: 'bold',
1734
+ fill: 0xffffff,
1735
+ ...config.style,
1736
+ },
1737
+ maxWidth: config.maxWidth,
1738
+ autoFit: !!config.maxWidth,
1739
+ });
1740
+ this.addChild(this._valueLabel);
1741
+ this.layoutLabels();
1742
+ }
1743
+ /** Current displayed value */
1744
+ get value() {
1745
+ return this._currentValue;
1746
+ }
1747
+ /**
1748
+ * Set the balance value. If animated, smoothly counts to the new value.
1749
+ */
1750
+ setValue(value) {
1751
+ const oldValue = this._currentValue;
1752
+ this._currentValue = value;
1753
+ if (this._config.animated && oldValue !== value) {
1754
+ this.animateValue(oldValue, value);
1755
+ }
1756
+ else {
1757
+ this._displayedValue = value;
1758
+ this.updateDisplay();
1759
+ }
1760
+ }
1761
+ /**
1762
+ * Set the currency code.
1763
+ */
1764
+ setCurrency(currency) {
1765
+ this._config.currency = currency;
1766
+ this.updateDisplay();
1767
+ }
1768
+ animateValue(from, to) {
1769
+ // Cancel any running animation
1770
+ Tween.killTweensOf(this._tweenTarget);
1771
+ this._tweenTarget.value = from;
1772
+ Tween.to(this._tweenTarget, { value: to }, this._config.animationDuration, Easing.easeOutCubic, () => {
1773
+ this._displayedValue = this._tweenTarget.value;
1774
+ this.updateDisplay();
1775
+ });
1776
+ }
1777
+ updateDisplay() {
1778
+ this._valueLabel.setCurrency(this._displayedValue, this._config.currency, this._config.locale);
1779
+ }
1780
+ layoutLabels() {
1781
+ if (this._prefixLabel) {
1782
+ this._prefixLabel.y = -14;
1783
+ this._valueLabel.y = 14;
1784
+ }
1785
+ }
1786
+ /** React reconciler update hook */
1787
+ updateConfig(changed) {
1788
+ if ('value' in changed)
1789
+ this.setValue(changed.value);
1790
+ if ('currency' in changed)
1791
+ this.setCurrency(changed.currency);
1792
+ }
1793
+ destroy(options) {
1794
+ Tween.killTweensOf(this._tweenTarget);
1795
+ super.destroy(options);
1796
+ }
1797
+ }
1798
+
1799
+ /**
1800
+ * Win amount display with countup animation.
1801
+ *
1802
+ * Shows a dramatic countup from 0 to the win amount, with optional
1803
+ * scale pop effect — typical of slot games. Uses engine Tween system.
1804
+ *
1805
+ * @example
1806
+ * ```ts
1807
+ * const winDisplay = new WinDisplay({ currency: 'USD' });
1808
+ * scene.container.addChild(winDisplay);
1809
+ * await winDisplay.showWin(150.50); // countup animation
1810
+ * winDisplay.hide();
1811
+ * ```
1812
+ */
1813
+ class WinDisplay extends pixi_js.Container {
1814
+ __uiComponent = true;
1815
+ _label;
1816
+ _config;
1817
+ /** Internal target for Tween countup */
1818
+ _tweenTarget = { value: 0 };
1819
+ constructor(config = {}) {
1820
+ super();
1821
+ this._config = {
1822
+ currency: config.currency ?? 'USD',
1823
+ locale: config.locale ?? 'en-US',
1824
+ countupDuration: config.countupDuration ?? 1500,
1825
+ popScale: config.popScale ?? 1.2,
1826
+ };
1827
+ this._label = new Label({
1828
+ text: '',
1829
+ style: {
1830
+ fontSize: 48,
1831
+ fontWeight: 'bold',
1832
+ fill: 0xffd700,
1833
+ stroke: { color: 0x000000, width: 3 },
1834
+ ...config.style,
1835
+ },
1836
+ });
1837
+ this.addChild(this._label);
1838
+ this.visible = false;
1839
+ }
1840
+ /**
1841
+ * Show a win with countup animation.
1842
+ *
1843
+ * @param amount - Win amount
1844
+ * @returns Promise that resolves when the animation completes
1845
+ */
1846
+ async showWin(amount) {
1847
+ this.visible = true;
1848
+ this.alpha = 1;
1849
+ // Cancel any running animation
1850
+ Tween.killTweensOf(this._tweenTarget);
1851
+ Tween.killTweensOf(this);
1852
+ // Setup countup
1853
+ this._tweenTarget.value = 0;
1854
+ this.scale.set(0.5);
1855
+ // Scale pop animation
1856
+ const scalePromise = Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, 300, Easing.easeOutBack);
1857
+ // Countup animation
1858
+ const countupPromise = Tween.to(this._tweenTarget, { value: amount }, this._config.countupDuration, Easing.easeOutCubic, () => {
1859
+ this.displayAmount(this._tweenTarget.value);
1860
+ });
1861
+ await Promise.all([scalePromise, countupPromise]);
1862
+ // Ensure final value is exact
1863
+ this.displayAmount(amount);
1864
+ this.scale.set(1);
1865
+ }
1866
+ /**
1867
+ * Skip the countup animation and show the final amount immediately.
1868
+ */
1869
+ skipCountup(amount) {
1870
+ Tween.killTweensOf(this._tweenTarget);
1871
+ Tween.killTweensOf(this);
1872
+ this.displayAmount(amount);
1873
+ this.scale.set(1);
1874
+ }
1875
+ /**
1876
+ * Hide the win display.
1877
+ */
1878
+ hide() {
1879
+ Tween.killTweensOf(this._tweenTarget);
1880
+ Tween.killTweensOf(this);
1881
+ this.visible = false;
1882
+ this._label.text = '';
1883
+ }
1884
+ displayAmount(amount) {
1885
+ this._label.setCurrency(amount, this._config.currency, this._config.locale);
1886
+ }
1887
+ /** React reconciler update hook */
1888
+ updateConfig(changed) {
1889
+ if ('currency' in changed)
1890
+ this._config.currency = changed.currency;
1891
+ if ('locale' in changed)
1892
+ this._config.locale = changed.locale;
1893
+ }
1894
+ destroy(options) {
1895
+ Tween.killTweensOf(this._tweenTarget);
1896
+ Tween.killTweensOf(this);
1897
+ super.destroy(options);
1898
+ }
1899
+ }
1900
+
1901
+ /**
1902
+ * Modal overlay component.
1903
+ * Shows content on top of a dark overlay with enter/exit animations.
1904
+ *
1905
+ * Content is automatically centered via position calculations.
1906
+ *
1907
+ * @example
1908
+ * ```ts
1909
+ * const modal = new Modal({ closeOnOverlay: true });
1910
+ * modal.content.addChild(settingsPanel);
1911
+ * modal.onClose = () => console.log('Closed');
1912
+ * await modal.show(1920, 1080);
1913
+ * ```
1914
+ */
1915
+ class Modal extends pixi_js.Container {
1916
+ __uiComponent = true;
1917
+ _overlay;
1918
+ _contentContainer;
1919
+ _config;
1920
+ _showing = false;
1921
+ /** Called when the modal is closed */
1922
+ onClose;
1923
+ constructor(config = {}) {
1924
+ super();
1925
+ this._config = {
1926
+ overlayColor: config.overlayColor ?? 0x000000,
1927
+ overlayAlpha: config.overlayAlpha ?? 0.7,
1928
+ closeOnOverlay: config.closeOnOverlay ?? true,
1929
+ animationDuration: config.animationDuration ?? 300,
1930
+ };
1931
+ // Overlay
1932
+ this._overlay = new pixi_js.Graphics();
1933
+ this._overlay.eventMode = 'static';
1934
+ this.addChild(this._overlay);
1935
+ if (this._config.closeOnOverlay) {
1936
+ this._overlay.on('pointertap', () => this.hide());
1937
+ }
1938
+ // Content container
1939
+ this._contentContainer = new pixi_js.Container();
1940
+ this.addChild(this._contentContainer);
1941
+ this.visible = false;
1942
+ }
1943
+ /** Content container — add your UI here */
1944
+ get content() {
1945
+ return this._contentContainer;
1946
+ }
1947
+ /** Whether the modal is currently showing */
1948
+ get isShowing() {
1949
+ return this._showing;
1950
+ }
1951
+ /**
1952
+ * Show the modal with animation.
1953
+ */
1954
+ async show(viewWidth, viewHeight) {
1955
+ this._showing = true;
1956
+ this.visible = true;
1957
+ // Draw overlay to cover full screen
1958
+ this._overlay.clear();
1959
+ this._overlay.rect(0, 0, viewWidth, viewHeight).fill(this._config.overlayColor);
1960
+ this._overlay.alpha = 0;
1961
+ // Center content
1962
+ this._contentContainer.x = viewWidth / 2;
1963
+ this._contentContainer.y = viewHeight / 2;
1964
+ this._contentContainer.alpha = 0;
1965
+ this._contentContainer.scale.set(0.8);
1966
+ // Animate in
1967
+ await Promise.all([
1968
+ Tween.to(this._overlay, { alpha: this._config.overlayAlpha }, this._config.animationDuration, Easing.easeOutCubic),
1969
+ Tween.to(this._contentContainer, { alpha: 1, 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutBack),
1970
+ ]);
1971
+ }
1972
+ /**
1973
+ * Hide the modal with animation.
1974
+ */
1975
+ async hide() {
1976
+ if (!this._showing)
1977
+ return;
1978
+ await Promise.all([
1979
+ Tween.to(this._overlay, { alpha: 0 }, this._config.animationDuration * 0.7, Easing.easeInCubic),
1980
+ Tween.to(this._contentContainer, { alpha: 0, 'scale.x': 0.8, 'scale.y': 0.8 }, this._config.animationDuration * 0.7, Easing.easeInCubic),
1981
+ ]);
1982
+ this.visible = false;
1983
+ this._showing = false;
1984
+ this.onClose?.();
1985
+ }
1986
+ /** React reconciler update hook */
1987
+ updateConfig(changed) {
1988
+ if ('overlayAlpha' in changed)
1989
+ this._config.overlayAlpha = changed.overlayAlpha;
1990
+ if ('closeOnOverlay' in changed)
1991
+ this._config.closeOnOverlay = changed.closeOnOverlay;
1992
+ if ('animationDuration' in changed)
1993
+ this._config.animationDuration = changed.animationDuration;
1994
+ if ('onClose' in changed)
1995
+ this.onClose = changed.onClose;
1996
+ }
1997
+ }
1998
+
1999
+ const TOAST_COLORS = {
2000
+ info: 0x3498db,
2001
+ success: 0x27ae60,
2002
+ warning: 0xf39c12,
2003
+ error: 0xe74c3c,
2004
+ };
2005
+ /**
2006
+ * Toast notification component for displaying transient messages.
2007
+ *
2008
+ * @example
2009
+ * ```ts
2010
+ * const toast = new Toast();
2011
+ * scene.container.addChild(toast);
2012
+ * await toast.show('Connection lost', 'error', 1920, 1080);
2013
+ * ```
2014
+ */
2015
+ class Toast extends pixi_js.Container {
2016
+ __uiComponent = true;
2017
+ _bg;
2018
+ _customBg;
2019
+ _text;
2020
+ _config;
2021
+ _dismissPending = false;
2022
+ constructor(config = {}) {
2023
+ super();
2024
+ this._config = {
2025
+ duration: config.duration ?? 3000,
2026
+ bottomOffset: config.bottomOffset ?? 60,
2027
+ };
2028
+ const customBg = resolveView(config.backgroundView);
2029
+ this._customBg = !!customBg;
2030
+ this._bg = customBg ?? new pixi_js.Graphics();
2031
+ this.addChild(this._bg);
2032
+ this._text = new pixi_js.Text({
2033
+ text: '',
2034
+ style: {
2035
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
2036
+ fontSize: 16,
2037
+ fill: 0xffffff,
2038
+ },
2039
+ });
2040
+ this._text.anchor.set(0.5);
2041
+ this.addChild(this._text);
2042
+ this.visible = false;
2043
+ }
2044
+ /**
2045
+ * Show a toast message.
2046
+ */
2047
+ async show(message, type = 'info', viewWidth, viewHeight) {
2048
+ // Cancel any pending dismiss
2049
+ Tween.killTweensOf(this);
2050
+ this._dismissPending = false;
2051
+ this._text.text = message;
2052
+ const padding = 20;
2053
+ const width = Math.max(200, this._text.width + padding * 2);
2054
+ const height = 44;
2055
+ const radius = 8;
2056
+ // Draw the background
2057
+ if (this._customBg) {
2058
+ this._bg.width = width;
2059
+ this._bg.height = height;
2060
+ this._bg.x = -width / 2;
2061
+ this._bg.y = -height / 2;
2062
+ }
2063
+ else {
2064
+ const g = this._bg;
2065
+ g.clear();
2066
+ g.roundRect(-width / 2, -height / 2, width, height, radius);
2067
+ g.fill(TOAST_COLORS[type]);
2068
+ }
2069
+ // Position
2070
+ if (viewWidth && viewHeight) {
2071
+ this.x = viewWidth / 2;
2072
+ this.y = viewHeight - this._config.bottomOffset;
2073
+ }
2074
+ this.visible = true;
2075
+ this.alpha = 0;
2076
+ this.y += 20;
2077
+ await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
2078
+ if (this._config.duration > 0) {
2079
+ this._dismissPending = true;
2080
+ await Tween.delay(this._config.duration);
2081
+ if (this._dismissPending) {
2082
+ this._dismissPending = false;
2083
+ await this.dismiss();
2084
+ }
2085
+ }
2086
+ }
2087
+ /**
2088
+ * Dismiss the toast.
2089
+ */
2090
+ async dismiss() {
2091
+ if (!this.visible)
2092
+ return;
2093
+ this._dismissPending = false;
2094
+ Tween.killTweensOf(this);
2095
+ await Tween.to(this, { alpha: 0, y: this.y + 20 }, 200, Easing.easeInCubic);
2096
+ this.visible = false;
2097
+ }
2098
+ /** React reconciler update hook */
2099
+ updateConfig(changed) {
2100
+ if ('duration' in changed)
2101
+ this._config.duration = changed.duration;
2102
+ if ('bottomOffset' in changed)
2103
+ this._config.bottomOffset = changed.bottomOffset;
2104
+ }
2105
+ destroy(options) {
2106
+ this._dismissPending = false;
2107
+ Tween.killTweensOf(this);
2108
+ super.destroy(options);
2109
+ }
2110
+ }
2111
+
2112
+ // ─── Helpers ─────────────────────────────────────────────
2113
+ function directionToFlex(direction) {
2114
+ switch (direction) {
2115
+ case 'horizontal': return { direction: 'row', wrap: false };
2116
+ case 'vertical': return { direction: 'column', wrap: false };
2117
+ case 'grid': return { direction: 'row', wrap: true };
2118
+ case 'wrap': return { direction: 'row', wrap: true };
2119
+ }
2120
+ }
2121
+ /**
2122
+ * Responsive layout container powered by a lightweight built-in flex layout solver.
2123
+ *
2124
+ * Supports horizontal, vertical, grid, and wrap layout modes with
2125
+ * alignment, padding, gap, and viewport-anchor positioning.
2126
+ * Breakpoints allow different layouts for different screen sizes.
2127
+ *
2128
+ * @example
2129
+ * ```ts
2130
+ * const toolbar = new Layout({
2131
+ * direction: 'horizontal',
2132
+ * gap: 20,
2133
+ * alignment: 'center',
2134
+ * anchor: 'bottom-center',
2135
+ * padding: 16,
2136
+ * breakpoints: {
2137
+ * 768: { direction: 'vertical', gap: 10 },
2138
+ * },
2139
+ * });
2140
+ *
2141
+ * toolbar.addItem(spinButton);
2142
+ * toolbar.addItem(betLabel);
2143
+ * scene.container.addChild(toolbar);
2144
+ *
2145
+ * toolbar.updateViewport(width, height);
2146
+ * ```
2147
+ */
2148
+ class Layout extends pixi_js.Container {
2149
+ __uiComponent = true;
2150
+ _layoutConfig;
2151
+ _padding;
2152
+ _anchor;
2153
+ _maxWidth;
2154
+ _breakpoints;
2155
+ _items = [];
2156
+ _viewportWidth = 0;
2157
+ _viewportHeight = 0;
2158
+ _flex;
2159
+ constructor(config = {}) {
2160
+ super();
2161
+ this._layoutConfig = {
2162
+ direction: config.direction ?? 'vertical',
2163
+ gap: config.gap ?? 0,
2164
+ alignment: config.alignment ?? 'start',
2165
+ autoLayout: config.autoLayout ?? true,
2166
+ columns: config.columns ?? 2,
2167
+ };
2168
+ this._padding = config.padding ?? 0;
2169
+ this._anchor = config.anchor ?? 'top-left';
2170
+ this._maxWidth = config.maxWidth ?? Infinity;
2171
+ this._breakpoints = config.breakpoints
2172
+ ? Object.entries(config.breakpoints)
2173
+ .map(([w, cfg]) => [Number(w), cfg])
2174
+ .sort((a, b) => a[0] - b[0])
2175
+ : [];
2176
+ // Create internal FlexContainer
2177
+ this._flex = new FlexContainer();
2178
+ super.addChild(this._flex);
2179
+ this.applyLayoutStyles();
2180
+ }
2181
+ /** Add an item to the layout */
2182
+ addItem(child) {
2183
+ this._items.push(child);
2184
+ const flexConfig = this.buildFlexItemConfig(child);
2185
+ this._flex.addFlexChild(child, flexConfig);
2186
+ if (this._layoutConfig.autoLayout) {
2187
+ this.applyLayoutStyles();
2188
+ }
2189
+ return this;
2190
+ }
2191
+ /** Remove an item from the layout */
2192
+ removeItem(child) {
2193
+ const idx = this._items.indexOf(child);
2194
+ if (idx !== -1) {
2195
+ this._items.splice(idx, 1);
2196
+ this._flex.removeFlexChild(child);
2197
+ }
2198
+ return this;
2199
+ }
2200
+ /** Remove all items */
2201
+ clearItems() {
2202
+ this._flex.clearFlexChildren();
2203
+ this._items.length = 0;
2204
+ return this;
2205
+ }
2206
+ /** Get all layout items */
2207
+ get items() {
2208
+ return this._items;
2209
+ }
2210
+ /**
2211
+ * Update the viewport size and recalculate layout.
2212
+ * Should be called from `Scene.onResize()`.
2213
+ */
2214
+ updateViewport(width, height) {
2215
+ this._viewportWidth = width;
2216
+ this._viewportHeight = height;
2217
+ this.applyLayoutStyles();
2218
+ this.applyAnchor();
2219
+ }
2220
+ applyLayoutStyles() {
2221
+ const effective = this.resolveConfig();
2222
+ const direction = effective.direction ?? this._layoutConfig.direction;
2223
+ const gap = effective.gap ?? this._layoutConfig.gap;
2224
+ const alignment = effective.alignment ?? this._layoutConfig.alignment;
2225
+ const padding = effective.padding ?? this._padding;
2226
+ const maxWidth = effective.maxWidth ?? this._maxWidth;
2227
+ const { direction: flexDir, wrap } = directionToFlex(direction);
2228
+ this._flex.setDirection(flexDir);
2229
+ this._flex.setJustifyContent('start');
2230
+ this._flex.setAlignItems(alignment);
2231
+ this._flex.setGap(gap);
2232
+ this._flex.setPadding(padding);
2233
+ // Wrap and maxWidth
2234
+ if (wrap) {
2235
+ this._flex._config.flexWrap = true;
2236
+ if (direction === 'grid' && maxWidth < Infinity) {
2237
+ this._flex._maxWidth = maxWidth;
2238
+ }
2239
+ if (maxWidth < Infinity) {
2240
+ this._flex._maxWidth = maxWidth;
2241
+ }
2242
+ }
2243
+ else {
2244
+ this._flex._config.flexWrap = false;
2245
+ }
2246
+ // Update grid child widths
2247
+ if (direction === 'grid') {
2248
+ for (const item of this._items) {
2249
+ const flexConfig = this.buildFlexItemConfig(item);
2250
+ item._flexConfig = flexConfig;
2251
+ }
2252
+ }
2253
+ // Set explicit size if we have viewport dimensions
2254
+ if (this._viewportWidth > 0 && this._viewportHeight > 0) {
2255
+ this._flex.resize(this._viewportWidth, this._viewportHeight);
2256
+ }
2257
+ else {
2258
+ this._flex.updateLayout();
2259
+ }
2260
+ }
2261
+ buildFlexItemConfig(_child) {
2262
+ const effective = this.resolveConfig();
2263
+ const direction = effective.direction ?? this._layoutConfig.direction;
2264
+ const columns = effective.columns ?? this._layoutConfig.columns;
2265
+ if (direction === 'grid' && columns > 0) {
2266
+ // For grid, give each item a proportional width
2267
+ // The actual pixel width will be computed during layout
2268
+ return { flexGrow: 1 };
2269
+ }
2270
+ return undefined;
2271
+ }
2272
+ applyAnchor() {
2273
+ const anchor = this.resolveConfig().anchor ?? this._anchor;
2274
+ if (this._viewportWidth === 0 || this._viewportHeight === 0)
2275
+ return;
2276
+ const { width: contentW, height: contentH } = this._flex.getContentSize();
2277
+ const vw = this._viewportWidth;
2278
+ const vh = this._viewportHeight;
2279
+ let anchorX = 0;
2280
+ let anchorY = 0;
2281
+ if (anchor.includes('left')) {
2282
+ anchorX = 0;
2283
+ }
2284
+ else if (anchor.includes('right')) {
2285
+ anchorX = vw - contentW;
2286
+ }
2287
+ else {
2288
+ anchorX = (vw - contentW) / 2;
2289
+ }
2290
+ if (anchor.startsWith('top')) {
2291
+ anchorY = 0;
2292
+ }
2293
+ else if (anchor.startsWith('bottom')) {
2294
+ anchorY = vh - contentH;
2295
+ }
2296
+ else {
2297
+ anchorY = (vh - contentH) / 2;
2298
+ }
2299
+ this.x = anchorX;
2300
+ this.y = anchorY;
2301
+ }
2302
+ resolveConfig() {
2303
+ if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
2304
+ return {};
2305
+ }
2306
+ for (const [maxWidth, overrides] of this._breakpoints) {
2307
+ if (this._viewportWidth <= maxWidth) {
2308
+ return overrides;
2309
+ }
2310
+ }
2311
+ return {};
2312
+ }
2313
+ /** React reconciler update hook */
2314
+ updateConfig(changed) {
2315
+ if ('direction' in changed)
2316
+ this._layoutConfig.direction = changed.direction;
2317
+ if ('gap' in changed)
2318
+ this._layoutConfig.gap = changed.gap;
2319
+ if ('alignment' in changed)
2320
+ this._layoutConfig.alignment = changed.alignment;
2321
+ if ('anchor' in changed)
2322
+ this._anchor = changed.anchor;
2323
+ if ('padding' in changed)
2324
+ this._padding = changed.padding;
2325
+ if ('columns' in changed)
2326
+ this._layoutConfig.columns = changed.columns;
2327
+ this.applyLayoutStyles();
2328
+ if (this._viewportWidth > 0)
2329
+ this.applyAnchor();
2330
+ }
2331
+ destroy(options) {
2332
+ this._items.length = 0;
2333
+ super.destroy(options);
2334
+ }
2335
+ }
2336
+
2337
+ const DECELERATION = 0.95;
2338
+ const MIN_VELOCITY = 0.5;
2339
+ /**
2340
+ * Scrollable container with touch/drag, mouse wheel, and inertia.
2341
+ *
2342
+ * @example
2343
+ * ```ts
2344
+ * const scroll = new ScrollContainer({
2345
+ * width: 600,
2346
+ * height: 400,
2347
+ * direction: 'vertical',
2348
+ * elementsMargin: 8,
2349
+ * });
2350
+ *
2351
+ * for (let i = 0; i < 50; i++) {
2352
+ * scroll.addItem(createRow(i));
2353
+ * }
2354
+ *
2355
+ * scene.container.addChild(scroll);
2356
+ * ```
2357
+ */
2358
+ class ScrollContainer extends pixi_js.Container {
2359
+ __uiComponent = true;
2360
+ _viewport;
2361
+ _internalSetup = true;
2362
+ _content;
2363
+ _maskGfx;
2364
+ _bg = null;
2365
+ _scrollConfig;
2366
+ _items = [];
2367
+ // Scrollbar
2368
+ _scrollbar = null;
2369
+ _scrollbarConfig;
2370
+ // Drag state
2371
+ _dragging = false;
2372
+ _dragStart = { x: 0, y: 0 };
2373
+ _contentStart = { x: 0, y: 0 };
2374
+ _velocity = { x: 0, y: 0 };
2375
+ _lastDragPos = { x: 0, y: 0 };
2376
+ _lastDragTime = 0;
2377
+ _inertiaActive = false;
2378
+ // Bound handlers for cleanup
2379
+ _onTickBound = null;
2380
+ _onWheelBound = null;
2381
+ constructor(config) {
2382
+ super();
2383
+ this._viewport = { width: config.width, height: config.height };
2384
+ this._scrollConfig = {
2385
+ direction: config.direction ?? 'vertical',
2386
+ elementsMargin: config.elementsMargin ?? 0,
2387
+ padding: config.padding ?? 0,
2388
+ borderRadius: config.borderRadius ?? 0,
2389
+ disableEasing: config.disableEasing ?? false,
2390
+ };
2391
+ // Background
2392
+ if (config.backgroundColor !== undefined) {
2393
+ this._bg = new pixi_js.Graphics();
2394
+ this._bg.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
2395
+ .fill(config.backgroundColor);
2396
+ this.addChild(this._bg);
2397
+ }
2398
+ // Mask
2399
+ this._maskGfx = new pixi_js.Graphics();
2400
+ this._maskGfx.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
2401
+ .fill(0xffffff);
2402
+ this.addChild(this._maskGfx);
2403
+ // Content container
2404
+ this._content = new pixi_js.Container();
2405
+ this._content.mask = this._maskGfx;
2406
+ this.addChild(this._content);
2407
+ // Interaction
2408
+ this.eventMode = 'static';
2409
+ this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
2410
+ this.on('pointerdown', this._onPointerDown, this);
2411
+ this.on('pointermove', this._onPointerMove, this);
2412
+ this.on('pointerup', this._onPointerUp, this);
2413
+ this.on('pointerupoutside', this._onPointerUp, this);
2414
+ // Mouse wheel
2415
+ this._onWheelBound = this._onWheel.bind(this);
2416
+ // Scrollbar
2417
+ const sbWidth = config.scrollbarWidth ?? 6;
2418
+ const sbPadding = config.scrollbarPadding ?? 4;
2419
+ this._scrollbarConfig = { width: sbWidth, padding: sbPadding };
2420
+ if (config.scrollbar) {
2421
+ const customThumb = resolveView(config.thumbView);
2422
+ if (customThumb) {
2423
+ this._scrollbar = customThumb;
2424
+ }
2425
+ else {
2426
+ const g = new pixi_js.Graphics();
2427
+ g.roundRect(0, 0, sbWidth, 40, sbWidth / 2).fill(config.scrollbarColor ?? 0xaaaaaa);
2428
+ g.alpha = config.scrollbarAlpha ?? 0.5;
2429
+ this._scrollbar = g;
2430
+ }
2431
+ this._scrollbar.visible = false;
2432
+ super.addChild(this._scrollbar);
2433
+ }
2434
+ this._internalSetup = false;
2435
+ }
2436
+ /**
2437
+ * Override addChild so external children are routed to scroll content.
2438
+ * Enables `<scrollContainer><label /><panel /></scrollContainer>` in React JSX.
2439
+ */
2440
+ addChild(...children) {
2441
+ if (this._internalSetup) {
2442
+ return super.addChild(...children);
2443
+ }
2444
+ for (const child of children) {
2445
+ this.addItem(child);
2446
+ }
2447
+ return children[0];
2448
+ }
2449
+ removeChild(...children) {
2450
+ if (this._internalSetup) {
2451
+ return super.removeChild(...children);
2452
+ }
2453
+ for (const child of children) {
2454
+ const idx = this._items.indexOf(child);
2455
+ if (idx !== -1) {
2456
+ this._items.splice(idx, 1);
2457
+ this._content.removeChild(child);
2458
+ }
2459
+ }
2460
+ this.layoutItems();
2461
+ return children[0];
2462
+ }
2463
+ /** React reconciler update hook */
2464
+ updateConfig(changed) {
2465
+ if ('width' in changed || 'height' in changed) {
2466
+ this.setViewportSize(changed.width ?? this._viewport.width, changed.height ?? this._viewport.height);
2467
+ }
2468
+ }
2469
+ /** Enable mouse wheel scrolling (call after adding to stage) */
2470
+ enableWheel(canvas) {
2471
+ if (this._onWheelBound) {
2472
+ canvas.addEventListener('wheel', this._onWheelBound, { passive: false });
2473
+ }
2474
+ }
2475
+ /** Set scrollable content. Replaces any existing items. */
2476
+ setContent(content) {
2477
+ this.clearItems();
2478
+ const children = [...content.children];
2479
+ for (const child of children) {
2480
+ this.addItem(child);
2481
+ }
2482
+ }
2483
+ /** Add a single item */
2484
+ addItem(child) {
2485
+ this._items.push(child);
2486
+ this._content.addChild(child);
2487
+ this.layoutItems();
2488
+ return this;
2489
+ }
2490
+ /** Remove all items */
2491
+ clearItems() {
2492
+ for (const item of this._items) {
2493
+ this._content.removeChild(item);
2494
+ }
2495
+ this._items.length = 0;
2496
+ }
2497
+ /** Get items */
2498
+ get items() {
2499
+ return this._items;
2500
+ }
2501
+ /** Scroll to make a specific item index visible */
2502
+ scrollToItem(index) {
2503
+ if (index < 0 || index >= this._items.length)
2504
+ return;
2505
+ const item = this._items[index];
2506
+ const isVert = this._scrollConfig.direction !== 'horizontal';
2507
+ if (isVert) {
2508
+ this._content.y = -item.y + this._scrollConfig.padding;
2509
+ }
2510
+ else {
2511
+ this._content.x = -item.x + this._scrollConfig.padding;
2512
+ }
2513
+ this.clampScroll();
2514
+ }
2515
+ /** Current scroll position */
2516
+ get scrollPosition() {
2517
+ return { x: this._content.x, y: this._content.y };
2518
+ }
2519
+ /** Resize the scroll viewport */
2520
+ setViewportSize(width, height) {
2521
+ this._viewport.width = width;
2522
+ this._viewport.height = height;
2523
+ this._maskGfx.clear();
2524
+ this._maskGfx.roundRect(0, 0, width, height, this._scrollConfig.borderRadius).fill(0xffffff);
2525
+ if (this._bg) {
2526
+ this._bg.clear();
2527
+ this._bg.roundRect(0, 0, width, height, this._scrollConfig.borderRadius)
2528
+ .fill(0xffffff); // color will be overridden if needed
2529
+ }
2530
+ this.clampScroll();
2531
+ }
2532
+ // ─── Layout ──────────────────────────────────────────
2533
+ layoutItems() {
2534
+ const { direction, elementsMargin, padding } = this._scrollConfig;
2535
+ const isVert = direction !== 'horizontal';
2536
+ let pos = padding;
2537
+ for (const item of this._items) {
2538
+ if (isVert) {
2539
+ item.x = padding;
2540
+ item.y = pos;
2541
+ pos += item.height + elementsMargin;
2542
+ }
2543
+ else {
2544
+ item.x = pos;
2545
+ item.y = padding;
2546
+ pos += item.width + elementsMargin;
2547
+ }
2548
+ }
2549
+ }
2550
+ // ─── Drag handling ───────────────────────────────────
2551
+ _onPointerDown(e) {
2552
+ this._dragging = true;
2553
+ this._inertiaActive = false;
2554
+ this._dragStart.x = e.globalX;
2555
+ this._dragStart.y = e.globalY;
2556
+ this._contentStart.x = this._content.x;
2557
+ this._contentStart.y = this._content.y;
2558
+ this._lastDragPos.x = e.globalX;
2559
+ this._lastDragPos.y = e.globalY;
2560
+ this._lastDragTime = Date.now();
2561
+ this._velocity.x = 0;
2562
+ this._velocity.y = 0;
2563
+ this.stopInertia();
2564
+ }
2565
+ _onPointerMove(e) {
2566
+ if (!this._dragging)
2567
+ return;
2568
+ const dx = e.globalX - this._dragStart.x;
2569
+ const dy = e.globalY - this._dragStart.y;
2570
+ const { direction } = this._scrollConfig;
2571
+ if (direction !== 'horizontal') {
2572
+ this._content.y = this._contentStart.y + dy;
2573
+ }
2574
+ if (direction !== 'vertical') {
2575
+ this._content.x = this._contentStart.x + dx;
2576
+ }
2577
+ // Track velocity
2578
+ const now = Date.now();
2579
+ const dt = now - this._lastDragTime;
2580
+ if (dt > 0) {
2581
+ this._velocity.x = (e.globalX - this._lastDragPos.x) / dt * 16;
2582
+ this._velocity.y = (e.globalY - this._lastDragPos.y) / dt * 16;
2583
+ }
2584
+ this._lastDragPos.x = e.globalX;
2585
+ this._lastDragPos.y = e.globalY;
2586
+ this._lastDragTime = now;
2587
+ this.clampScroll();
2588
+ }
2589
+ _onPointerUp() {
2590
+ if (!this._dragging)
2591
+ return;
2592
+ this._dragging = false;
2593
+ if (!this._scrollConfig.disableEasing &&
2594
+ (Math.abs(this._velocity.x) > MIN_VELOCITY || Math.abs(this._velocity.y) > MIN_VELOCITY)) {
2595
+ this.startInertia();
2596
+ }
2597
+ }
2598
+ // ─── Inertia ─────────────────────────────────────────
2599
+ startInertia() {
2600
+ this._inertiaActive = true;
2601
+ this._onTickBound = this._inertiaTick.bind(this);
2602
+ pixi_js.Ticker.shared.add(this._onTickBound);
2603
+ }
2604
+ stopInertia() {
2605
+ if (this._onTickBound && this._inertiaActive) {
2606
+ pixi_js.Ticker.shared.remove(this._onTickBound);
2607
+ this._inertiaActive = false;
2608
+ }
2609
+ }
2610
+ _inertiaTick() {
2611
+ const { direction } = this._scrollConfig;
2612
+ if (direction !== 'horizontal') {
2613
+ this._content.y += this._velocity.y;
2614
+ this._velocity.y *= DECELERATION;
2615
+ }
2616
+ if (direction !== 'vertical') {
2617
+ this._content.x += this._velocity.x;
2618
+ this._velocity.x *= DECELERATION;
2619
+ }
2620
+ this.clampScroll();
2621
+ if (Math.abs(this._velocity.x) < MIN_VELOCITY && Math.abs(this._velocity.y) < MIN_VELOCITY) {
2622
+ this.stopInertia();
2623
+ }
2624
+ }
2625
+ // ─── Mouse wheel ─────────────────────────────────────
2626
+ _onWheel(e) {
2627
+ const { direction } = this._scrollConfig;
2628
+ e.preventDefault();
2629
+ if (direction !== 'horizontal') {
2630
+ this._content.y -= e.deltaY;
2631
+ }
2632
+ if (direction !== 'vertical') {
2633
+ this._content.x -= e.deltaX;
2634
+ }
2635
+ this.clampScroll();
2636
+ }
2637
+ // ─── Scroll bounds ───────────────────────────────────
2638
+ clampScroll() {
2639
+ const { direction } = this._scrollConfig;
2640
+ const bounds = this._content.getLocalBounds();
2641
+ if (direction !== 'horizontal') {
2642
+ const contentHeight = bounds.height + bounds.y;
2643
+ const maxScroll = Math.min(0, this._viewport.height - contentHeight);
2644
+ this._content.y = Math.max(maxScroll, Math.min(0, this._content.y));
2645
+ }
2646
+ if (direction !== 'vertical') {
2647
+ const contentWidth = bounds.width + bounds.x;
2648
+ const maxScroll = Math.min(0, this._viewport.width - contentWidth);
2649
+ this._content.x = Math.max(maxScroll, Math.min(0, this._content.x));
2650
+ }
2651
+ this.updateScrollbar();
2652
+ }
2653
+ updateScrollbar() {
2654
+ if (!this._scrollbar)
2655
+ return;
2656
+ const { direction } = this._scrollConfig;
2657
+ const { width: sbW, padding: sbPad } = this._scrollbarConfig;
2658
+ const bounds = this._content.getLocalBounds();
2659
+ const isVert = direction !== 'horizontal';
2660
+ if (isVert) {
2661
+ const contentH = bounds.height + bounds.y;
2662
+ if (contentH <= this._viewport.height) {
2663
+ this._scrollbar.visible = false;
2664
+ return;
2665
+ }
2666
+ this._scrollbar.visible = true;
2667
+ const ratio = this._viewport.height / contentH;
2668
+ const thumbH = Math.max(20, this._viewport.height * ratio);
2669
+ const scrollRange = this._viewport.height - thumbH;
2670
+ const scrollProgress = -this._content.y / (contentH - this._viewport.height);
2671
+ this._scrollbar.x = this._viewport.width - sbW - sbPad;
2672
+ this._scrollbar.y = scrollProgress * scrollRange;
2673
+ this._scrollbar.height = thumbH;
2674
+ this._scrollbar.width = sbW;
2675
+ }
2676
+ else {
2677
+ const contentW = bounds.width + bounds.x;
2678
+ if (contentW <= this._viewport.width) {
2679
+ this._scrollbar.visible = false;
2680
+ return;
2681
+ }
2682
+ this._scrollbar.visible = true;
2683
+ const ratio = this._viewport.width / contentW;
2684
+ const thumbW = Math.max(20, this._viewport.width * ratio);
2685
+ const scrollRange = this._viewport.width - thumbW;
2686
+ const scrollProgress = -this._content.x / (contentW - this._viewport.width);
2687
+ this._scrollbar.y = this._viewport.height - sbW - sbPad;
2688
+ this._scrollbar.x = scrollProgress * scrollRange;
2689
+ this._scrollbar.width = thumbW;
2690
+ this._scrollbar.height = sbW;
2691
+ }
2692
+ }
2693
+ destroy(options) {
2694
+ this.stopInertia();
2695
+ this.off('pointerdown', this._onPointerDown, this);
2696
+ this.off('pointermove', this._onPointerMove, this);
2697
+ this.off('pointerup', this._onPointerUp, this);
2698
+ this.off('pointerupoutside', this._onPointerUp, this);
2699
+ this._items.length = 0;
2700
+ super.destroy(options);
2701
+ }
2702
+ }
2703
+
2704
+ /**
2705
+ * Draggable slider with customizable track, fill, and handle views.
2706
+ *
2707
+ * @example
2708
+ * ```ts
2709
+ * const volume = new Slider({
2710
+ * min: 0, max: 1, value: 0.5,
2711
+ * width: 200, height: 8,
2712
+ * fillColor: 0xffd700,
2713
+ * onUpdate: (v) => console.log('Volume:', v),
2714
+ * });
2715
+ * ```
2716
+ */
2717
+ class Slider extends pixi_js.Container {
2718
+ __uiComponent = true;
2719
+ _track;
2720
+ _fill;
2721
+ _fillMask;
2722
+ _handle;
2723
+ _config;
2724
+ _value;
2725
+ _dragging = false;
2726
+ onUpdate = null;
2727
+ onChange = null;
2728
+ constructor(config = {}) {
2729
+ super();
2730
+ this._config = {
2731
+ min: config.min ?? 0,
2732
+ max: config.max ?? 1,
2733
+ step: config.step ?? 0,
2734
+ width: config.width ?? 200,
2735
+ height: config.height ?? 8,
2736
+ borderRadius: config.borderRadius ?? 4,
2737
+ trackColor: config.trackColor ?? 0x333333,
2738
+ fillColor: config.fillColor ?? 0xffd700,
2739
+ handleRadius: config.handleRadius ?? 12,
2740
+ handleColor: config.handleColor ?? 0xffffff,
2741
+ };
2742
+ this._value = config.value ?? this._config.min;
2743
+ this.onUpdate = config.onUpdate ?? null;
2744
+ this.onChange = config.onChange ?? null;
2745
+ const { width, height, borderRadius, trackColor, fillColor, handleRadius, handleColor } = this._config;
2746
+ // Track
2747
+ const customTrack = resolveView(config.trackView);
2748
+ if (customTrack) {
2749
+ customTrack.width = width;
2750
+ customTrack.height = height;
2751
+ this._track = customTrack;
2752
+ }
2753
+ else {
2754
+ const g = new pixi_js.Graphics();
2755
+ g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
2756
+ this._track = g;
2757
+ }
2758
+ this.addChild(this._track);
2759
+ // Fill
2760
+ const customFill = resolveView(config.fillView);
2761
+ if (customFill) {
2762
+ customFill.width = width;
2763
+ customFill.height = height;
2764
+ this._fill = customFill;
2765
+ }
2766
+ else {
2767
+ const g = new pixi_js.Graphics();
2768
+ g.roundRect(0, 0, width, height, borderRadius).fill(fillColor);
2769
+ this._fill = g;
2770
+ }
2771
+ this.addChild(this._fill);
2772
+ // Fill mask
2773
+ this._fillMask = new pixi_js.Graphics();
2774
+ this.addChild(this._fillMask);
2775
+ this._fill.mask = this._fillMask;
2776
+ // Handle
2777
+ const customHandle = resolveView(config.handleView);
2778
+ if (customHandle) {
2779
+ this._handle = customHandle;
2780
+ }
2781
+ else {
2782
+ const g = new pixi_js.Graphics();
2783
+ g.circle(0, 0, handleRadius).fill(handleColor);
2784
+ this._handle = g;
2785
+ }
2786
+ this._handle.y = height / 2;
2787
+ this.addChild(this._handle);
2788
+ // Interaction
2789
+ this.eventMode = 'static';
2790
+ this.cursor = 'pointer';
2791
+ // Hit area covers track + handle overflow
2792
+ const hitPad = Math.max(handleRadius - height / 2, 0);
2793
+ this.hitArea = { contains: (x, y) => x >= -hitPad && x <= width + hitPad && y >= -hitPad && y <= height + hitPad };
2794
+ this.on('pointerdown', this._onPointerDown, this);
2795
+ this.on('globalpointermove', this._onPointerMove, this);
2796
+ this.on('pointerup', this._onPointerUp, this);
2797
+ this.on('pointerupoutside', this._onPointerUp, this);
2798
+ this._updateVisuals();
2799
+ }
2800
+ /** Current value */
2801
+ get value() {
2802
+ return this._value;
2803
+ }
2804
+ set value(v) {
2805
+ const clamped = this._applyStep(Math.max(this._config.min, Math.min(this._config.max, v)));
2806
+ if (clamped === this._value)
2807
+ return;
2808
+ this._value = clamped;
2809
+ this._updateVisuals();
2810
+ }
2811
+ get min() { return this._config.min; }
2812
+ get max() { return this._config.max; }
2813
+ /** React reconciler update hook */
2814
+ updateConfig(changed) {
2815
+ if ('value' in changed)
2816
+ this.value = changed.value;
2817
+ if ('min' in changed) {
2818
+ this._config.min = changed.min;
2819
+ this._updateVisuals();
2820
+ }
2821
+ if ('max' in changed) {
2822
+ this._config.max = changed.max;
2823
+ this._updateVisuals();
2824
+ }
2825
+ if ('step' in changed)
2826
+ this._config.step = changed.step;
2827
+ if ('onUpdate' in changed)
2828
+ this.onUpdate = changed.onUpdate;
2829
+ if ('onChange' in changed)
2830
+ this.onChange = changed.onChange;
2831
+ }
2832
+ _fraction() {
2833
+ const { min, max } = this._config;
2834
+ return max === min ? 0 : (this._value - min) / (max - min);
2835
+ }
2836
+ _applyStep(v) {
2837
+ const { step, min } = this._config;
2838
+ if (step <= 0)
2839
+ return v;
2840
+ return min + Math.round((v - min) / step) * step;
2841
+ }
2842
+ _updateVisuals() {
2843
+ const frac = this._fraction();
2844
+ const w = this._config.width;
2845
+ const h = this._config.height;
2846
+ // Update fill mask
2847
+ this._fillMask.clear();
2848
+ this._fillMask.rect(0, 0, w * frac, h).fill(0xffffff);
2849
+ // Update handle position
2850
+ this._handle.x = w * frac;
2851
+ }
2852
+ _valueFromPointer(e) {
2853
+ const local = this.toLocal(e.global);
2854
+ const frac = Math.max(0, Math.min(1, local.x / this._config.width));
2855
+ const { min, max } = this._config;
2856
+ return this._applyStep(min + frac * (max - min));
2857
+ }
2858
+ _onPointerDown(e) {
2859
+ this._dragging = true;
2860
+ const newValue = this._valueFromPointer(e);
2861
+ if (newValue !== this._value) {
2862
+ this._value = newValue;
2863
+ this._updateVisuals();
2864
+ this.onUpdate?.(this._value);
2865
+ }
2866
+ }
2867
+ _onPointerMove(e) {
2868
+ if (!this._dragging)
2869
+ return;
2870
+ const newValue = this._valueFromPointer(e);
2871
+ if (newValue !== this._value) {
2872
+ this._value = newValue;
2873
+ this._updateVisuals();
2874
+ this.onUpdate?.(this._value);
2875
+ }
2876
+ }
2877
+ _onPointerUp(_e) {
2878
+ if (!this._dragging)
2879
+ return;
2880
+ this._dragging = false;
2881
+ this.onChange?.(this._value);
2882
+ }
2883
+ destroy(options) {
2884
+ this.off('pointerdown', this._onPointerDown, this);
2885
+ this.off('globalpointermove', this._onPointerMove, this);
2886
+ this.off('pointerup', this._onPointerUp, this);
2887
+ this.off('pointerupoutside', this._onPointerUp, this);
2888
+ this.onUpdate = null;
2889
+ this.onChange = null;
2890
+ super.destroy(options);
2891
+ }
2892
+ }
2893
+
2894
+ /**
2895
+ * Toggle switch with two states.
2896
+ *
2897
+ * Supports custom ON/OFF views or auto-generated Graphics-based toggle.
2898
+ * Click to toggle, or use `forceSwitch(value)` programmatically.
2899
+ *
2900
+ * @example
2901
+ * ```ts
2902
+ * const mute = new Toggle({
2903
+ * value: false,
2904
+ * onColor: 0x22cc22,
2905
+ * onChange: (on) => audioManager.mute(!on),
2906
+ * });
2907
+ * ```
2908
+ */
2909
+ class Toggle extends pixi_js.Container {
2910
+ __uiComponent = true;
2911
+ _value;
2912
+ _onView = null;
2913
+ _offView = null;
2914
+ _handle = null;
2915
+ _trackGfx = null;
2916
+ _config;
2917
+ _useCustomViews;
2918
+ onChange = null;
2919
+ constructor(config = {}) {
2920
+ super();
2921
+ this._config = {
2922
+ width: config.width ?? 52,
2923
+ height: config.height ?? 28,
2924
+ onColor: config.onColor ?? 0x22cc22,
2925
+ offColor: config.offColor ?? 0x666666,
2926
+ handleColor: config.handleColor ?? 0xffffff,
2927
+ handleRadius: config.handleRadius ?? 0, // 0 = auto
2928
+ animationDuration: config.animationDuration ?? 200,
2929
+ };
2930
+ this._value = config.value ?? false;
2931
+ this.onChange = config.onChange ?? null;
2932
+ const customOn = resolveView(config.onView);
2933
+ const customOff = resolveView(config.offView);
2934
+ this._useCustomViews = !!(customOn || customOff);
2935
+ if (this._useCustomViews) {
2936
+ // Custom view mode: show/hide ON and OFF views
2937
+ if (customOn) {
2938
+ this._onView = customOn;
2939
+ this._onView.visible = this._value;
2940
+ this.addChild(this._onView);
2941
+ }
2942
+ if (customOff) {
2943
+ this._offView = customOff;
2944
+ this._offView.visible = !this._value;
2945
+ this.addChild(this._offView);
2946
+ }
2947
+ }
2948
+ else {
2949
+ // Graphics mode: track + sliding handle
2950
+ const { width, height, handleColor } = this._config;
2951
+ const handleRadius = this._config.handleRadius || (height / 2 - 3);
2952
+ this._config.handleRadius = handleRadius;
2953
+ this._trackGfx = new pixi_js.Graphics();
2954
+ this.addChild(this._trackGfx);
2955
+ this._drawTrack();
2956
+ const handle = new pixi_js.Graphics();
2957
+ handle.circle(0, 0, handleRadius).fill(handleColor);
2958
+ handle.y = height / 2;
2959
+ handle.x = this._value ? width - handleRadius - 3 : handleRadius + 3;
2960
+ this._handle = handle;
2961
+ this.addChild(handle);
2962
+ }
2963
+ // Interaction
2964
+ this.eventMode = 'static';
2965
+ this.cursor = 'pointer';
2966
+ this.on('pointertap', this._onTap, this);
2967
+ }
2968
+ /** Current toggle state */
2969
+ get value() {
2970
+ return this._value;
2971
+ }
2972
+ set value(v) {
2973
+ if (v === this._value)
2974
+ return;
2975
+ this.forceSwitch(v);
2976
+ }
2977
+ /** Programmatically switch to a specific state with animation */
2978
+ forceSwitch(value) {
2979
+ this._value = value;
2980
+ this._animateToState();
2981
+ }
2982
+ /** React reconciler update hook */
2983
+ updateConfig(changed) {
2984
+ if ('value' in changed)
2985
+ this.value = changed.value;
2986
+ if ('onChange' in changed)
2987
+ this.onChange = changed.onChange;
2988
+ if ('animationDuration' in changed)
2989
+ this._config.animationDuration = changed.animationDuration;
2990
+ }
2991
+ _onTap() {
2992
+ this._value = !this._value;
2993
+ this._animateToState();
2994
+ this.onChange?.(this._value);
2995
+ }
2996
+ _animateToState() {
2997
+ const duration = this._config.animationDuration;
2998
+ if (this._useCustomViews) {
2999
+ // Custom views: crossfade
3000
+ if (this._onView) {
3001
+ Tween.killTweensOf(this._onView);
3002
+ if (this._value) {
3003
+ this._onView.visible = true;
3004
+ Tween.to(this._onView, { alpha: 1 }, duration);
3005
+ }
3006
+ else {
3007
+ Tween.to(this._onView, { alpha: 0 }, duration).then(() => {
3008
+ if (this._onView)
3009
+ this._onView.visible = false;
3010
+ });
3011
+ }
3012
+ }
3013
+ if (this._offView) {
3014
+ Tween.killTweensOf(this._offView);
3015
+ if (!this._value) {
3016
+ this._offView.visible = true;
3017
+ Tween.to(this._offView, { alpha: 1 }, duration);
3018
+ }
3019
+ else {
3020
+ Tween.to(this._offView, { alpha: 0 }, duration).then(() => {
3021
+ if (this._offView)
3022
+ this._offView.visible = false;
3023
+ });
3024
+ }
3025
+ }
3026
+ }
3027
+ else {
3028
+ // Graphics mode: slide handle + recolor track
3029
+ this._drawTrack();
3030
+ if (this._handle) {
3031
+ const { width } = this._config;
3032
+ const handleRadius = this._config.handleRadius;
3033
+ const targetX = this._value ? width - handleRadius - 3 : handleRadius + 3;
3034
+ Tween.killTweensOf(this._handle);
3035
+ Tween.to(this._handle, { x: targetX }, duration);
3036
+ }
3037
+ }
3038
+ }
3039
+ _drawTrack() {
3040
+ if (!this._trackGfx)
3041
+ return;
3042
+ const { width, height, onColor, offColor } = this._config;
3043
+ const radius = height / 2;
3044
+ this._trackGfx.clear();
3045
+ this._trackGfx.roundRect(0, 0, width, height, radius).fill(this._value ? onColor : offColor);
3046
+ }
3047
+ destroy(options) {
3048
+ this.off('pointertap', this._onTap, this);
3049
+ if (this._handle)
3050
+ Tween.killTweensOf(this._handle);
3051
+ if (this._onView)
3052
+ Tween.killTweensOf(this._onView);
3053
+ if (this._offView)
3054
+ Tween.killTweensOf(this._offView);
3055
+ this.onChange = null;
3056
+ super.destroy(options);
3057
+ }
3058
+ }
3059
+
3060
+ /**
3061
+ * Register all standard PixiJS display objects for JSX use.
3062
+ * Call once at app startup before rendering any React scenes.
3063
+ */
3064
+ function extendPixiElements() {
3065
+ extend({
3066
+ Container: pixi_js.Container,
3067
+ Sprite: pixi_js.Sprite,
3068
+ Graphics: pixi_js.Graphics,
3069
+ Text: pixi_js.Text,
3070
+ AnimatedSprite: pixi_js.AnimatedSprite,
3071
+ NineSliceSprite: pixi_js.NineSliceSprite,
3072
+ TilingSprite: pixi_js.TilingSprite,
3073
+ Mesh: pixi_js.Mesh,
3074
+ MeshPlane: pixi_js.MeshPlane,
3075
+ MeshRope: pixi_js.MeshRope,
3076
+ MeshSimple: pixi_js.MeshSimple,
3077
+ BitmapText: pixi_js.BitmapText,
3078
+ HTMLText: pixi_js.HTMLText,
3079
+ });
3080
+ }
3081
+ /**
3082
+ * Register all engine UI components for JSX use.
3083
+ * Call once at app startup before rendering React scenes that use UI components.
3084
+ *
3085
+ * @example
3086
+ * ```ts
3087
+ * extendPixiElements();
3088
+ * extendUIElements();
3089
+ *
3090
+ * // Now you can use:
3091
+ * // <button text="SPIN" onPress={handler} />
3092
+ * // <flexContainer direction="row" gap={16}>...</flexContainer>
3093
+ * // <label text="Hello" style-fontSize={24} />
3094
+ * ```
3095
+ */
3096
+ function extendUIElements() {
3097
+ extend({
3098
+ Button, Label, Panel, FlexContainer, ProgressBar,
3099
+ ScrollContainer, Modal, Toast, BalanceDisplay, WinDisplay, Layout,
3100
+ Slider, Toggle,
3101
+ });
3102
+ }
3103
+ /**
3104
+ * Register additional custom components for JSX use.
3105
+ * Pass an object mapping component names to their constructors.
3106
+ */
3107
+ function extendCustomElements(components) {
3108
+ extend(components);
297
3109
  }
298
3110
 
299
3111
  /**
@@ -457,8 +3269,9 @@ exports.EngineContext = EngineContext;
457
3269
  exports.ReactScene = ReactScene;
458
3270
  exports.createPixiRoot = createPixiRoot;
459
3271
  exports.extend = extend;
460
- exports.extendLayoutElements = extendLayoutElements;
3272
+ exports.extendCustomElements = extendCustomElements;
461
3273
  exports.extendPixiElements = extendPixiElements;
3274
+ exports.extendUIElements = extendUIElements;
462
3275
  exports.useAudio = useAudio;
463
3276
  exports.useBalance = useBalance;
464
3277
  exports.useEngine = useEngine;