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