@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/ui.cjs.js CHANGED
@@ -1,9 +1,659 @@
1
1
  'use strict';
2
2
 
3
- require('@pixi/layout');
4
3
  var pixi_js = require('pixi.js');
5
- var ui = require('@pixi/ui');
6
- var components = require('@pixi/layout/components');
4
+
5
+ /**
6
+ * Resolve a ViewInput to a Container instance.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * resolveView('btn-idle') // → Sprite.from('btn-idle')
11
+ * resolveView(someTexture) // → new Sprite(someTexture)
12
+ * resolveView(myCustomContainer) // → myCustomContainer (as-is)
13
+ * resolveView(undefined) // → null
14
+ * ```
15
+ */
16
+ function resolveView(input) {
17
+ if (input == null)
18
+ return null;
19
+ if (typeof input === 'string')
20
+ return pixi_js.Sprite.from(input);
21
+ if (input instanceof pixi_js.Texture)
22
+ return new pixi_js.Sprite(input);
23
+ return input;
24
+ }
25
+
26
+ // ─── Helpers ─────────────────────────────────────────────
27
+ function normalizePadding(p) {
28
+ return typeof p === 'number' ? [p, p, p, p] : p;
29
+ }
30
+ /** Measure a child's size and bounds offset for layout purposes */
31
+ function measureChild(child) {
32
+ const cfg = child._flexConfig;
33
+ if (cfg?.layoutWidth !== undefined && cfg?.layoutHeight !== undefined) {
34
+ return { w: cfg.layoutWidth, h: cfg.layoutHeight, ox: 0, oy: 0 };
35
+ }
36
+ // For FlexContainers, use their explicit size if set
37
+ if (child instanceof FlexContainer) {
38
+ const fc = child;
39
+ if (fc._explicitWidth > 0 && fc._explicitHeight > 0) {
40
+ return { w: fc._explicitWidth, h: fc._explicitHeight, ox: 0, oy: 0 };
41
+ }
42
+ }
43
+ // Use localBounds to get the true visual extent and origin offset.
44
+ // This handles children with non-zero anchors (e.g. Button, Label with centered text).
45
+ const bounds = child.getLocalBounds();
46
+ const w = cfg?.layoutWidth ?? bounds.width;
47
+ const h = cfg?.layoutHeight ?? bounds.height;
48
+ return { w, h, ox: bounds.x, oy: bounds.y };
49
+ }
50
+ function layoutLine(items, isRow, mainSize, justify, align, gap, crossOffset, crossSize) {
51
+ if (items.length === 0)
52
+ return;
53
+ // Compute total fixed main size and flex grow total
54
+ let totalFixed = 0;
55
+ let totalGrow = 0;
56
+ for (const item of items) {
57
+ const grow = item.child._flexConfig?.flexGrow ?? 0;
58
+ if (grow > 0) {
59
+ totalGrow += grow;
60
+ }
61
+ else {
62
+ totalFixed += isRow ? item.w : item.h;
63
+ }
64
+ }
65
+ const totalGap = gap * (items.length - 1);
66
+ const availableForFlex = Math.max(0, mainSize - totalFixed - totalGap);
67
+ // Resolve flex sizes
68
+ if (totalGrow > 0) {
69
+ for (const item of items) {
70
+ const grow = item.child._flexConfig?.flexGrow ?? 0;
71
+ if (grow > 0) {
72
+ const flexSize = (grow / totalGrow) * availableForFlex;
73
+ if (isRow) {
74
+ item.w = flexSize;
75
+ item.child.width = flexSize;
76
+ }
77
+ else {
78
+ item.h = flexSize;
79
+ item.child.height = flexSize;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ // Shrink: if content overflows and mainSize is finite, shrink eligible items
85
+ if (totalGrow === 0 && mainSize > 0) {
86
+ const overflow = totalFixed + totalGap - mainSize;
87
+ if (overflow > 0) {
88
+ let totalShrinkable = 0;
89
+ for (const item of items) {
90
+ const shrink = item.child._flexConfig?.flexShrink ?? 1;
91
+ if (shrink > 0) {
92
+ totalShrinkable += isRow ? item.w : item.h;
93
+ }
94
+ }
95
+ if (totalShrinkable > 0) {
96
+ for (const item of items) {
97
+ const shrink = item.child._flexConfig?.flexShrink ?? 1;
98
+ if (shrink > 0) {
99
+ const itemMain = isRow ? item.w : item.h;
100
+ const reduction = overflow * (itemMain / totalShrinkable);
101
+ const newSize = Math.max(0, itemMain - reduction);
102
+ if (isRow) {
103
+ item.w = newSize;
104
+ item.child.width = newSize;
105
+ }
106
+ else {
107
+ item.h = newSize;
108
+ item.child.height = newSize;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ // Calculate total main size after flex
116
+ let totalMain = totalGap;
117
+ for (const item of items) {
118
+ totalMain += isRow ? item.w : item.h;
119
+ }
120
+ // Justify: compute starting offset and extra spacing
121
+ let mainOffset = 0;
122
+ let extraGap = 0;
123
+ switch (justify) {
124
+ case 'start':
125
+ break;
126
+ case 'center':
127
+ mainOffset = Math.max(0, (mainSize - totalMain) / 2);
128
+ break;
129
+ case 'end':
130
+ mainOffset = Math.max(0, mainSize - totalMain);
131
+ break;
132
+ case 'space-between':
133
+ if (items.length > 1) {
134
+ extraGap = Math.max(0, (mainSize - totalMain + totalGap) / (items.length - 1)) - gap;
135
+ }
136
+ break;
137
+ case 'space-around':
138
+ if (items.length > 0) {
139
+ const totalSpace = Math.max(0, mainSize - totalMain + totalGap);
140
+ const segment = totalSpace / items.length;
141
+ mainOffset = segment / 2;
142
+ extraGap = segment - gap;
143
+ }
144
+ break;
145
+ }
146
+ // Position each item
147
+ let pos = mainOffset;
148
+ for (const item of items) {
149
+ const mainDim = isRow ? item.w : item.h;
150
+ const crossDim = isRow ? item.h : item.w;
151
+ // Cross-axis alignment (alignSelf overrides align)
152
+ const effectiveAlign = (item.child._flexConfig?.alignSelf && item.child._flexConfig.alignSelf !== 'auto')
153
+ ? item.child._flexConfig.alignSelf
154
+ : align;
155
+ let crossPos = crossOffset;
156
+ switch (effectiveAlign) {
157
+ case 'start':
158
+ break;
159
+ case 'center':
160
+ crossPos += (crossSize - crossDim) / 2;
161
+ break;
162
+ case 'end':
163
+ crossPos += crossSize - crossDim;
164
+ break;
165
+ case 'stretch':
166
+ if (isRow) {
167
+ item.child.height = crossSize;
168
+ }
169
+ else {
170
+ item.child.width = crossSize;
171
+ }
172
+ break;
173
+ }
174
+ // Compensate for local bounds offset (e.g. centered anchors)
175
+ if (isRow) {
176
+ item.child.x = pos - item.ox;
177
+ item.child.y = crossPos - item.oy;
178
+ }
179
+ else {
180
+ item.child.x = crossPos - item.ox;
181
+ item.child.y = pos - item.oy;
182
+ }
183
+ pos += mainDim + gap + extraGap;
184
+ }
185
+ }
186
+ // ─── FlexContainer ───────────────────────────────────────
187
+ /**
188
+ * Lightweight flexbox-like layout container for PixiJS.
189
+ *
190
+ * Supports row/column direction, justify/align, gap, padding, wrapping,
191
+ * and flex-grow distribution. Zero external dependencies.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * const toolbar = new FlexContainer({
196
+ * direction: 'row',
197
+ * justifyContent: 'space-between',
198
+ * alignItems: 'center',
199
+ * gap: 16,
200
+ * padding: 12,
201
+ * });
202
+ *
203
+ * toolbar.addFlexChild(button1);
204
+ * toolbar.addFlexChild(button2);
205
+ * toolbar.resize(800, 60);
206
+ * ```
207
+ */
208
+ class FlexContainer extends pixi_js.Container {
209
+ __uiComponent = true;
210
+ _config;
211
+ _padding;
212
+ _maxWidth;
213
+ _maxHeight;
214
+ /** @internal */ _explicitWidth;
215
+ /** @internal */ _explicitHeight;
216
+ _layoutChildren = [];
217
+ _layoutDirty = true;
218
+ constructor(config = {}) {
219
+ super();
220
+ this._config = {
221
+ direction: config.direction ?? 'row',
222
+ justifyContent: config.justifyContent ?? 'start',
223
+ alignItems: config.alignItems ?? 'start',
224
+ gap: config.gap ?? 0,
225
+ flexWrap: config.flexWrap ?? false,
226
+ };
227
+ this._padding = normalizePadding(config.padding ?? 0);
228
+ this._maxWidth = config.maxWidth ?? Infinity;
229
+ this._maxHeight = config.maxHeight ?? Infinity;
230
+ this._explicitWidth = config.width ?? 0;
231
+ this._explicitHeight = config.height ?? 0;
232
+ }
233
+ // ─── Public API ──────────────────────────────────────
234
+ /** Add a child with optional flex config. Also registers in flex layout. */
235
+ addFlexChild(child, flexConfig) {
236
+ if (flexConfig)
237
+ child._flexConfig = flexConfig;
238
+ if (!this._layoutChildren.includes(child)) {
239
+ this._layoutChildren.push(child);
240
+ this._layoutDirty = true;
241
+ }
242
+ super.addChild(child);
243
+ return this;
244
+ }
245
+ /** Remove a child from flex layout and display list */
246
+ removeFlexChild(child) {
247
+ const idx = this._layoutChildren.indexOf(child);
248
+ if (idx !== -1) {
249
+ this._layoutChildren.splice(idx, 1);
250
+ this._layoutDirty = true;
251
+ }
252
+ super.removeChild(child);
253
+ return this;
254
+ }
255
+ /** Remove all flex children */
256
+ clearFlexChildren() {
257
+ for (const child of this._layoutChildren) {
258
+ super.removeChild(child);
259
+ }
260
+ this._layoutChildren.length = 0;
261
+ this._layoutDirty = true;
262
+ return this;
263
+ }
264
+ /**
265
+ * Override addChild so children automatically participate in flex layout.
266
+ * This enables declarative usage from React JSX.
267
+ */
268
+ addChild(...children) {
269
+ for (const child of children) {
270
+ if (!this._layoutChildren.includes(child)) {
271
+ this._layoutChildren.push(child);
272
+ this._layoutDirty = true;
273
+ }
274
+ }
275
+ const result = super.addChild(...children);
276
+ if (this._layoutDirty)
277
+ this.updateLayout();
278
+ return result;
279
+ }
280
+ removeChild(...children) {
281
+ for (const child of children) {
282
+ const idx = this._layoutChildren.indexOf(child);
283
+ if (idx !== -1) {
284
+ this._layoutChildren.splice(idx, 1);
285
+ this._layoutDirty = true;
286
+ }
287
+ }
288
+ return super.removeChild(...children);
289
+ }
290
+ /** Get all flex layout children (read-only) */
291
+ get flexChildren() {
292
+ return this._layoutChildren;
293
+ }
294
+ /** Update the container size and recalculate layout */
295
+ resize(width, height) {
296
+ this._explicitWidth = width;
297
+ this._explicitHeight = height;
298
+ this._layoutDirty = true;
299
+ this.updateLayout();
300
+ }
301
+ /** Update layout direction */
302
+ setDirection(direction) {
303
+ this._config.direction = direction;
304
+ this._layoutDirty = true;
305
+ }
306
+ /** Update justifyContent */
307
+ setJustifyContent(justify) {
308
+ this._config.justifyContent = justify;
309
+ this._layoutDirty = true;
310
+ }
311
+ /** Update alignItems */
312
+ setAlignItems(align) {
313
+ this._config.alignItems = align;
314
+ this._layoutDirty = true;
315
+ }
316
+ /** Update gap */
317
+ setGap(gap) {
318
+ this._config.gap = gap;
319
+ this._layoutDirty = true;
320
+ }
321
+ /** Update padding */
322
+ setPadding(padding) {
323
+ this._padding = normalizePadding(padding);
324
+ this._layoutDirty = true;
325
+ }
326
+ /**
327
+ * Recalculate and apply layout positions for all children.
328
+ * Called automatically by `resize()`. Call manually after
329
+ * adding/removing children without resize.
330
+ */
331
+ updateLayout() {
332
+ this._layoutDirty = false;
333
+ const { direction, justifyContent, alignItems, gap, flexWrap } = this._config;
334
+ const [pt, pr, pb, pl] = this._padding;
335
+ const isRow = direction === 'row';
336
+ const contentW = this._explicitWidth > 0 ? this._explicitWidth - pl - pr : Infinity;
337
+ const contentH = this._explicitHeight > 0 ? this._explicitHeight - pt - pb : Infinity;
338
+ const mainLimit = isRow ? contentW : contentH;
339
+ const crossLimit = isRow ? contentH : contentW;
340
+ // Measure children (skip flexExclude — they position themselves)
341
+ const measured = [];
342
+ for (const child of this._layoutChildren) {
343
+ if (child._flexConfig?.flexExclude)
344
+ continue;
345
+ const { w, h, ox, oy } = measureChild(child);
346
+ measured.push({ child, w, h, ox, oy });
347
+ }
348
+ // Split into lines (if wrapping)
349
+ const lines = [];
350
+ if (flexWrap && mainLimit < Infinity) {
351
+ let currentLine = [];
352
+ let lineMain = 0;
353
+ for (const item of measured) {
354
+ const itemMain = isRow ? item.w : item.h;
355
+ const wouldBe = lineMain + (currentLine.length > 0 ? gap : 0) + itemMain;
356
+ if (currentLine.length > 0 && wouldBe > mainLimit) {
357
+ lines.push(currentLine);
358
+ currentLine = [item];
359
+ lineMain = itemMain;
360
+ }
361
+ else {
362
+ currentLine.push(item);
363
+ lineMain = wouldBe;
364
+ }
365
+ }
366
+ if (currentLine.length > 0)
367
+ lines.push(currentLine);
368
+ }
369
+ else {
370
+ lines.push(measured);
371
+ }
372
+ // Compute cross size per line
373
+ const lineCrossSizes = lines.map((line) => {
374
+ let maxCross = 0;
375
+ for (const item of line) {
376
+ const cross = isRow ? item.h : item.w;
377
+ if (cross > maxCross)
378
+ maxCross = cross;
379
+ }
380
+ return maxCross;
381
+ });
382
+ // Layout each line
383
+ let crossOffset = isRow ? pt : pl;
384
+ for (let i = 0; i < lines.length; i++) {
385
+ const line = lines[i];
386
+ const lineCross = lineCrossSizes[i];
387
+ const mainStart = isRow ? pl : pt;
388
+ // Offset items by padding
389
+ const tempItems = line.map((item) => ({ ...item }));
390
+ // For single-line layouts, use the full available cross space for alignment;
391
+ // for multi-line (wrapping), each line gets its own measured cross size.
392
+ const effectiveCross = lines.length === 1 && crossLimit < Infinity
393
+ ? crossLimit
394
+ : (crossLimit < Infinity ? Math.min(lineCross, crossLimit) : lineCross);
395
+ layoutLine(tempItems, isRow, mainLimit < Infinity ? mainLimit : 0, mainLimit < Infinity ? justifyContent : 'start', alignItems, gap, crossOffset, effectiveCross);
396
+ // Apply main-axis padding offset
397
+ for (const item of tempItems) {
398
+ const origChild = line.find((l) => l.child === item.child);
399
+ origChild.child.x = item.child.x + (isRow ? mainStart : 0);
400
+ origChild.child.y = item.child.y + (isRow ? 0 : mainStart);
401
+ }
402
+ crossOffset += lineCross + gap;
403
+ }
404
+ }
405
+ /** Computed content size (after layout) */
406
+ getContentSize() {
407
+ if (this._layoutDirty)
408
+ this.updateLayout();
409
+ let maxX = 0;
410
+ let maxY = 0;
411
+ for (const child of this._layoutChildren) {
412
+ const { w, h } = measureChild(child);
413
+ maxX = Math.max(maxX, child.x + w);
414
+ maxY = Math.max(maxY, child.y + h);
415
+ }
416
+ const [, pr, pb] = this._padding;
417
+ return { width: maxX + pr, height: maxY + pb };
418
+ }
419
+ /** React reconciler update hook — applies changed config props */
420
+ updateConfig(changed) {
421
+ if ('direction' in changed)
422
+ this.setDirection(changed.direction);
423
+ if ('justifyContent' in changed)
424
+ this.setJustifyContent(changed.justifyContent);
425
+ if ('alignItems' in changed)
426
+ this.setAlignItems(changed.alignItems);
427
+ if ('gap' in changed)
428
+ this.setGap(changed.gap);
429
+ if ('padding' in changed)
430
+ this.setPadding(changed.padding);
431
+ if ('flexWrap' in changed) {
432
+ this._config.flexWrap = changed.flexWrap;
433
+ this._layoutDirty = true;
434
+ }
435
+ if ('width' in changed || 'height' in changed) {
436
+ this.resize(changed.width ?? this._explicitWidth, changed.height ?? this._explicitHeight);
437
+ return; // resize calls updateLayout
438
+ }
439
+ if (this._layoutDirty)
440
+ this.updateLayout();
441
+ }
442
+ destroy(options) {
443
+ this._layoutChildren.length = 0;
444
+ super.destroy(options);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Collection of easing functions for use with Tween and Timeline.
450
+ *
451
+ * All functions take a progress value t (0..1) and return the eased value.
452
+ */
453
+ const Easing = {
454
+ easeOutQuad: (t) => t * (2 - t),
455
+ easeInCubic: (t) => t * t * t,
456
+ easeOutCubic: (t) => --t * t * t + 1,
457
+ easeOutBack: (t) => {
458
+ const c1 = 1.70158;
459
+ const c3 = c1 + 1;
460
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
461
+ }};
462
+
463
+ /**
464
+ * Lightweight tween system integrated with PixiJS Ticker.
465
+ * Zero external dependencies — no GSAP required.
466
+ *
467
+ * All tweens return a Promise that resolves on completion.
468
+ *
469
+ * @example
470
+ * ```ts
471
+ * // Fade in a sprite
472
+ * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
473
+ *
474
+ * // Move and wait
475
+ * await Tween.to(sprite, { x: 500 }, 300);
476
+ *
477
+ * // From a starting value
478
+ * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
479
+ * ```
480
+ */
481
+ class Tween {
482
+ static _tweens = [];
483
+ static _tickerAdded = false;
484
+ /**
485
+ * Animate properties from current values to target values.
486
+ *
487
+ * @param target - Object to animate (Sprite, Container, etc.)
488
+ * @param props - Target property values
489
+ * @param duration - Duration in milliseconds
490
+ * @param easing - Easing function (default: easeOutQuad)
491
+ * @param onUpdate - Progress callback (0..1)
492
+ */
493
+ static to(target, props, duration, easing, onUpdate) {
494
+ return new Promise((resolve) => {
495
+ // Capture starting values
496
+ const from = {};
497
+ for (const key of Object.keys(props)) {
498
+ from[key] = Tween.getProperty(target, key);
499
+ }
500
+ const tween = {
501
+ target,
502
+ from,
503
+ to: { ...props },
504
+ duration: Math.max(1, duration),
505
+ easing: easing ?? Easing.easeOutQuad,
506
+ elapsed: 0,
507
+ delay: 0,
508
+ resolve,
509
+ onUpdate,
510
+ };
511
+ Tween._tweens.push(tween);
512
+ Tween.ensureTicker();
513
+ });
514
+ }
515
+ /**
516
+ * Animate properties from given values to current values.
517
+ */
518
+ static from(target, props, duration, easing, onUpdate) {
519
+ // Capture current values as "to"
520
+ const to = {};
521
+ for (const key of Object.keys(props)) {
522
+ to[key] = Tween.getProperty(target, key);
523
+ Tween.setProperty(target, key, props[key]);
524
+ }
525
+ return Tween.to(target, to, duration, easing, onUpdate);
526
+ }
527
+ /**
528
+ * Animate from one set of values to another.
529
+ */
530
+ static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
531
+ // Set starting values
532
+ for (const key of Object.keys(fromProps)) {
533
+ Tween.setProperty(target, key, fromProps[key]);
534
+ }
535
+ return Tween.to(target, toProps, duration, easing, onUpdate);
536
+ }
537
+ /**
538
+ * Wait for a given duration (useful in timelines).
539
+ * Uses PixiJS Ticker for consistent timing with other tweens.
540
+ */
541
+ static delay(ms) {
542
+ return new Promise((resolve) => {
543
+ let elapsed = 0;
544
+ const onTick = (ticker) => {
545
+ elapsed += ticker.deltaMS;
546
+ if (elapsed >= ms) {
547
+ pixi_js.Ticker.shared.remove(onTick);
548
+ resolve();
549
+ }
550
+ };
551
+ pixi_js.Ticker.shared.add(onTick);
552
+ });
553
+ }
554
+ /**
555
+ * Kill all tweens on a target.
556
+ */
557
+ static killTweensOf(target) {
558
+ Tween._tweens = Tween._tweens.filter((tw) => {
559
+ if (tw.target === target) {
560
+ tw.resolve();
561
+ return false;
562
+ }
563
+ return true;
564
+ });
565
+ }
566
+ /**
567
+ * Kill all active tweens.
568
+ */
569
+ static killAll() {
570
+ for (const tw of Tween._tweens) {
571
+ tw.resolve();
572
+ }
573
+ Tween._tweens.length = 0;
574
+ }
575
+ /** Number of active tweens */
576
+ static get activeTweens() {
577
+ return Tween._tweens.length;
578
+ }
579
+ /**
580
+ * Reset the tween system — kill all tweens and remove the ticker.
581
+ * Useful for cleanup between game instances, tests, or hot-reload.
582
+ */
583
+ static reset() {
584
+ for (const tw of Tween._tweens) {
585
+ tw.resolve();
586
+ }
587
+ Tween._tweens.length = 0;
588
+ if (Tween._tickerAdded) {
589
+ pixi_js.Ticker.shared.remove(Tween.tick);
590
+ Tween._tickerAdded = false;
591
+ }
592
+ }
593
+ // ─── Internal ──────────────────────────────────────────
594
+ static ensureTicker() {
595
+ if (Tween._tickerAdded)
596
+ return;
597
+ Tween._tickerAdded = true;
598
+ pixi_js.Ticker.shared.add(Tween.tick);
599
+ }
600
+ static tick = (ticker) => {
601
+ const dt = ticker.deltaMS;
602
+ const completed = [];
603
+ for (const tw of Tween._tweens) {
604
+ tw.elapsed += dt;
605
+ if (tw.elapsed < tw.delay)
606
+ continue;
607
+ const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
608
+ const t = tw.easing(raw);
609
+ // Interpolate each property
610
+ for (const key of Object.keys(tw.to)) {
611
+ const start = tw.from[key];
612
+ const end = tw.to[key];
613
+ const value = start + (end - start) * t;
614
+ Tween.setProperty(tw.target, key, value);
615
+ }
616
+ tw.onUpdate?.(raw);
617
+ if (raw >= 1) {
618
+ completed.push(tw);
619
+ }
620
+ }
621
+ // Remove completed tweens
622
+ for (const tw of completed) {
623
+ const idx = Tween._tweens.indexOf(tw);
624
+ if (idx !== -1)
625
+ Tween._tweens.splice(idx, 1);
626
+ tw.resolve();
627
+ }
628
+ // Remove ticker when no active tweens
629
+ if (Tween._tweens.length === 0 && Tween._tickerAdded) {
630
+ pixi_js.Ticker.shared.remove(Tween.tick);
631
+ Tween._tickerAdded = false;
632
+ }
633
+ };
634
+ /**
635
+ * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
636
+ */
637
+ static getProperty(target, key) {
638
+ const parts = key.split('.');
639
+ let obj = target;
640
+ for (let i = 0; i < parts.length - 1; i++) {
641
+ obj = obj[parts[i]];
642
+ }
643
+ return obj[parts[parts.length - 1]] ?? 0;
644
+ }
645
+ /**
646
+ * Set a potentially nested property.
647
+ */
648
+ static setProperty(target, key, value) {
649
+ const parts = key.split('.');
650
+ let obj = target;
651
+ for (let i = 0; i < parts.length - 1; i++) {
652
+ obj = obj[parts[i]];
653
+ }
654
+ obj[parts[parts.length - 1]] = value;
655
+ }
656
+ }
7
657
 
8
658
  const DEFAULT_COLORS = {
9
659
  default: 0xffd700,
@@ -13,33 +663,55 @@ const DEFAULT_COLORS = {
13
663
  };
14
664
  function makeGraphicsView(w, h, radius, color) {
15
665
  const g = new pixi_js.Graphics();
16
- g.roundRect(0, 0, w, h, radius).fill(color);
17
- // Highlight overlay
18
- g.roundRect(2, 2, w - 4, h * 0.45, radius).fill({ color: 0xffffff, alpha: 0.1 });
666
+ g.roundRect(-w / 2, -h / 2, w, h, radius).fill(color);
19
667
  return g;
20
668
  }
21
669
  /**
22
- * Interactive button component powered by `@pixi/ui` FancyButton.
670
+ * Interactive button with per-state custom views and animations.
23
671
  *
24
- * Supports both texture-based and Graphics-based rendering with
25
- * per-state views, press animation, and text.
672
+ * Each visual state accepts a `ViewInput`: texture name, Texture, or any Container
673
+ * (Sprite, NineSliceSprite, AnimatedSprite, custom artwork, etc).
674
+ * Falls back to colored Graphics when no custom view is provided.
26
675
  *
27
676
  * @example
28
677
  * ```ts
678
+ * // Graphics-based (quick prototyping)
29
679
  * const btn = new Button({
30
680
  * width: 200, height: 60, borderRadius: 12,
31
681
  * colors: { default: 0x22aa22, hover: 0x33cc33 },
32
682
  * text: 'SPIN',
683
+ * onPress: () => spin(),
33
684
  * });
34
685
  *
35
- * btn.onPress.connect(() => console.log('Clicked!'));
36
- * scene.container.addChild(btn);
686
+ * // Asset-based (production art)
687
+ * const btn = new Button({
688
+ * defaultView: 'btn-idle',
689
+ * hoverView: 'btn-hover',
690
+ * pressedView: 'btn-pressed',
691
+ * disabledView: 'btn-disabled',
692
+ * text: 'SPIN',
693
+ * onPress: () => spin(),
694
+ * });
695
+ *
696
+ * // Custom Container view
697
+ * const btn = new Button({
698
+ * defaultView: myAnimatedSprite,
699
+ * text: 'SPIN',
700
+ * });
37
701
  * ```
38
702
  */
39
- class Button extends ui.FancyButton {
40
- _buttonConfig;
703
+ class Button extends pixi_js.Container {
704
+ __uiComponent = true;
705
+ _views = new Map();
706
+ _state = 'default';
707
+ _enabled = true;
708
+ _config;
709
+ _textObj = null;
710
+ /** Press callback */
711
+ onPress;
41
712
  constructor(config = {}) {
42
- const resolvedConfig = {
713
+ super();
714
+ this._config = {
43
715
  width: config.width ?? 200,
44
716
  height: config.height ?? 60,
45
717
  borderRadius: config.borderRadius ?? 8,
@@ -47,81 +719,202 @@ class Button extends ui.FancyButton {
47
719
  animationDuration: config.animationDuration ?? 100,
48
720
  ...config,
49
721
  };
50
- const colorMap = { ...DEFAULT_COLORS, ...config.colors };
51
- const { width, height, borderRadius } = resolvedConfig;
52
- // Build FancyButton options
53
- const options = {
54
- anchor: 0.5,
55
- animations: {
56
- hover: {
57
- props: { scale: { x: 1.03, y: 1.03 } },
58
- duration: resolvedConfig.animationDuration,
59
- },
60
- pressed: {
61
- props: { scale: { x: resolvedConfig.pressScale, y: resolvedConfig.pressScale } },
62
- duration: resolvedConfig.animationDuration,
63
- },
64
- },
65
- };
66
- // Texture-based views
67
- if (config.textures) {
68
- if (config.textures.default)
69
- options.defaultView = config.textures.default;
70
- if (config.textures.hover)
71
- options.hoverView = config.textures.hover;
72
- if (config.textures.pressed)
73
- options.pressedView = config.textures.pressed;
74
- if (config.textures.disabled)
75
- options.disabledView = config.textures.disabled;
76
- }
77
- else {
78
- // Graphics-based views
79
- options.defaultView = makeGraphicsView(width, height, borderRadius, colorMap.default);
80
- options.hoverView = makeGraphicsView(width, height, borderRadius, colorMap.hover);
81
- options.pressedView = makeGraphicsView(width, height, borderRadius, colorMap.pressed);
82
- options.disabledView = makeGraphicsView(width, height, borderRadius, colorMap.disabled);
83
- }
722
+ this.onPress = config.onPress;
723
+ this._buildViews(config);
84
724
  // Text
85
725
  if (config.text) {
86
- options.text = config.text;
726
+ this._textObj = new pixi_js.Text({
727
+ text: config.text,
728
+ style: {
729
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
730
+ fontSize: 20,
731
+ fill: 0xffffff,
732
+ fontWeight: 'bold',
733
+ ...config.textStyle,
734
+ },
735
+ });
736
+ this._textObj.anchor.set(0.5);
737
+ this.addChild(this._textObj);
87
738
  }
88
- super(options);
89
- this._buttonConfig = resolvedConfig;
739
+ // Interaction
740
+ this.eventMode = 'static';
741
+ this.cursor = 'pointer';
742
+ this.on('pointerover', this._onPointerOver, this);
743
+ this.on('pointerout', this._onPointerOut, this);
744
+ this.on('pointerdown', this._onPointerDown, this);
745
+ this.on('pointerup', this._onPointerUp, this);
746
+ this.on('pointerupoutside', this._onPointerUpOutside, this);
90
747
  if (config.disabled) {
91
748
  this.enabled = false;
92
749
  }
93
750
  }
751
+ /** Current button state */
752
+ get state() {
753
+ return this._state;
754
+ }
94
755
  /** Enable the button */
95
756
  enable() {
96
757
  this.enabled = true;
97
758
  }
98
- /** Disable the button */
99
- disable() {
100
- this.enabled = false;
759
+ /** Disable the button */
760
+ disable() {
761
+ this.enabled = false;
762
+ }
763
+ /** Whether the button is enabled */
764
+ get enabled() {
765
+ return this._enabled;
766
+ }
767
+ set enabled(value) {
768
+ this._enabled = value;
769
+ this.cursor = value ? 'pointer' : 'default';
770
+ this.eventMode = value ? 'static' : 'none';
771
+ this._setState(value ? 'default' : 'disabled');
772
+ }
773
+ /** Whether the button is disabled */
774
+ get disabled() {
775
+ return !this._enabled;
776
+ }
777
+ /** Update button text */
778
+ set text(value) {
779
+ if (this._textObj) {
780
+ this._textObj.text = value;
781
+ }
782
+ }
783
+ // ─── View building ──────────────────────────────────
784
+ _buildViews(config) {
785
+ const colorMap = { ...DEFAULT_COLORS, ...config.colors };
786
+ const { width, height, borderRadius } = this._config;
787
+ const stateViews = {
788
+ default: config.defaultView,
789
+ hover: config.hoverView,
790
+ pressed: config.pressedView,
791
+ disabled: config.disabledView,
792
+ };
793
+ const states = ['default', 'hover', 'pressed', 'disabled'];
794
+ for (const state of states) {
795
+ const customView = resolveView(stateViews[state]);
796
+ const view = customView ?? makeGraphicsView(width, height, borderRadius, colorMap[state]);
797
+ view.visible = state === 'default';
798
+ this._views.set(state, view);
799
+ this.addChild(view);
800
+ }
801
+ }
802
+ _rebuildViews() {
803
+ for (const [, view] of this._views) {
804
+ this.removeChild(view);
805
+ view.destroy();
806
+ }
807
+ this._views.clear();
808
+ this._buildViews(this._config);
809
+ // Re-insert views before text
810
+ if (this._textObj && this._textObj.parent === this) {
811
+ this.setChildIndex(this._textObj, this.children.length - 1);
812
+ }
813
+ }
814
+ // ─── State management ───────────────────────────────
815
+ _setState(state) {
816
+ if (this._state === state)
817
+ return;
818
+ this._state = state;
819
+ for (const [s, view] of this._views) {
820
+ view.visible = s === state;
821
+ }
822
+ }
823
+ _onPointerOver() {
824
+ if (!this._enabled)
825
+ return;
826
+ this._setState('hover');
827
+ Tween.killTweensOf(this);
828
+ Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
829
+ }
830
+ _onPointerOut() {
831
+ if (!this._enabled)
832
+ return;
833
+ this._setState('default');
834
+ Tween.killTweensOf(this);
835
+ Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
836
+ }
837
+ _onPointerDown() {
838
+ if (!this._enabled)
839
+ return;
840
+ this._setState('pressed');
841
+ Tween.killTweensOf(this);
842
+ const s = this._config.pressScale;
843
+ Tween.to(this, { 'scale.x': s, 'scale.y': s }, this._config.animationDuration, Easing.easeOutQuad);
844
+ }
845
+ _onPointerUp() {
846
+ if (!this._enabled)
847
+ return;
848
+ this._setState('hover');
849
+ Tween.killTweensOf(this);
850
+ Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
851
+ this.onPress?.();
852
+ }
853
+ _onPointerUpOutside() {
854
+ if (!this._enabled)
855
+ return;
856
+ this._setState('default');
857
+ Tween.killTweensOf(this);
858
+ Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
859
+ }
860
+ /** React reconciler update hook */
861
+ updateConfig(changed) {
862
+ if ('text' in changed && this._textObj)
863
+ this._textObj.text = changed.text;
864
+ if ('disabled' in changed)
865
+ this.enabled = !changed.disabled;
866
+ if ('onPress' in changed)
867
+ this.onPress = changed.onPress;
868
+ const structural = [
869
+ 'colors', 'width', 'height', 'borderRadius', 'textStyle',
870
+ 'defaultView', 'hoverView', 'pressedView', 'disabledView',
871
+ ];
872
+ const needsRebuild = structural.some((k) => k in changed);
873
+ if (needsRebuild) {
874
+ Object.assign(this._config, changed);
875
+ this._rebuildViews();
876
+ }
101
877
  }
102
- /** Whether the button is disabled */
103
- get disabled() {
104
- return !this.enabled;
878
+ destroy(options) {
879
+ Tween.killTweensOf(this);
880
+ this.off('pointerover', this._onPointerOver, this);
881
+ this.off('pointerout', this._onPointerOut, this);
882
+ this.off('pointerdown', this._onPointerDown, this);
883
+ this.off('pointerup', this._onPointerUp, this);
884
+ this.off('pointerupoutside', this._onPointerUpOutside, this);
885
+ this._views.clear();
886
+ this._textObj = null;
887
+ super.destroy(options);
105
888
  }
106
889
  }
107
890
 
108
- function makeBarGraphics(w, h, radius, color) {
109
- return new pixi_js.Graphics().roundRect(0, 0, w, h, radius).fill(color);
110
- }
111
891
  /**
112
- * Horizontal progress bar powered by `@pixi/ui` ProgressBar.
892
+ * Horizontal progress bar with optional custom track/fill views.
113
893
  *
114
- * Provides optional smooth animated fill via per-frame `update()`.
894
+ * Supports asset-based skinning: provide `trackView` and/or `fillView`
895
+ * as texture names, Textures, or any Container (NineSliceSprite, custom artwork, etc).
896
+ * Falls back to colored Graphics when no custom views are provided.
115
897
  *
116
898
  * @example
117
899
  * ```ts
900
+ * // Graphics-based (quick prototyping)
118
901
  * const bar = new ProgressBar({ width: 300, height: 20, fillColor: 0x22cc22 });
119
- * scene.container.addChild(bar);
120
- * bar.progress = 0.5; // 50%
902
+ * bar.progress = 0.5;
903
+ *
904
+ * // Asset-based (production art)
905
+ * const bar = new ProgressBar({
906
+ * width: 300, height: 20,
907
+ * trackView: 'bar-track',
908
+ * fillView: new NineSliceSprite({ texture: 'bar-fill', ... }),
909
+ * });
910
+ * bar.progress = 0.75;
121
911
  * ```
122
912
  */
123
913
  class ProgressBar extends pixi_js.Container {
124
- _bar;
914
+ __uiComponent = true;
915
+ _track;
916
+ _fill;
917
+ _fillMask;
125
918
  _borderGfx;
126
919
  _config;
127
920
  _progress = 0;
@@ -140,21 +933,39 @@ class ProgressBar extends pixi_js.Container {
140
933
  animationSpeed: config.animationSpeed ?? 0.1,
141
934
  };
142
935
  const { width, height, borderRadius, fillColor, trackColor, borderColor, borderWidth } = this._config;
143
- const bgGraphics = makeBarGraphics(width, height, borderRadius, trackColor);
144
- const fillGraphics = makeBarGraphics(width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1), fillColor);
145
- const options = {
146
- bg: bgGraphics,
147
- fill: fillGraphics,
148
- fillPaddings: {
149
- top: borderWidth,
150
- right: borderWidth,
151
- bottom: borderWidth,
152
- left: borderWidth,
153
- },
154
- progress: 0,
155
- };
156
- this._bar = new ui.ProgressBar(options);
157
- this.addChild(this._bar);
936
+ // Track background custom view or Graphics
937
+ const customTrack = resolveView(config.trackView);
938
+ if (customTrack) {
939
+ customTrack.width = width;
940
+ customTrack.height = height;
941
+ this._track = customTrack;
942
+ }
943
+ else {
944
+ const g = new pixi_js.Graphics();
945
+ g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
946
+ this._track = g;
947
+ }
948
+ this.addChild(this._track);
949
+ // Fill bar — custom view or Graphics
950
+ const customFill = resolveView(config.fillView);
951
+ if (customFill) {
952
+ customFill.x = borderWidth;
953
+ customFill.y = borderWidth;
954
+ customFill.width = width - borderWidth * 2;
955
+ customFill.height = height - borderWidth * 2;
956
+ this._fill = customFill;
957
+ }
958
+ else {
959
+ const g = new pixi_js.Graphics();
960
+ g.roundRect(borderWidth, borderWidth, width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1)).fill(fillColor);
961
+ this._fill = g;
962
+ }
963
+ this.addChild(this._fill);
964
+ // Mask for the fill (controls visible width)
965
+ this._fillMask = new pixi_js.Graphics();
966
+ this._fillMask.rect(0, 0, 0, height).fill(0xffffff);
967
+ this.addChild(this._fillMask);
968
+ this._fill.mask = this._fillMask;
158
969
  // Border overlay
159
970
  this._borderGfx = new pixi_js.Graphics();
160
971
  if (borderColor !== undefined && borderWidth > 0) {
@@ -172,7 +983,7 @@ class ProgressBar extends pixi_js.Container {
172
983
  this._progress = Math.max(0, Math.min(1, value));
173
984
  if (!this._config.animated) {
174
985
  this._displayedProgress = this._progress;
175
- this._bar.progress = this._displayedProgress * 100;
986
+ this.updateMask();
176
987
  }
177
988
  }
178
989
  /**
@@ -183,11 +994,26 @@ class ProgressBar extends pixi_js.Container {
183
994
  return;
184
995
  if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
185
996
  this._displayedProgress = this._progress;
997
+ this.updateMask();
186
998
  return;
187
999
  }
188
1000
  this._displayedProgress +=
189
1001
  (this._progress - this._displayedProgress) * this._config.animationSpeed;
190
- this._bar.progress = this._displayedProgress * 100;
1002
+ this.updateMask();
1003
+ }
1004
+ /** React reconciler update hook */
1005
+ updateConfig(changed) {
1006
+ if ('progress' in changed)
1007
+ this.progress = changed.progress;
1008
+ if ('animated' in changed)
1009
+ this._config.animated = changed.animated;
1010
+ if ('animationSpeed' in changed)
1011
+ this._config.animationSpeed = changed.animationSpeed;
1012
+ }
1013
+ updateMask() {
1014
+ const w = this._config.width * this._displayedProgress;
1015
+ this._fillMask.clear();
1016
+ this._fillMask.rect(0, 0, w, this._config.height).fill(0xffffff);
191
1017
  }
192
1018
  }
193
1019
 
@@ -205,6 +1031,7 @@ class ProgressBar extends pixi_js.Container {
205
1031
  * ```
206
1032
  */
207
1033
  class Label extends pixi_js.Container {
1034
+ __uiComponent = true;
208
1035
  _text;
209
1036
  _maxWidth;
210
1037
  _autoFit;
@@ -271,6 +1098,21 @@ class Label extends pixi_js.Container {
271
1098
  maximumFractionDigits: decimals,
272
1099
  }).format(value);
273
1100
  }
1101
+ /** React reconciler update hook */
1102
+ updateConfig(changed) {
1103
+ if ('text' in changed)
1104
+ this.text = changed.text;
1105
+ if ('maxWidth' in changed)
1106
+ this.maxWidth = changed.maxWidth;
1107
+ if ('autoFit' in changed) {
1108
+ this._autoFit = changed.autoFit;
1109
+ this.fitText();
1110
+ }
1111
+ if ('style' in changed && typeof changed.style === 'object') {
1112
+ Object.assign(this._text.style, changed.style);
1113
+ this.fitText();
1114
+ }
1115
+ }
274
1116
  fitText() {
275
1117
  if (!this._autoFit || this._maxWidth === Infinity)
276
1118
  return;
@@ -283,10 +1125,10 @@ class Label extends pixi_js.Container {
283
1125
  }
284
1126
 
285
1127
  /**
286
- * Background panel powered by `@pixi/layout` LayoutContainer.
1128
+ * Background panel with optional flexbox content layout.
287
1129
  *
288
1130
  * Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
289
- * Children added to `content` participate in flexbox layout automatically.
1131
+ * Children added via `addContent()` participate in flex layout automatically.
290
1132
  *
291
1133
  * @example
292
1134
  * ```ts
@@ -301,9 +1143,14 @@ class Label extends pixi_js.Container {
301
1143
  * });
302
1144
  * ```
303
1145
  */
304
- class Panel extends components.LayoutContainer {
1146
+ class Panel extends pixi_js.Container {
1147
+ __uiComponent = true;
1148
+ _bg;
1149
+ _content;
1150
+ _internalSetup = true;
305
1151
  _panelConfig;
306
1152
  constructor(config = {}) {
1153
+ super();
307
1154
  const resolvedConfig = {
308
1155
  width: config.width ?? 400,
309
1156
  height: config.height ?? 300,
@@ -311,8 +1158,8 @@ class Panel extends components.LayoutContainer {
311
1158
  backgroundAlpha: config.backgroundAlpha ?? 1,
312
1159
  ...config,
313
1160
  };
314
- // If using a 9-slice texture, pass it as a custom background
315
- let customBackground;
1161
+ this._panelConfig = resolvedConfig;
1162
+ // Create background
316
1163
  if (config.nineSliceTexture) {
317
1164
  const texture = typeof config.nineSliceTexture === 'string'
318
1165
  ? pixi_js.Texture.from(config.nineSliceTexture)
@@ -328,126 +1175,110 @@ class Panel extends components.LayoutContainer {
328
1175
  nineSlice.width = resolvedConfig.width;
329
1176
  nineSlice.height = resolvedConfig.height;
330
1177
  nineSlice.alpha = resolvedConfig.backgroundAlpha;
331
- customBackground = nineSlice;
1178
+ this._bg = nineSlice;
332
1179
  }
333
- super(customBackground ? { background: customBackground } : undefined);
334
- this._panelConfig = resolvedConfig;
335
- // Apply layout styles
336
- const layoutStyles = {
337
- width: resolvedConfig.width,
338
- height: resolvedConfig.height,
339
- padding: resolvedConfig.padding,
340
- flexDirection: 'column',
341
- };
342
- // Graphics-based background via layout styles
343
- if (!config.nineSliceTexture) {
344
- layoutStyles.backgroundColor = config.backgroundColor ?? 0x1a1a2e;
345
- layoutStyles.borderRadius = config.borderRadius ?? 0;
1180
+ else {
1181
+ const g = new pixi_js.Graphics();
1182
+ const bgColor = config.backgroundColor ?? 0x1a1a2e;
1183
+ const radius = config.borderRadius ?? 0;
1184
+ g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius).fill(bgColor);
346
1185
  if (config.borderColor !== undefined && config.borderWidth) {
347
- layoutStyles.borderColor = config.borderColor;
348
- layoutStyles.borderWidth = config.borderWidth;
1186
+ g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius)
1187
+ .stroke({ color: config.borderColor, width: config.borderWidth });
349
1188
  }
1189
+ g.alpha = resolvedConfig.backgroundAlpha;
1190
+ this._bg = g;
350
1191
  }
351
- this.layout = layoutStyles;
352
- if (!config.nineSliceTexture) {
353
- this.background.alpha = resolvedConfig.backgroundAlpha;
354
- }
1192
+ this.addChild(this._bg);
1193
+ // Create content flex container
1194
+ this._content = new FlexContainer({
1195
+ ...config.layout,
1196
+ direction: config.layout?.direction ?? 'column',
1197
+ justifyContent: config.layout?.justifyContent ?? 'start',
1198
+ alignItems: config.layout?.alignItems ?? 'start',
1199
+ gap: config.layout?.gap ?? 0,
1200
+ padding: resolvedConfig.padding,
1201
+ width: resolvedConfig.width,
1202
+ height: resolvedConfig.height,
1203
+ });
1204
+ this.addChild(this._content);
1205
+ this._internalSetup = false;
355
1206
  }
356
- /** Access the content container (children added here participate in layout) */
1207
+ /** Access the content flex container — add children here for layout */
357
1208
  get content() {
358
- return this.overflowContainer;
1209
+ return this._content;
1210
+ }
1211
+ /** Convenience: add a child to the content layout */
1212
+ addContent(child) {
1213
+ this._content.addFlexChild(child);
1214
+ this._content.updateLayout();
1215
+ return this;
359
1216
  }
360
1217
  /** Resize the panel */
361
1218
  setSize(width, height) {
362
1219
  this._panelConfig.width = width;
363
1220
  this._panelConfig.height = height;
364
- this._layout?.setStyle({ width, height });
1221
+ // Resize background
1222
+ if (this._bg instanceof pixi_js.NineSliceSprite) {
1223
+ this._bg.width = width;
1224
+ this._bg.height = height;
1225
+ }
1226
+ else if (this._bg instanceof pixi_js.Graphics) {
1227
+ const radius = this._panelConfig.borderRadius ?? 0;
1228
+ const bgColor = this._panelConfig.backgroundColor ?? 0x1a1a2e;
1229
+ this._bg.clear();
1230
+ this._bg.roundRect(0, 0, width, height, radius).fill(bgColor);
1231
+ if (this._panelConfig.borderColor !== undefined && this._panelConfig.borderWidth) {
1232
+ this._bg.roundRect(0, 0, width, height, radius)
1233
+ .stroke({ color: this._panelConfig.borderColor, width: this._panelConfig.borderWidth });
1234
+ }
1235
+ this._bg.alpha = this._panelConfig.backgroundAlpha;
1236
+ }
1237
+ this._content.resize(width, height);
1238
+ }
1239
+ /**
1240
+ * Override addChild so external children are routed to content FlexContainer.
1241
+ * Enables `<panel><label /><button /></panel>` in React JSX.
1242
+ */
1243
+ addChild(...children) {
1244
+ if (this._internalSetup) {
1245
+ return super.addChild(...children);
1246
+ }
1247
+ for (const child of children) {
1248
+ this._content.addFlexChild(child);
1249
+ }
1250
+ this._content.updateLayout();
1251
+ return children[0];
1252
+ }
1253
+ removeChild(...children) {
1254
+ if (this._internalSetup) {
1255
+ return super.removeChild(...children);
1256
+ }
1257
+ for (const child of children) {
1258
+ this._content.removeFlexChild(child);
1259
+ }
1260
+ return children[0];
1261
+ }
1262
+ /** React reconciler update hook */
1263
+ updateConfig(changed) {
1264
+ if ('width' in changed || 'height' in changed) {
1265
+ this.setSize(changed.width ?? this._panelConfig.width, changed.height ?? this._panelConfig.height);
1266
+ }
1267
+ if ('backgroundAlpha' in changed) {
1268
+ this._panelConfig.backgroundAlpha = changed.backgroundAlpha;
1269
+ this._bg.alpha = changed.backgroundAlpha;
1270
+ }
1271
+ }
1272
+ destroy(options) {
1273
+ super.destroy(options);
365
1274
  }
366
1275
  }
367
1276
 
368
- /**
369
- * Collection of easing functions for use with Tween and Timeline.
370
- *
371
- * All functions take a progress value t (0..1) and return the eased value.
372
- */
373
- const Easing = {
374
- linear: (t) => t,
375
- easeInQuad: (t) => t * t,
376
- easeOutQuad: (t) => t * (2 - t),
377
- easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
378
- easeInCubic: (t) => t * t * t,
379
- easeOutCubic: (t) => --t * t * t + 1,
380
- easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
381
- easeInQuart: (t) => t * t * t * t,
382
- easeOutQuart: (t) => 1 - --t * t * t * t,
383
- easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
384
- easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
385
- easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
386
- easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
387
- easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
388
- easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
389
- easeInOutExpo: (t) => t === 0
390
- ? 0
391
- : t === 1
392
- ? 1
393
- : t < 0.5
394
- ? Math.pow(2, 20 * t - 10) / 2
395
- : (2 - Math.pow(2, -20 * t + 10)) / 2,
396
- easeInBack: (t) => {
397
- const c1 = 1.70158;
398
- const c3 = c1 + 1;
399
- return c3 * t * t * t - c1 * t * t;
400
- },
401
- easeOutBack: (t) => {
402
- const c1 = 1.70158;
403
- const c3 = c1 + 1;
404
- return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
405
- },
406
- easeInOutBack: (t) => {
407
- const c1 = 1.70158;
408
- const c2 = c1 * 1.525;
409
- return t < 0.5
410
- ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
411
- : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
412
- },
413
- easeOutBounce: (t) => {
414
- const n1 = 7.5625;
415
- const d1 = 2.75;
416
- if (t < 1 / d1)
417
- return n1 * t * t;
418
- if (t < 2 / d1)
419
- return n1 * (t -= 1.5 / d1) * t + 0.75;
420
- if (t < 2.5 / d1)
421
- return n1 * (t -= 2.25 / d1) * t + 0.9375;
422
- return n1 * (t -= 2.625 / d1) * t + 0.984375;
423
- },
424
- easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
425
- easeInOutBounce: (t) => t < 0.5
426
- ? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
427
- : (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
428
- easeOutElastic: (t) => {
429
- const c4 = (2 * Math.PI) / 3;
430
- return t === 0
431
- ? 0
432
- : t === 1
433
- ? 1
434
- : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
435
- },
436
- easeInElastic: (t) => {
437
- const c4 = (2 * Math.PI) / 3;
438
- return t === 0
439
- ? 0
440
- : t === 1
441
- ? 1
442
- : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
443
- },
444
- };
445
-
446
1277
  /**
447
1278
  * Reactive balance display component.
448
1279
  *
449
1280
  * Automatically formats currency and can animate value changes
450
- * with a smooth countup/countdown effect.
1281
+ * with a smooth countup/countdown effect using engine Tween.
451
1282
  *
452
1283
  * @example
453
1284
  * ```ts
@@ -459,13 +1290,14 @@ const Easing = {
459
1290
  * ```
460
1291
  */
461
1292
  class BalanceDisplay extends pixi_js.Container {
1293
+ __uiComponent = true;
462
1294
  _prefixLabel = null;
463
1295
  _valueLabel;
464
1296
  _config;
465
1297
  _currentValue = 0;
466
1298
  _displayedValue = 0;
467
- _animating = false;
468
- _animationCancelled = false;
1299
+ /** Internal target for Tween animation */
1300
+ _tweenTarget = { value: 0 };
469
1301
  constructor(config = {}) {
470
1302
  super();
471
1303
  this._config = {
@@ -526,37 +1358,13 @@ class BalanceDisplay extends pixi_js.Container {
526
1358
  this._config.currency = currency;
527
1359
  this.updateDisplay();
528
1360
  }
529
- async animateValue(from, to) {
530
- if (this._animating) {
531
- this._animationCancelled = true;
532
- }
533
- this._animating = true;
534
- this._animationCancelled = false;
535
- const duration = this._config.animationDuration;
536
- const startTime = Date.now();
537
- return new Promise((resolve) => {
538
- const tick = () => {
539
- if (this._animationCancelled) {
540
- this._animating = false;
541
- resolve();
542
- return;
543
- }
544
- const elapsed = Date.now() - startTime;
545
- const t = Math.min(elapsed / duration, 1);
546
- const eased = Easing.easeOutCubic(t);
547
- this._displayedValue = from + (to - from) * eased;
548
- this.updateDisplay();
549
- if (t < 1) {
550
- requestAnimationFrame(tick);
551
- }
552
- else {
553
- this._displayedValue = to;
554
- this.updateDisplay();
555
- this._animating = false;
556
- resolve();
557
- }
558
- };
559
- requestAnimationFrame(tick);
1361
+ animateValue(from, to) {
1362
+ // Cancel any running animation
1363
+ Tween.killTweensOf(this._tweenTarget);
1364
+ this._tweenTarget.value = from;
1365
+ Tween.to(this._tweenTarget, { value: to }, this._config.animationDuration, Easing.easeOutCubic, () => {
1366
+ this._displayedValue = this._tweenTarget.value;
1367
+ this.updateDisplay();
560
1368
  });
561
1369
  }
562
1370
  updateDisplay() {
@@ -568,13 +1376,24 @@ class BalanceDisplay extends pixi_js.Container {
568
1376
  this._valueLabel.y = 14;
569
1377
  }
570
1378
  }
1379
+ /** React reconciler update hook */
1380
+ updateConfig(changed) {
1381
+ if ('value' in changed)
1382
+ this.setValue(changed.value);
1383
+ if ('currency' in changed)
1384
+ this.setCurrency(changed.currency);
1385
+ }
1386
+ destroy(options) {
1387
+ Tween.killTweensOf(this._tweenTarget);
1388
+ super.destroy(options);
1389
+ }
571
1390
  }
572
1391
 
573
1392
  /**
574
1393
  * Win amount display with countup animation.
575
1394
  *
576
1395
  * Shows a dramatic countup from 0 to the win amount, with optional
577
- * scale pop effect — typical of slot games.
1396
+ * scale pop effect — typical of slot games. Uses engine Tween system.
578
1397
  *
579
1398
  * @example
580
1399
  * ```ts
@@ -585,9 +1404,11 @@ class BalanceDisplay extends pixi_js.Container {
585
1404
  * ```
586
1405
  */
587
1406
  class WinDisplay extends pixi_js.Container {
1407
+ __uiComponent = true;
588
1408
  _label;
589
1409
  _config;
590
- _cancelCountup = false;
1410
+ /** Internal target for Tween countup */
1411
+ _tweenTarget = { value: 0 };
591
1412
  constructor(config = {}) {
592
1413
  super();
593
1414
  this._config = {
@@ -613,258 +1434,60 @@ class WinDisplay extends pixi_js.Container {
613
1434
  * Show a win with countup animation.
614
1435
  *
615
1436
  * @param amount - Win amount
616
- * @returns Promise that resolves when the animation completes
617
- */
618
- async showWin(amount) {
619
- this.visible = true;
620
- this._cancelCountup = false;
621
- this.alpha = 1;
622
- const duration = this._config.countupDuration;
623
- const startTime = Date.now();
624
- // Scale pop
625
- this.scale.set(0.5);
626
- return new Promise((resolve) => {
627
- const tick = () => {
628
- if (this._cancelCountup) {
629
- this.displayAmount(amount);
630
- resolve();
631
- return;
632
- }
633
- const elapsed = Date.now() - startTime;
634
- const t = Math.min(elapsed / duration, 1);
635
- const eased = Easing.easeOutCubic(t);
636
- // Countup
637
- const current = amount * eased;
638
- this.displayAmount(current);
639
- // Scale animation
640
- const scaleT = Math.min(elapsed / 300, 1);
641
- const scaleEased = Easing.easeOutBack(scaleT);
642
- const targetScale = 1;
643
- this.scale.set(0.5 + (targetScale - 0.5) * scaleEased);
644
- if (t < 1) {
645
- requestAnimationFrame(tick);
646
- }
647
- else {
648
- this.displayAmount(amount);
649
- this.scale.set(1);
650
- resolve();
651
- }
652
- };
653
- requestAnimationFrame(tick);
654
- });
655
- }
656
- /**
657
- * Skip the countup animation and show the final amount immediately.
658
- */
659
- skipCountup(amount) {
660
- this._cancelCountup = true;
661
- this.displayAmount(amount);
662
- this.scale.set(1);
663
- }
664
- /**
665
- * Hide the win display.
666
- */
667
- hide() {
668
- this.visible = false;
669
- this._label.text = '';
670
- }
671
- displayAmount(amount) {
672
- this._label.setCurrency(amount, this._config.currency, this._config.locale);
673
- }
674
- }
675
-
676
- /**
677
- * Lightweight tween system integrated with PixiJS Ticker.
678
- * Zero external dependencies — no GSAP required.
679
- *
680
- * All tweens return a Promise that resolves on completion.
681
- *
682
- * @example
683
- * ```ts
684
- * // Fade in a sprite
685
- * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
686
- *
687
- * // Move and wait
688
- * await Tween.to(sprite, { x: 500 }, 300);
689
- *
690
- * // From a starting value
691
- * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
692
- * ```
693
- */
694
- class Tween {
695
- static _tweens = [];
696
- static _tickerAdded = false;
697
- /**
698
- * Animate properties from current values to target values.
699
- *
700
- * @param target - Object to animate (Sprite, Container, etc.)
701
- * @param props - Target property values
702
- * @param duration - Duration in milliseconds
703
- * @param easing - Easing function (default: easeOutQuad)
704
- * @param onUpdate - Progress callback (0..1)
705
- */
706
- static to(target, props, duration, easing, onUpdate) {
707
- return new Promise((resolve) => {
708
- // Capture starting values
709
- const from = {};
710
- for (const key of Object.keys(props)) {
711
- from[key] = Tween.getProperty(target, key);
712
- }
713
- const tween = {
714
- target,
715
- from,
716
- to: { ...props },
717
- duration: Math.max(1, duration),
718
- easing: easing ?? Easing.easeOutQuad,
719
- elapsed: 0,
720
- delay: 0,
721
- resolve,
722
- onUpdate,
723
- };
724
- Tween._tweens.push(tween);
725
- Tween.ensureTicker();
726
- });
727
- }
728
- /**
729
- * Animate properties from given values to current values.
730
- */
731
- static from(target, props, duration, easing, onUpdate) {
732
- // Capture current values as "to"
733
- const to = {};
734
- for (const key of Object.keys(props)) {
735
- to[key] = Tween.getProperty(target, key);
736
- Tween.setProperty(target, key, props[key]);
737
- }
738
- return Tween.to(target, to, duration, easing, onUpdate);
739
- }
740
- /**
741
- * Animate from one set of values to another.
742
- */
743
- static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
744
- // Set starting values
745
- for (const key of Object.keys(fromProps)) {
746
- Tween.setProperty(target, key, fromProps[key]);
747
- }
748
- return Tween.to(target, toProps, duration, easing, onUpdate);
749
- }
750
- /**
751
- * Wait for a given duration (useful in timelines).
752
- * Uses PixiJS Ticker for consistent timing with other tweens.
753
- */
754
- static delay(ms) {
755
- return new Promise((resolve) => {
756
- let elapsed = 0;
757
- const onTick = (ticker) => {
758
- elapsed += ticker.deltaMS;
759
- if (elapsed >= ms) {
760
- pixi_js.Ticker.shared.remove(onTick);
761
- resolve();
762
- }
763
- };
764
- pixi_js.Ticker.shared.add(onTick);
765
- });
766
- }
767
- /**
768
- * Kill all tweens on a target.
769
- */
770
- static killTweensOf(target) {
771
- Tween._tweens = Tween._tweens.filter((tw) => {
772
- if (tw.target === target) {
773
- tw.resolve();
774
- return false;
775
- }
776
- return true;
777
- });
778
- }
779
- /**
780
- * Kill all active tweens.
781
- */
782
- static killAll() {
783
- for (const tw of Tween._tweens) {
784
- tw.resolve();
785
- }
786
- Tween._tweens.length = 0;
787
- }
788
- /** Number of active tweens */
789
- static get activeTweens() {
790
- return Tween._tweens.length;
791
- }
792
- /**
793
- * Reset the tween system — kill all tweens and remove the ticker.
794
- * Useful for cleanup between game instances, tests, or hot-reload.
795
- */
796
- static reset() {
797
- for (const tw of Tween._tweens) {
798
- tw.resolve();
799
- }
800
- Tween._tweens.length = 0;
801
- if (Tween._tickerAdded) {
802
- pixi_js.Ticker.shared.remove(Tween.tick);
803
- Tween._tickerAdded = false;
804
- }
805
- }
806
- // ─── Internal ──────────────────────────────────────────
807
- static ensureTicker() {
808
- if (Tween._tickerAdded)
809
- return;
810
- Tween._tickerAdded = true;
811
- pixi_js.Ticker.shared.add(Tween.tick);
1437
+ * @returns Promise that resolves when the animation completes
1438
+ */
1439
+ async showWin(amount) {
1440
+ this.visible = true;
1441
+ this.alpha = 1;
1442
+ // Cancel any running animation
1443
+ Tween.killTweensOf(this._tweenTarget);
1444
+ Tween.killTweensOf(this);
1445
+ // Setup countup
1446
+ this._tweenTarget.value = 0;
1447
+ this.scale.set(0.5);
1448
+ // Scale pop animation
1449
+ const scalePromise = Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, 300, Easing.easeOutBack);
1450
+ // Countup animation
1451
+ const countupPromise = Tween.to(this._tweenTarget, { value: amount }, this._config.countupDuration, Easing.easeOutCubic, () => {
1452
+ this.displayAmount(this._tweenTarget.value);
1453
+ });
1454
+ await Promise.all([scalePromise, countupPromise]);
1455
+ // Ensure final value is exact
1456
+ this.displayAmount(amount);
1457
+ this.scale.set(1);
812
1458
  }
813
- static tick = (ticker) => {
814
- const dt = ticker.deltaMS;
815
- const completed = [];
816
- for (const tw of Tween._tweens) {
817
- tw.elapsed += dt;
818
- if (tw.elapsed < tw.delay)
819
- continue;
820
- const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
821
- const t = tw.easing(raw);
822
- // Interpolate each property
823
- for (const key of Object.keys(tw.to)) {
824
- const start = tw.from[key];
825
- const end = tw.to[key];
826
- const value = start + (end - start) * t;
827
- Tween.setProperty(tw.target, key, value);
828
- }
829
- tw.onUpdate?.(raw);
830
- if (raw >= 1) {
831
- completed.push(tw);
832
- }
833
- }
834
- // Remove completed tweens
835
- for (const tw of completed) {
836
- const idx = Tween._tweens.indexOf(tw);
837
- if (idx !== -1)
838
- Tween._tweens.splice(idx, 1);
839
- tw.resolve();
840
- }
841
- // Remove ticker when no active tweens
842
- if (Tween._tweens.length === 0 && Tween._tickerAdded) {
843
- pixi_js.Ticker.shared.remove(Tween.tick);
844
- Tween._tickerAdded = false;
845
- }
846
- };
847
1459
  /**
848
- * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
1460
+ * Skip the countup animation and show the final amount immediately.
849
1461
  */
850
- static getProperty(target, key) {
851
- const parts = key.split('.');
852
- let obj = target;
853
- for (let i = 0; i < parts.length - 1; i++) {
854
- obj = obj[parts[i]];
855
- }
856
- return obj[parts[parts.length - 1]] ?? 0;
1462
+ skipCountup(amount) {
1463
+ Tween.killTweensOf(this._tweenTarget);
1464
+ Tween.killTweensOf(this);
1465
+ this.displayAmount(amount);
1466
+ this.scale.set(1);
857
1467
  }
858
1468
  /**
859
- * Set a potentially nested property.
1469
+ * Hide the win display.
860
1470
  */
861
- static setProperty(target, key, value) {
862
- const parts = key.split('.');
863
- let obj = target;
864
- for (let i = 0; i < parts.length - 1; i++) {
865
- obj = obj[parts[i]];
866
- }
867
- obj[parts[parts.length - 1]] = value;
1471
+ hide() {
1472
+ Tween.killTweensOf(this._tweenTarget);
1473
+ Tween.killTweensOf(this);
1474
+ this.visible = false;
1475
+ this._label.text = '';
1476
+ }
1477
+ displayAmount(amount) {
1478
+ this._label.setCurrency(amount, this._config.currency, this._config.locale);
1479
+ }
1480
+ /** React reconciler update hook */
1481
+ updateConfig(changed) {
1482
+ if ('currency' in changed)
1483
+ this._config.currency = changed.currency;
1484
+ if ('locale' in changed)
1485
+ this._config.locale = changed.locale;
1486
+ }
1487
+ destroy(options) {
1488
+ Tween.killTweensOf(this._tweenTarget);
1489
+ Tween.killTweensOf(this);
1490
+ super.destroy(options);
868
1491
  }
869
1492
  }
870
1493
 
@@ -872,7 +1495,7 @@ class Tween {
872
1495
  * Modal overlay component.
873
1496
  * Shows content on top of a dark overlay with enter/exit animations.
874
1497
  *
875
- * The content container uses `@pixi/layout` for automatic centering.
1498
+ * Content is automatically centered via position calculations.
876
1499
  *
877
1500
  * @example
878
1501
  * ```ts
@@ -883,6 +1506,7 @@ class Tween {
883
1506
  * ```
884
1507
  */
885
1508
  class Modal extends pixi_js.Container {
1509
+ __uiComponent = true;
886
1510
  _overlay;
887
1511
  _contentContainer;
888
1512
  _config;
@@ -952,6 +1576,17 @@ class Modal extends pixi_js.Container {
952
1576
  this._showing = false;
953
1577
  this.onClose?.();
954
1578
  }
1579
+ /** React reconciler update hook */
1580
+ updateConfig(changed) {
1581
+ if ('overlayAlpha' in changed)
1582
+ this._config.overlayAlpha = changed.overlayAlpha;
1583
+ if ('closeOnOverlay' in changed)
1584
+ this._config.closeOnOverlay = changed.closeOnOverlay;
1585
+ if ('animationDuration' in changed)
1586
+ this._config.animationDuration = changed.animationDuration;
1587
+ if ('onClose' in changed)
1588
+ this.onClose = changed.onClose;
1589
+ }
955
1590
  }
956
1591
 
957
1592
  const TOAST_COLORS = {
@@ -971,17 +1606,21 @@ const TOAST_COLORS = {
971
1606
  * ```
972
1607
  */
973
1608
  class Toast extends pixi_js.Container {
1609
+ __uiComponent = true;
974
1610
  _bg;
1611
+ _customBg;
975
1612
  _text;
976
1613
  _config;
977
- _dismissTimeout = null;
1614
+ _dismissPending = false;
978
1615
  constructor(config = {}) {
979
1616
  super();
980
1617
  this._config = {
981
1618
  duration: config.duration ?? 3000,
982
1619
  bottomOffset: config.bottomOffset ?? 60,
983
1620
  };
984
- this._bg = new pixi_js.Graphics();
1621
+ const customBg = resolveView(config.backgroundView);
1622
+ this._customBg = !!customBg;
1623
+ this._bg = customBg ?? new pixi_js.Graphics();
985
1624
  this.addChild(this._bg);
986
1625
  this._text = new pixi_js.Text({
987
1626
  text: '',
@@ -999,18 +1638,27 @@ class Toast extends pixi_js.Container {
999
1638
  * Show a toast message.
1000
1639
  */
1001
1640
  async show(message, type = 'info', viewWidth, viewHeight) {
1002
- if (this._dismissTimeout) {
1003
- clearTimeout(this._dismissTimeout);
1004
- }
1641
+ // Cancel any pending dismiss
1642
+ Tween.killTweensOf(this);
1643
+ this._dismissPending = false;
1005
1644
  this._text.text = message;
1006
1645
  const padding = 20;
1007
1646
  const width = Math.max(200, this._text.width + padding * 2);
1008
1647
  const height = 44;
1009
1648
  const radius = 8;
1010
1649
  // Draw the background
1011
- this._bg.clear();
1012
- this._bg.roundRect(-width / 2, -height / 2, width, height, radius);
1013
- this._bg.fill(TOAST_COLORS[type]);
1650
+ if (this._customBg) {
1651
+ this._bg.width = width;
1652
+ this._bg.height = height;
1653
+ this._bg.x = -width / 2;
1654
+ this._bg.y = -height / 2;
1655
+ }
1656
+ else {
1657
+ const g = this._bg;
1658
+ g.clear();
1659
+ g.roundRect(-width / 2, -height / 2, width, height, radius);
1660
+ g.fill(TOAST_COLORS[type]);
1661
+ }
1014
1662
  // Position
1015
1663
  if (viewWidth && viewHeight) {
1016
1664
  this.x = viewWidth / 2;
@@ -1021,9 +1669,12 @@ class Toast extends pixi_js.Container {
1021
1669
  this.y += 20;
1022
1670
  await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
1023
1671
  if (this._config.duration > 0) {
1024
- this._dismissTimeout = setTimeout(() => {
1025
- this.dismiss();
1026
- }, this._config.duration);
1672
+ this._dismissPending = true;
1673
+ await Tween.delay(this._config.duration);
1674
+ if (this._dismissPending) {
1675
+ this._dismissPending = false;
1676
+ await this.dismiss();
1677
+ }
1027
1678
  }
1028
1679
  }
1029
1680
  /**
@@ -1032,57 +1683,36 @@ class Toast extends pixi_js.Container {
1032
1683
  async dismiss() {
1033
1684
  if (!this.visible)
1034
1685
  return;
1035
- if (this._dismissTimeout) {
1036
- clearTimeout(this._dismissTimeout);
1037
- this._dismissTimeout = null;
1038
- }
1686
+ this._dismissPending = false;
1687
+ Tween.killTweensOf(this);
1039
1688
  await Tween.to(this, { alpha: 0, y: this.y + 20 }, 200, Easing.easeInCubic);
1040
1689
  this.visible = false;
1041
1690
  }
1691
+ /** React reconciler update hook */
1692
+ updateConfig(changed) {
1693
+ if ('duration' in changed)
1694
+ this._config.duration = changed.duration;
1695
+ if ('bottomOffset' in changed)
1696
+ this._config.bottomOffset = changed.bottomOffset;
1697
+ }
1698
+ destroy(options) {
1699
+ this._dismissPending = false;
1700
+ Tween.killTweensOf(this);
1701
+ super.destroy(options);
1702
+ }
1042
1703
  }
1043
1704
 
1044
1705
  // ─── Helpers ─────────────────────────────────────────────
1045
- const ALIGNMENT_MAP = {
1046
- start: 'flex-start',
1047
- center: 'center',
1048
- end: 'flex-end',
1049
- stretch: 'stretch',
1050
- };
1051
- function normalizePadding(padding) {
1052
- if (typeof padding === 'number')
1053
- return [padding, padding, padding, padding];
1054
- return padding;
1055
- }
1056
- function directionToFlexStyles(direction, maxWidth) {
1706
+ function directionToFlex(direction) {
1057
1707
  switch (direction) {
1058
- case 'horizontal':
1059
- return { flexDirection: 'row', flexWrap: 'nowrap' };
1060
- case 'vertical':
1061
- return { flexDirection: 'column', flexWrap: 'nowrap' };
1062
- case 'grid':
1063
- return { flexDirection: 'row', flexWrap: 'wrap' };
1064
- case 'wrap':
1065
- return {
1066
- flexDirection: 'row',
1067
- flexWrap: 'wrap',
1068
- ...(maxWidth < Infinity ? { maxWidth } : {}),
1069
- };
1708
+ case 'horizontal': return { direction: 'row', wrap: false };
1709
+ case 'vertical': return { direction: 'column', wrap: false };
1710
+ case 'grid': return { direction: 'row', wrap: true };
1711
+ case 'wrap': return { direction: 'row', wrap: true };
1070
1712
  }
1071
1713
  }
1072
- function buildLayoutStyles(config) {
1073
- const [pt, pr, pb, pl] = config.padding;
1074
- return {
1075
- ...directionToFlexStyles(config.direction, config.maxWidth),
1076
- gap: config.gap,
1077
- alignItems: ALIGNMENT_MAP[config.alignment],
1078
- paddingTop: pt,
1079
- paddingRight: pr,
1080
- paddingBottom: pb,
1081
- paddingLeft: pl,
1082
- };
1083
- }
1084
1714
  /**
1085
- * Responsive layout container powered by `@pixi/layout` (Yoga flexbox engine).
1715
+ * Responsive layout container powered by a lightweight built-in flex layout solver.
1086
1716
  *
1087
1717
  * Supports horizontal, vertical, grid, and wrap layout modes with
1088
1718
  * alignment, padding, gap, and viewport-anchor positioning.
@@ -1109,6 +1739,7 @@ function buildLayoutStyles(config) {
1109
1739
  * ```
1110
1740
  */
1111
1741
  class Layout extends pixi_js.Container {
1742
+ __uiComponent = true;
1112
1743
  _layoutConfig;
1113
1744
  _padding;
1114
1745
  _anchor;
@@ -1117,6 +1748,7 @@ class Layout extends pixi_js.Container {
1117
1748
  _items = [];
1118
1749
  _viewportWidth = 0;
1119
1750
  _viewportHeight = 0;
1751
+ _flex;
1120
1752
  constructor(config = {}) {
1121
1753
  super();
1122
1754
  this._layoutConfig = {
@@ -1126,7 +1758,7 @@ class Layout extends pixi_js.Container {
1126
1758
  autoLayout: config.autoLayout ?? true,
1127
1759
  columns: config.columns ?? 2,
1128
1760
  };
1129
- this._padding = normalizePadding(config.padding ?? 0);
1761
+ this._padding = config.padding ?? 0;
1130
1762
  this._anchor = config.anchor ?? 'top-left';
1131
1763
  this._maxWidth = config.maxWidth ?? Infinity;
1132
1764
  this._breakpoints = config.breakpoints
@@ -1134,14 +1766,18 @@ class Layout extends pixi_js.Container {
1134
1766
  .map(([w, cfg]) => [Number(w), cfg])
1135
1767
  .sort((a, b) => a[0] - b[0])
1136
1768
  : [];
1769
+ // Create internal FlexContainer
1770
+ this._flex = new FlexContainer();
1771
+ super.addChild(this._flex);
1137
1772
  this.applyLayoutStyles();
1138
1773
  }
1139
1774
  /** Add an item to the layout */
1140
1775
  addItem(child) {
1141
1776
  this._items.push(child);
1142
- this.addChild(child);
1143
- if (this._layoutConfig.direction === 'grid') {
1144
- this.applyGridChildWidth(child);
1777
+ const flexConfig = this.buildFlexItemConfig(child);
1778
+ this._flex.addFlexChild(child, flexConfig);
1779
+ if (this._layoutConfig.autoLayout) {
1780
+ this.applyLayoutStyles();
1145
1781
  }
1146
1782
  return this;
1147
1783
  }
@@ -1150,15 +1786,13 @@ class Layout extends pixi_js.Container {
1150
1786
  const idx = this._items.indexOf(child);
1151
1787
  if (idx !== -1) {
1152
1788
  this._items.splice(idx, 1);
1153
- this.removeChild(child);
1789
+ this._flex.removeFlexChild(child);
1154
1790
  }
1155
1791
  return this;
1156
1792
  }
1157
1793
  /** Remove all items */
1158
1794
  clearItems() {
1159
- for (const item of this._items) {
1160
- this.removeChild(item);
1161
- }
1795
+ this._flex.clearFlexChildren();
1162
1796
  this._items.length = 0;
1163
1797
  return this;
1164
1798
  }
@@ -1181,43 +1815,58 @@ class Layout extends pixi_js.Container {
1181
1815
  const direction = effective.direction ?? this._layoutConfig.direction;
1182
1816
  const gap = effective.gap ?? this._layoutConfig.gap;
1183
1817
  const alignment = effective.alignment ?? this._layoutConfig.alignment;
1184
- effective.columns ?? this._layoutConfig.columns;
1185
- const padding = effective.padding !== undefined
1186
- ? normalizePadding(effective.padding)
1187
- : this._padding;
1818
+ const padding = effective.padding ?? this._padding;
1188
1819
  const maxWidth = effective.maxWidth ?? this._maxWidth;
1189
- const styles = buildLayoutStyles({ direction, gap, alignment, padding, maxWidth });
1190
- this.layout = styles;
1820
+ const { direction: flexDir, wrap } = directionToFlex(direction);
1821
+ this._flex.setDirection(flexDir);
1822
+ this._flex.setJustifyContent('start');
1823
+ this._flex.setAlignItems(alignment);
1824
+ this._flex.setGap(gap);
1825
+ this._flex.setPadding(padding);
1826
+ // Wrap and maxWidth
1827
+ if (wrap) {
1828
+ this._flex._config.flexWrap = true;
1829
+ if (direction === 'grid' && maxWidth < Infinity) {
1830
+ this._flex._maxWidth = maxWidth;
1831
+ }
1832
+ if (maxWidth < Infinity) {
1833
+ this._flex._maxWidth = maxWidth;
1834
+ }
1835
+ }
1836
+ else {
1837
+ this._flex._config.flexWrap = false;
1838
+ }
1839
+ // Update grid child widths
1191
1840
  if (direction === 'grid') {
1192
1841
  for (const item of this._items) {
1193
- this.applyGridChildWidth(item);
1842
+ const flexConfig = this.buildFlexItemConfig(item);
1843
+ item._flexConfig = flexConfig;
1194
1844
  }
1195
1845
  }
1846
+ // Set explicit size if we have viewport dimensions
1847
+ if (this._viewportWidth > 0 && this._viewportHeight > 0) {
1848
+ this._flex.resize(this._viewportWidth, this._viewportHeight);
1849
+ }
1850
+ else {
1851
+ this._flex.updateLayout();
1852
+ }
1196
1853
  }
1197
- applyGridChildWidth(child) {
1854
+ buildFlexItemConfig(_child) {
1198
1855
  const effective = this.resolveConfig();
1856
+ const direction = effective.direction ?? this._layoutConfig.direction;
1199
1857
  const columns = effective.columns ?? this._layoutConfig.columns;
1200
- const gap = effective.gap ?? this._layoutConfig.gap;
1201
- // Account for gaps between columns: total gap space = gap * (columns - 1)
1202
- // Each column gets: (100% - total_gap) / columns
1203
- // We use flexBasis + flexGrow to let Yoga handle the math when gap > 0
1204
- const styles = gap > 0
1205
- ? { flexBasis: 0, flexGrow: 1, flexShrink: 1, maxWidth: `${(100 / columns).toFixed(2)}%` }
1206
- : { width: `${(100 / columns).toFixed(2)}%` };
1207
- if (child._layout) {
1208
- child._layout.setStyle(styles);
1209
- }
1210
- else {
1211
- child.layout = styles;
1858
+ if (direction === 'grid' && columns > 0) {
1859
+ // For grid, give each item a proportional width
1860
+ // The actual pixel width will be computed during layout
1861
+ return { flexGrow: 1 };
1212
1862
  }
1863
+ return undefined;
1213
1864
  }
1214
1865
  applyAnchor() {
1215
1866
  const anchor = this.resolveConfig().anchor ?? this._anchor;
1216
1867
  if (this._viewportWidth === 0 || this._viewportHeight === 0)
1217
1868
  return;
1218
- const bounds = this.getLocalBounds();
1219
- const contentW = bounds.width * this.scale.x;
1220
- const contentH = bounds.height * this.scale.y;
1869
+ const { width: contentW, height: contentH } = this._flex.getContentSize();
1221
1870
  const vw = this._viewportWidth;
1222
1871
  const vh = this._viewportHeight;
1223
1872
  let anchorX = 0;
@@ -1240,8 +1889,8 @@ class Layout extends pixi_js.Container {
1240
1889
  else {
1241
1890
  anchorY = (vh - contentH) / 2;
1242
1891
  }
1243
- this.x = anchorX - bounds.x * this.scale.x;
1244
- this.y = anchorY - bounds.y * this.scale.y;
1892
+ this.x = anchorX;
1893
+ this.y = anchorY;
1245
1894
  }
1246
1895
  resolveConfig() {
1247
1896
  if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
@@ -1254,18 +1903,34 @@ class Layout extends pixi_js.Container {
1254
1903
  }
1255
1904
  return {};
1256
1905
  }
1906
+ /** React reconciler update hook */
1907
+ updateConfig(changed) {
1908
+ if ('direction' in changed)
1909
+ this._layoutConfig.direction = changed.direction;
1910
+ if ('gap' in changed)
1911
+ this._layoutConfig.gap = changed.gap;
1912
+ if ('alignment' in changed)
1913
+ this._layoutConfig.alignment = changed.alignment;
1914
+ if ('anchor' in changed)
1915
+ this._anchor = changed.anchor;
1916
+ if ('padding' in changed)
1917
+ this._padding = changed.padding;
1918
+ if ('columns' in changed)
1919
+ this._layoutConfig.columns = changed.columns;
1920
+ this.applyLayoutStyles();
1921
+ if (this._viewportWidth > 0)
1922
+ this.applyAnchor();
1923
+ }
1924
+ destroy(options) {
1925
+ this._items.length = 0;
1926
+ super.destroy(options);
1927
+ }
1257
1928
  }
1258
1929
 
1259
- const DIRECTION_MAP = {
1260
- vertical: 'vertical',
1261
- horizontal: 'horizontal',
1262
- both: 'bidirectional',
1263
- };
1930
+ const DECELERATION = 0.95;
1931
+ const MIN_VELOCITY = 0.5;
1264
1932
  /**
1265
- * Scrollable container powered by `@pixi/ui` ScrollBox.
1266
- *
1267
- * Provides touch/drag scrolling, mouse wheel support, inertia, and
1268
- * dynamic rendering optimization for off-screen items.
1933
+ * Scrollable container with touch/drag, mouse wheel, and inertia.
1269
1934
  *
1270
1935
  * @example
1271
1936
  * ```ts
@@ -1283,64 +1948,720 @@ const DIRECTION_MAP = {
1283
1948
  * scene.container.addChild(scroll);
1284
1949
  * ```
1285
1950
  */
1286
- class ScrollContainer extends ui.ScrollBox {
1951
+ class ScrollContainer extends pixi_js.Container {
1952
+ __uiComponent = true;
1953
+ _viewport;
1954
+ _internalSetup = true;
1955
+ _content;
1956
+ _maskGfx;
1957
+ _bg = null;
1287
1958
  _scrollConfig;
1959
+ _items = [];
1960
+ // Scrollbar
1961
+ _scrollbar = null;
1962
+ _scrollbarConfig;
1963
+ // Drag state
1964
+ _dragging = false;
1965
+ _dragStart = { x: 0, y: 0 };
1966
+ _contentStart = { x: 0, y: 0 };
1967
+ _velocity = { x: 0, y: 0 };
1968
+ _lastDragPos = { x: 0, y: 0 };
1969
+ _lastDragTime = 0;
1970
+ _inertiaActive = false;
1971
+ // Bound handlers for cleanup
1972
+ _onTickBound = null;
1973
+ _onWheelBound = null;
1288
1974
  constructor(config) {
1289
- const options = {
1290
- width: config.width,
1291
- height: config.height,
1292
- type: DIRECTION_MAP[config.direction ?? 'vertical'],
1293
- radius: config.borderRadius ?? 0,
1975
+ super();
1976
+ this._viewport = { width: config.width, height: config.height };
1977
+ this._scrollConfig = {
1978
+ direction: config.direction ?? 'vertical',
1294
1979
  elementsMargin: config.elementsMargin ?? 0,
1295
1980
  padding: config.padding ?? 0,
1296
- disableDynamicRendering: config.disableDynamicRendering ?? false,
1981
+ borderRadius: config.borderRadius ?? 0,
1297
1982
  disableEasing: config.disableEasing ?? false,
1298
- globalScroll: config.globalScroll ?? true,
1299
1983
  };
1984
+ // Background
1300
1985
  if (config.backgroundColor !== undefined) {
1301
- options.background = config.backgroundColor;
1986
+ this._bg = new pixi_js.Graphics();
1987
+ this._bg.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
1988
+ .fill(config.backgroundColor);
1989
+ this.addChild(this._bg);
1990
+ }
1991
+ // Mask
1992
+ this._maskGfx = new pixi_js.Graphics();
1993
+ this._maskGfx.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
1994
+ .fill(0xffffff);
1995
+ this.addChild(this._maskGfx);
1996
+ // Content container
1997
+ this._content = new pixi_js.Container();
1998
+ this._content.mask = this._maskGfx;
1999
+ this.addChild(this._content);
2000
+ // Interaction
2001
+ this.eventMode = 'static';
2002
+ this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
2003
+ this.on('pointerdown', this._onPointerDown, this);
2004
+ this.on('pointermove', this._onPointerMove, this);
2005
+ this.on('pointerup', this._onPointerUp, this);
2006
+ this.on('pointerupoutside', this._onPointerUp, this);
2007
+ // Mouse wheel
2008
+ this._onWheelBound = this._onWheel.bind(this);
2009
+ // Scrollbar
2010
+ const sbWidth = config.scrollbarWidth ?? 6;
2011
+ const sbPadding = config.scrollbarPadding ?? 4;
2012
+ this._scrollbarConfig = { width: sbWidth, padding: sbPadding };
2013
+ if (config.scrollbar) {
2014
+ const customThumb = resolveView(config.thumbView);
2015
+ if (customThumb) {
2016
+ this._scrollbar = customThumb;
2017
+ }
2018
+ else {
2019
+ const g = new pixi_js.Graphics();
2020
+ g.roundRect(0, 0, sbWidth, 40, sbWidth / 2).fill(config.scrollbarColor ?? 0xaaaaaa);
2021
+ g.alpha = config.scrollbarAlpha ?? 0.5;
2022
+ this._scrollbar = g;
2023
+ }
2024
+ this._scrollbar.visible = false;
2025
+ super.addChild(this._scrollbar);
1302
2026
  }
1303
- super(options);
1304
- this._scrollConfig = config;
2027
+ this._internalSetup = false;
1305
2028
  }
1306
- /** Set scrollable content. Replaces any existing content. */
1307
- setContent(content) {
1308
- // Remove existing items
1309
- const existing = this.items;
1310
- if (existing.length > 0) {
1311
- for (let i = existing.length - 1; i >= 0; i--) {
1312
- this.removeItem(i);
2029
+ /**
2030
+ * Override addChild so external children are routed to scroll content.
2031
+ * Enables `<scrollContainer><label /><panel /></scrollContainer>` in React JSX.
2032
+ */
2033
+ addChild(...children) {
2034
+ if (this._internalSetup) {
2035
+ return super.addChild(...children);
2036
+ }
2037
+ for (const child of children) {
2038
+ this.addItem(child);
2039
+ }
2040
+ return children[0];
2041
+ }
2042
+ removeChild(...children) {
2043
+ if (this._internalSetup) {
2044
+ return super.removeChild(...children);
2045
+ }
2046
+ for (const child of children) {
2047
+ const idx = this._items.indexOf(child);
2048
+ if (idx !== -1) {
2049
+ this._items.splice(idx, 1);
2050
+ this._content.removeChild(child);
1313
2051
  }
1314
2052
  }
1315
- // Add all children from the content container
2053
+ this.layoutItems();
2054
+ return children[0];
2055
+ }
2056
+ /** React reconciler update hook */
2057
+ updateConfig(changed) {
2058
+ if ('width' in changed || 'height' in changed) {
2059
+ this.setViewportSize(changed.width ?? this._viewport.width, changed.height ?? this._viewport.height);
2060
+ }
2061
+ }
2062
+ /** Enable mouse wheel scrolling (call after adding to stage) */
2063
+ enableWheel(canvas) {
2064
+ if (this._onWheelBound) {
2065
+ canvas.addEventListener('wheel', this._onWheelBound, { passive: false });
2066
+ }
2067
+ }
2068
+ /** Set scrollable content. Replaces any existing items. */
2069
+ setContent(content) {
2070
+ this.clearItems();
1316
2071
  const children = [...content.children];
1317
- if (children.length > 0) {
1318
- this.addItems(children);
2072
+ for (const child of children) {
2073
+ this.addItem(child);
1319
2074
  }
1320
2075
  }
1321
2076
  /** Add a single item */
1322
- addItem(...items) {
1323
- this.addItems(items);
1324
- return items[0];
2077
+ addItem(child) {
2078
+ this._items.push(child);
2079
+ this._content.addChild(child);
2080
+ this.layoutItems();
2081
+ return this;
2082
+ }
2083
+ /** Remove all items */
2084
+ clearItems() {
2085
+ for (const item of this._items) {
2086
+ this._content.removeChild(item);
2087
+ }
2088
+ this._items.length = 0;
1325
2089
  }
1326
- /** Scroll to make a specific item/child visible */
2090
+ /** Get items */
2091
+ get items() {
2092
+ return this._items;
2093
+ }
2094
+ /** Scroll to make a specific item index visible */
1327
2095
  scrollToItem(index) {
1328
- this.scrollTo(index);
2096
+ if (index < 0 || index >= this._items.length)
2097
+ return;
2098
+ const item = this._items[index];
2099
+ const isVert = this._scrollConfig.direction !== 'horizontal';
2100
+ if (isVert) {
2101
+ this._content.y = -item.y + this._scrollConfig.padding;
2102
+ }
2103
+ else {
2104
+ this._content.x = -item.x + this._scrollConfig.padding;
2105
+ }
2106
+ this.clampScroll();
1329
2107
  }
1330
2108
  /** Current scroll position */
1331
2109
  get scrollPosition() {
1332
- return { x: this.scrollX, y: this.scrollY };
2110
+ return { x: this._content.x, y: this._content.y };
2111
+ }
2112
+ /** Resize the scroll viewport */
2113
+ setViewportSize(width, height) {
2114
+ this._viewport.width = width;
2115
+ this._viewport.height = height;
2116
+ this._maskGfx.clear();
2117
+ this._maskGfx.roundRect(0, 0, width, height, this._scrollConfig.borderRadius).fill(0xffffff);
2118
+ if (this._bg) {
2119
+ this._bg.clear();
2120
+ this._bg.roundRect(0, 0, width, height, this._scrollConfig.borderRadius)
2121
+ .fill(0xffffff); // color will be overridden if needed
2122
+ }
2123
+ this.clampScroll();
2124
+ }
2125
+ // ─── Layout ──────────────────────────────────────────
2126
+ layoutItems() {
2127
+ const { direction, elementsMargin, padding } = this._scrollConfig;
2128
+ const isVert = direction !== 'horizontal';
2129
+ let pos = padding;
2130
+ for (const item of this._items) {
2131
+ if (isVert) {
2132
+ item.x = padding;
2133
+ item.y = pos;
2134
+ pos += item.height + elementsMargin;
2135
+ }
2136
+ else {
2137
+ item.x = pos;
2138
+ item.y = padding;
2139
+ pos += item.width + elementsMargin;
2140
+ }
2141
+ }
2142
+ }
2143
+ // ─── Drag handling ───────────────────────────────────
2144
+ _onPointerDown(e) {
2145
+ this._dragging = true;
2146
+ this._inertiaActive = false;
2147
+ this._dragStart.x = e.globalX;
2148
+ this._dragStart.y = e.globalY;
2149
+ this._contentStart.x = this._content.x;
2150
+ this._contentStart.y = this._content.y;
2151
+ this._lastDragPos.x = e.globalX;
2152
+ this._lastDragPos.y = e.globalY;
2153
+ this._lastDragTime = Date.now();
2154
+ this._velocity.x = 0;
2155
+ this._velocity.y = 0;
2156
+ this.stopInertia();
2157
+ }
2158
+ _onPointerMove(e) {
2159
+ if (!this._dragging)
2160
+ return;
2161
+ const dx = e.globalX - this._dragStart.x;
2162
+ const dy = e.globalY - this._dragStart.y;
2163
+ const { direction } = this._scrollConfig;
2164
+ if (direction !== 'horizontal') {
2165
+ this._content.y = this._contentStart.y + dy;
2166
+ }
2167
+ if (direction !== 'vertical') {
2168
+ this._content.x = this._contentStart.x + dx;
2169
+ }
2170
+ // Track velocity
2171
+ const now = Date.now();
2172
+ const dt = now - this._lastDragTime;
2173
+ if (dt > 0) {
2174
+ this._velocity.x = (e.globalX - this._lastDragPos.x) / dt * 16;
2175
+ this._velocity.y = (e.globalY - this._lastDragPos.y) / dt * 16;
2176
+ }
2177
+ this._lastDragPos.x = e.globalX;
2178
+ this._lastDragPos.y = e.globalY;
2179
+ this._lastDragTime = now;
2180
+ this.clampScroll();
2181
+ }
2182
+ _onPointerUp() {
2183
+ if (!this._dragging)
2184
+ return;
2185
+ this._dragging = false;
2186
+ if (!this._scrollConfig.disableEasing &&
2187
+ (Math.abs(this._velocity.x) > MIN_VELOCITY || Math.abs(this._velocity.y) > MIN_VELOCITY)) {
2188
+ this.startInertia();
2189
+ }
2190
+ }
2191
+ // ─── Inertia ─────────────────────────────────────────
2192
+ startInertia() {
2193
+ this._inertiaActive = true;
2194
+ this._onTickBound = this._inertiaTick.bind(this);
2195
+ pixi_js.Ticker.shared.add(this._onTickBound);
2196
+ }
2197
+ stopInertia() {
2198
+ if (this._onTickBound && this._inertiaActive) {
2199
+ pixi_js.Ticker.shared.remove(this._onTickBound);
2200
+ this._inertiaActive = false;
2201
+ }
2202
+ }
2203
+ _inertiaTick() {
2204
+ const { direction } = this._scrollConfig;
2205
+ if (direction !== 'horizontal') {
2206
+ this._content.y += this._velocity.y;
2207
+ this._velocity.y *= DECELERATION;
2208
+ }
2209
+ if (direction !== 'vertical') {
2210
+ this._content.x += this._velocity.x;
2211
+ this._velocity.x *= DECELERATION;
2212
+ }
2213
+ this.clampScroll();
2214
+ if (Math.abs(this._velocity.x) < MIN_VELOCITY && Math.abs(this._velocity.y) < MIN_VELOCITY) {
2215
+ this.stopInertia();
2216
+ }
2217
+ }
2218
+ // ─── Mouse wheel ─────────────────────────────────────
2219
+ _onWheel(e) {
2220
+ const { direction } = this._scrollConfig;
2221
+ e.preventDefault();
2222
+ if (direction !== 'horizontal') {
2223
+ this._content.y -= e.deltaY;
2224
+ }
2225
+ if (direction !== 'vertical') {
2226
+ this._content.x -= e.deltaX;
2227
+ }
2228
+ this.clampScroll();
2229
+ }
2230
+ // ─── Scroll bounds ───────────────────────────────────
2231
+ clampScroll() {
2232
+ const { direction } = this._scrollConfig;
2233
+ const bounds = this._content.getLocalBounds();
2234
+ if (direction !== 'horizontal') {
2235
+ const contentHeight = bounds.height + bounds.y;
2236
+ const maxScroll = Math.min(0, this._viewport.height - contentHeight);
2237
+ this._content.y = Math.max(maxScroll, Math.min(0, this._content.y));
2238
+ }
2239
+ if (direction !== 'vertical') {
2240
+ const contentWidth = bounds.width + bounds.x;
2241
+ const maxScroll = Math.min(0, this._viewport.width - contentWidth);
2242
+ this._content.x = Math.max(maxScroll, Math.min(0, this._content.x));
2243
+ }
2244
+ this.updateScrollbar();
2245
+ }
2246
+ updateScrollbar() {
2247
+ if (!this._scrollbar)
2248
+ return;
2249
+ const { direction } = this._scrollConfig;
2250
+ const { width: sbW, padding: sbPad } = this._scrollbarConfig;
2251
+ const bounds = this._content.getLocalBounds();
2252
+ const isVert = direction !== 'horizontal';
2253
+ if (isVert) {
2254
+ const contentH = bounds.height + bounds.y;
2255
+ if (contentH <= this._viewport.height) {
2256
+ this._scrollbar.visible = false;
2257
+ return;
2258
+ }
2259
+ this._scrollbar.visible = true;
2260
+ const ratio = this._viewport.height / contentH;
2261
+ const thumbH = Math.max(20, this._viewport.height * ratio);
2262
+ const scrollRange = this._viewport.height - thumbH;
2263
+ const scrollProgress = -this._content.y / (contentH - this._viewport.height);
2264
+ this._scrollbar.x = this._viewport.width - sbW - sbPad;
2265
+ this._scrollbar.y = scrollProgress * scrollRange;
2266
+ this._scrollbar.height = thumbH;
2267
+ this._scrollbar.width = sbW;
2268
+ }
2269
+ else {
2270
+ const contentW = bounds.width + bounds.x;
2271
+ if (contentW <= this._viewport.width) {
2272
+ this._scrollbar.visible = false;
2273
+ return;
2274
+ }
2275
+ this._scrollbar.visible = true;
2276
+ const ratio = this._viewport.width / contentW;
2277
+ const thumbW = Math.max(20, this._viewport.width * ratio);
2278
+ const scrollRange = this._viewport.width - thumbW;
2279
+ const scrollProgress = -this._content.x / (contentW - this._viewport.width);
2280
+ this._scrollbar.y = this._viewport.height - sbW - sbPad;
2281
+ this._scrollbar.x = scrollProgress * scrollRange;
2282
+ this._scrollbar.width = thumbW;
2283
+ this._scrollbar.height = sbW;
2284
+ }
2285
+ }
2286
+ destroy(options) {
2287
+ this.stopInertia();
2288
+ this.off('pointerdown', this._onPointerDown, this);
2289
+ this.off('pointermove', this._onPointerMove, this);
2290
+ this.off('pointerup', this._onPointerUp, this);
2291
+ this.off('pointerupoutside', this._onPointerUp, this);
2292
+ this._items.length = 0;
2293
+ super.destroy(options);
2294
+ }
2295
+ }
2296
+
2297
+ /**
2298
+ * Draggable slider with customizable track, fill, and handle views.
2299
+ *
2300
+ * @example
2301
+ * ```ts
2302
+ * const volume = new Slider({
2303
+ * min: 0, max: 1, value: 0.5,
2304
+ * width: 200, height: 8,
2305
+ * fillColor: 0xffd700,
2306
+ * onUpdate: (v) => console.log('Volume:', v),
2307
+ * });
2308
+ * ```
2309
+ */
2310
+ class Slider extends pixi_js.Container {
2311
+ __uiComponent = true;
2312
+ _track;
2313
+ _fill;
2314
+ _fillMask;
2315
+ _handle;
2316
+ _config;
2317
+ _value;
2318
+ _dragging = false;
2319
+ onUpdate = null;
2320
+ onChange = null;
2321
+ constructor(config = {}) {
2322
+ super();
2323
+ this._config = {
2324
+ min: config.min ?? 0,
2325
+ max: config.max ?? 1,
2326
+ step: config.step ?? 0,
2327
+ width: config.width ?? 200,
2328
+ height: config.height ?? 8,
2329
+ borderRadius: config.borderRadius ?? 4,
2330
+ trackColor: config.trackColor ?? 0x333333,
2331
+ fillColor: config.fillColor ?? 0xffd700,
2332
+ handleRadius: config.handleRadius ?? 12,
2333
+ handleColor: config.handleColor ?? 0xffffff,
2334
+ };
2335
+ this._value = config.value ?? this._config.min;
2336
+ this.onUpdate = config.onUpdate ?? null;
2337
+ this.onChange = config.onChange ?? null;
2338
+ const { width, height, borderRadius, trackColor, fillColor, handleRadius, handleColor } = this._config;
2339
+ // Track
2340
+ const customTrack = resolveView(config.trackView);
2341
+ if (customTrack) {
2342
+ customTrack.width = width;
2343
+ customTrack.height = height;
2344
+ this._track = customTrack;
2345
+ }
2346
+ else {
2347
+ const g = new pixi_js.Graphics();
2348
+ g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
2349
+ this._track = g;
2350
+ }
2351
+ this.addChild(this._track);
2352
+ // Fill
2353
+ const customFill = resolveView(config.fillView);
2354
+ if (customFill) {
2355
+ customFill.width = width;
2356
+ customFill.height = height;
2357
+ this._fill = customFill;
2358
+ }
2359
+ else {
2360
+ const g = new pixi_js.Graphics();
2361
+ g.roundRect(0, 0, width, height, borderRadius).fill(fillColor);
2362
+ this._fill = g;
2363
+ }
2364
+ this.addChild(this._fill);
2365
+ // Fill mask
2366
+ this._fillMask = new pixi_js.Graphics();
2367
+ this.addChild(this._fillMask);
2368
+ this._fill.mask = this._fillMask;
2369
+ // Handle
2370
+ const customHandle = resolveView(config.handleView);
2371
+ if (customHandle) {
2372
+ this._handle = customHandle;
2373
+ }
2374
+ else {
2375
+ const g = new pixi_js.Graphics();
2376
+ g.circle(0, 0, handleRadius).fill(handleColor);
2377
+ this._handle = g;
2378
+ }
2379
+ this._handle.y = height / 2;
2380
+ this.addChild(this._handle);
2381
+ // Interaction
2382
+ this.eventMode = 'static';
2383
+ this.cursor = 'pointer';
2384
+ // Hit area covers track + handle overflow
2385
+ const hitPad = Math.max(handleRadius - height / 2, 0);
2386
+ this.hitArea = { contains: (x, y) => x >= -hitPad && x <= width + hitPad && y >= -hitPad && y <= height + hitPad };
2387
+ this.on('pointerdown', this._onPointerDown, this);
2388
+ this.on('globalpointermove', this._onPointerMove, this);
2389
+ this.on('pointerup', this._onPointerUp, this);
2390
+ this.on('pointerupoutside', this._onPointerUp, this);
2391
+ this._updateVisuals();
2392
+ }
2393
+ /** Current value */
2394
+ get value() {
2395
+ return this._value;
2396
+ }
2397
+ set value(v) {
2398
+ const clamped = this._applyStep(Math.max(this._config.min, Math.min(this._config.max, v)));
2399
+ if (clamped === this._value)
2400
+ return;
2401
+ this._value = clamped;
2402
+ this._updateVisuals();
2403
+ }
2404
+ get min() { return this._config.min; }
2405
+ get max() { return this._config.max; }
2406
+ /** React reconciler update hook */
2407
+ updateConfig(changed) {
2408
+ if ('value' in changed)
2409
+ this.value = changed.value;
2410
+ if ('min' in changed) {
2411
+ this._config.min = changed.min;
2412
+ this._updateVisuals();
2413
+ }
2414
+ if ('max' in changed) {
2415
+ this._config.max = changed.max;
2416
+ this._updateVisuals();
2417
+ }
2418
+ if ('step' in changed)
2419
+ this._config.step = changed.step;
2420
+ if ('onUpdate' in changed)
2421
+ this.onUpdate = changed.onUpdate;
2422
+ if ('onChange' in changed)
2423
+ this.onChange = changed.onChange;
2424
+ }
2425
+ _fraction() {
2426
+ const { min, max } = this._config;
2427
+ return max === min ? 0 : (this._value - min) / (max - min);
2428
+ }
2429
+ _applyStep(v) {
2430
+ const { step, min } = this._config;
2431
+ if (step <= 0)
2432
+ return v;
2433
+ return min + Math.round((v - min) / step) * step;
2434
+ }
2435
+ _updateVisuals() {
2436
+ const frac = this._fraction();
2437
+ const w = this._config.width;
2438
+ const h = this._config.height;
2439
+ // Update fill mask
2440
+ this._fillMask.clear();
2441
+ this._fillMask.rect(0, 0, w * frac, h).fill(0xffffff);
2442
+ // Update handle position
2443
+ this._handle.x = w * frac;
2444
+ }
2445
+ _valueFromPointer(e) {
2446
+ const local = this.toLocal(e.global);
2447
+ const frac = Math.max(0, Math.min(1, local.x / this._config.width));
2448
+ const { min, max } = this._config;
2449
+ return this._applyStep(min + frac * (max - min));
2450
+ }
2451
+ _onPointerDown(e) {
2452
+ this._dragging = true;
2453
+ const newValue = this._valueFromPointer(e);
2454
+ if (newValue !== this._value) {
2455
+ this._value = newValue;
2456
+ this._updateVisuals();
2457
+ this.onUpdate?.(this._value);
2458
+ }
2459
+ }
2460
+ _onPointerMove(e) {
2461
+ if (!this._dragging)
2462
+ return;
2463
+ const newValue = this._valueFromPointer(e);
2464
+ if (newValue !== this._value) {
2465
+ this._value = newValue;
2466
+ this._updateVisuals();
2467
+ this.onUpdate?.(this._value);
2468
+ }
2469
+ }
2470
+ _onPointerUp(_e) {
2471
+ if (!this._dragging)
2472
+ return;
2473
+ this._dragging = false;
2474
+ this.onChange?.(this._value);
2475
+ }
2476
+ destroy(options) {
2477
+ this.off('pointerdown', this._onPointerDown, this);
2478
+ this.off('globalpointermove', this._onPointerMove, this);
2479
+ this.off('pointerup', this._onPointerUp, this);
2480
+ this.off('pointerupoutside', this._onPointerUp, this);
2481
+ this.onUpdate = null;
2482
+ this.onChange = null;
2483
+ super.destroy(options);
2484
+ }
2485
+ }
2486
+
2487
+ /**
2488
+ * Toggle switch with two states.
2489
+ *
2490
+ * Supports custom ON/OFF views or auto-generated Graphics-based toggle.
2491
+ * Click to toggle, or use `forceSwitch(value)` programmatically.
2492
+ *
2493
+ * @example
2494
+ * ```ts
2495
+ * const mute = new Toggle({
2496
+ * value: false,
2497
+ * onColor: 0x22cc22,
2498
+ * onChange: (on) => audioManager.mute(!on),
2499
+ * });
2500
+ * ```
2501
+ */
2502
+ class Toggle extends pixi_js.Container {
2503
+ __uiComponent = true;
2504
+ _value;
2505
+ _onView = null;
2506
+ _offView = null;
2507
+ _handle = null;
2508
+ _trackGfx = null;
2509
+ _config;
2510
+ _useCustomViews;
2511
+ onChange = null;
2512
+ constructor(config = {}) {
2513
+ super();
2514
+ this._config = {
2515
+ width: config.width ?? 52,
2516
+ height: config.height ?? 28,
2517
+ onColor: config.onColor ?? 0x22cc22,
2518
+ offColor: config.offColor ?? 0x666666,
2519
+ handleColor: config.handleColor ?? 0xffffff,
2520
+ handleRadius: config.handleRadius ?? 0, // 0 = auto
2521
+ animationDuration: config.animationDuration ?? 200,
2522
+ };
2523
+ this._value = config.value ?? false;
2524
+ this.onChange = config.onChange ?? null;
2525
+ const customOn = resolveView(config.onView);
2526
+ const customOff = resolveView(config.offView);
2527
+ this._useCustomViews = !!(customOn || customOff);
2528
+ if (this._useCustomViews) {
2529
+ // Custom view mode: show/hide ON and OFF views
2530
+ if (customOn) {
2531
+ this._onView = customOn;
2532
+ this._onView.visible = this._value;
2533
+ this.addChild(this._onView);
2534
+ }
2535
+ if (customOff) {
2536
+ this._offView = customOff;
2537
+ this._offView.visible = !this._value;
2538
+ this.addChild(this._offView);
2539
+ }
2540
+ }
2541
+ else {
2542
+ // Graphics mode: track + sliding handle
2543
+ const { width, height, handleColor } = this._config;
2544
+ const handleRadius = this._config.handleRadius || (height / 2 - 3);
2545
+ this._config.handleRadius = handleRadius;
2546
+ this._trackGfx = new pixi_js.Graphics();
2547
+ this.addChild(this._trackGfx);
2548
+ this._drawTrack();
2549
+ const handle = new pixi_js.Graphics();
2550
+ handle.circle(0, 0, handleRadius).fill(handleColor);
2551
+ handle.y = height / 2;
2552
+ handle.x = this._value ? width - handleRadius - 3 : handleRadius + 3;
2553
+ this._handle = handle;
2554
+ this.addChild(handle);
2555
+ }
2556
+ // Interaction
2557
+ this.eventMode = 'static';
2558
+ this.cursor = 'pointer';
2559
+ this.on('pointertap', this._onTap, this);
2560
+ }
2561
+ /** Current toggle state */
2562
+ get value() {
2563
+ return this._value;
2564
+ }
2565
+ set value(v) {
2566
+ if (v === this._value)
2567
+ return;
2568
+ this.forceSwitch(v);
2569
+ }
2570
+ /** Programmatically switch to a specific state with animation */
2571
+ forceSwitch(value) {
2572
+ this._value = value;
2573
+ this._animateToState();
2574
+ }
2575
+ /** React reconciler update hook */
2576
+ updateConfig(changed) {
2577
+ if ('value' in changed)
2578
+ this.value = changed.value;
2579
+ if ('onChange' in changed)
2580
+ this.onChange = changed.onChange;
2581
+ if ('animationDuration' in changed)
2582
+ this._config.animationDuration = changed.animationDuration;
2583
+ }
2584
+ _onTap() {
2585
+ this._value = !this._value;
2586
+ this._animateToState();
2587
+ this.onChange?.(this._value);
2588
+ }
2589
+ _animateToState() {
2590
+ const duration = this._config.animationDuration;
2591
+ if (this._useCustomViews) {
2592
+ // Custom views: crossfade
2593
+ if (this._onView) {
2594
+ Tween.killTweensOf(this._onView);
2595
+ if (this._value) {
2596
+ this._onView.visible = true;
2597
+ Tween.to(this._onView, { alpha: 1 }, duration);
2598
+ }
2599
+ else {
2600
+ Tween.to(this._onView, { alpha: 0 }, duration).then(() => {
2601
+ if (this._onView)
2602
+ this._onView.visible = false;
2603
+ });
2604
+ }
2605
+ }
2606
+ if (this._offView) {
2607
+ Tween.killTweensOf(this._offView);
2608
+ if (!this._value) {
2609
+ this._offView.visible = true;
2610
+ Tween.to(this._offView, { alpha: 1 }, duration);
2611
+ }
2612
+ else {
2613
+ Tween.to(this._offView, { alpha: 0 }, duration).then(() => {
2614
+ if (this._offView)
2615
+ this._offView.visible = false;
2616
+ });
2617
+ }
2618
+ }
2619
+ }
2620
+ else {
2621
+ // Graphics mode: slide handle + recolor track
2622
+ this._drawTrack();
2623
+ if (this._handle) {
2624
+ const { width } = this._config;
2625
+ const handleRadius = this._config.handleRadius;
2626
+ const targetX = this._value ? width - handleRadius - 3 : handleRadius + 3;
2627
+ Tween.killTweensOf(this._handle);
2628
+ Tween.to(this._handle, { x: targetX }, duration);
2629
+ }
2630
+ }
2631
+ }
2632
+ _drawTrack() {
2633
+ if (!this._trackGfx)
2634
+ return;
2635
+ const { width, height, onColor, offColor } = this._config;
2636
+ const radius = height / 2;
2637
+ this._trackGfx.clear();
2638
+ this._trackGfx.roundRect(0, 0, width, height, radius).fill(this._value ? onColor : offColor);
2639
+ }
2640
+ destroy(options) {
2641
+ this.off('pointertap', this._onTap, this);
2642
+ if (this._handle)
2643
+ Tween.killTweensOf(this._handle);
2644
+ if (this._onView)
2645
+ Tween.killTweensOf(this._onView);
2646
+ if (this._offView)
2647
+ Tween.killTweensOf(this._offView);
2648
+ this.onChange = null;
2649
+ super.destroy(options);
1333
2650
  }
1334
2651
  }
1335
2652
 
1336
2653
  exports.BalanceDisplay = BalanceDisplay;
1337
2654
  exports.Button = Button;
2655
+ exports.FlexContainer = FlexContainer;
1338
2656
  exports.Label = Label;
1339
2657
  exports.Layout = Layout;
1340
2658
  exports.Modal = Modal;
1341
2659
  exports.Panel = Panel;
1342
2660
  exports.ProgressBar = ProgressBar;
1343
2661
  exports.ScrollContainer = ScrollContainer;
2662
+ exports.Slider = Slider;
1344
2663
  exports.Toast = Toast;
2664
+ exports.Toggle = Toggle;
1345
2665
  exports.WinDisplay = WinDisplay;
2666
+ exports.resolveView = resolveView;
1346
2667
  //# sourceMappingURL=ui.cjs.js.map