@energy8platform/game-engine 0.10.11 → 0.11.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 (48) hide show
  1. package/README.md +185 -74
  2. package/dist/index.cjs.js +1280 -296
  3. package/dist/index.cjs.js.map +1 -1
  4. package/dist/index.d.ts +362 -46
  5. package/dist/index.esm.js +1281 -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 +2372 -11
  13. package/dist/react.cjs.js.map +1 -1
  14. package/dist/react.d.ts +17 -6
  15. package/dist/react.esm.js +2372 -12
  16. package/dist/react.esm.js.map +1 -1
  17. package/dist/ui.cjs.js +1553 -632
  18. package/dist/ui.cjs.js.map +1 -1
  19. package/dist/ui.d.ts +374 -46
  20. package/dist/ui.esm.js +1553 -634
  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 +86 -0
  31. package/src/react/extendAll.ts +27 -6
  32. package/src/react/index.ts +1 -1
  33. package/src/react/jsx.d.ts +222 -0
  34. package/src/react/reconciler.ts +22 -5
  35. package/src/ui/BalanceDisplay.ts +31 -38
  36. package/src/ui/Button.ts +217 -53
  37. package/src/ui/FlexContainer.ts +479 -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/Toast.ts +47 -17
  45. package/src/ui/WinDisplay.ts +51 -39
  46. package/src/ui/index.ts +5 -11
  47. package/src/ui/view.ts +28 -0
  48. package/src/vite/index.ts +1 -11
package/dist/ui.esm.js CHANGED
@@ -1,7 +1,615 @@
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
+ // Calculate total main size after flex
83
+ let totalMain = totalGap;
84
+ for (const item of items) {
85
+ totalMain += isRow ? item.w : item.h;
86
+ }
87
+ // Justify: compute starting offset and extra spacing
88
+ let mainOffset = 0;
89
+ let extraGap = 0;
90
+ switch (justify) {
91
+ case 'start':
92
+ break;
93
+ case 'center':
94
+ mainOffset = Math.max(0, (mainSize - totalMain) / 2);
95
+ break;
96
+ case 'end':
97
+ mainOffset = Math.max(0, mainSize - totalMain);
98
+ break;
99
+ case 'space-between':
100
+ if (items.length > 1) {
101
+ extraGap = Math.max(0, (mainSize - totalMain + totalGap) / (items.length - 1)) - gap;
102
+ }
103
+ break;
104
+ case 'space-around':
105
+ if (items.length > 0) {
106
+ const totalSpace = Math.max(0, mainSize - totalMain + totalGap);
107
+ const segment = totalSpace / items.length;
108
+ mainOffset = segment / 2;
109
+ extraGap = segment - gap;
110
+ }
111
+ break;
112
+ }
113
+ // Position each item
114
+ let pos = mainOffset;
115
+ for (const item of items) {
116
+ const mainDim = isRow ? item.w : item.h;
117
+ const crossDim = isRow ? item.h : item.w;
118
+ // Cross-axis alignment
119
+ let crossPos = crossOffset;
120
+ switch (align) {
121
+ case 'start':
122
+ break;
123
+ case 'center':
124
+ crossPos += (crossSize - crossDim) / 2;
125
+ break;
126
+ case 'end':
127
+ crossPos += crossSize - crossDim;
128
+ break;
129
+ case 'stretch':
130
+ if (isRow) {
131
+ item.child.height = crossSize;
132
+ }
133
+ else {
134
+ item.child.width = crossSize;
135
+ }
136
+ break;
137
+ }
138
+ // Compensate for local bounds offset (e.g. centered anchors)
139
+ if (isRow) {
140
+ item.child.x = pos - item.ox;
141
+ item.child.y = crossPos - item.oy;
142
+ }
143
+ else {
144
+ item.child.x = crossPos - item.ox;
145
+ item.child.y = pos - item.oy;
146
+ }
147
+ pos += mainDim + gap + extraGap;
148
+ }
149
+ }
150
+ // ─── FlexContainer ───────────────────────────────────────
151
+ /**
152
+ * Lightweight flexbox-like layout container for PixiJS.
153
+ *
154
+ * Supports row/column direction, justify/align, gap, padding, wrapping,
155
+ * and flex-grow distribution. Zero external dependencies.
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * const toolbar = new FlexContainer({
160
+ * direction: 'row',
161
+ * justifyContent: 'space-between',
162
+ * alignItems: 'center',
163
+ * gap: 16,
164
+ * padding: 12,
165
+ * });
166
+ *
167
+ * toolbar.addFlexChild(button1);
168
+ * toolbar.addFlexChild(button2);
169
+ * toolbar.resize(800, 60);
170
+ * ```
171
+ */
172
+ class FlexContainer extends Container {
173
+ __uiComponent = true;
174
+ _config;
175
+ _padding;
176
+ _maxWidth;
177
+ _maxHeight;
178
+ /** @internal */ _explicitWidth;
179
+ /** @internal */ _explicitHeight;
180
+ _layoutChildren = [];
181
+ _layoutDirty = true;
182
+ constructor(config = {}) {
183
+ super();
184
+ this._config = {
185
+ direction: config.direction ?? 'row',
186
+ justifyContent: config.justifyContent ?? 'start',
187
+ alignItems: config.alignItems ?? 'start',
188
+ gap: config.gap ?? 0,
189
+ flexWrap: config.flexWrap ?? false,
190
+ };
191
+ this._padding = normalizePadding(config.padding ?? 0);
192
+ this._maxWidth = config.maxWidth ?? Infinity;
193
+ this._maxHeight = config.maxHeight ?? Infinity;
194
+ this._explicitWidth = config.width ?? 0;
195
+ this._explicitHeight = config.height ?? 0;
196
+ }
197
+ // ─── Public API ──────────────────────────────────────
198
+ /** Add a child with optional flex config. Also registers in flex layout. */
199
+ addFlexChild(child, flexConfig) {
200
+ if (flexConfig)
201
+ child._flexConfig = flexConfig;
202
+ if (!this._layoutChildren.includes(child)) {
203
+ this._layoutChildren.push(child);
204
+ this._layoutDirty = true;
205
+ }
206
+ super.addChild(child);
207
+ return this;
208
+ }
209
+ /** Remove a child from flex layout and display list */
210
+ removeFlexChild(child) {
211
+ const idx = this._layoutChildren.indexOf(child);
212
+ if (idx !== -1) {
213
+ this._layoutChildren.splice(idx, 1);
214
+ this._layoutDirty = true;
215
+ }
216
+ super.removeChild(child);
217
+ return this;
218
+ }
219
+ /** Remove all flex children */
220
+ clearFlexChildren() {
221
+ for (const child of this._layoutChildren) {
222
+ super.removeChild(child);
223
+ }
224
+ this._layoutChildren.length = 0;
225
+ this._layoutDirty = true;
226
+ return this;
227
+ }
228
+ /**
229
+ * Override addChild so children automatically participate in flex layout.
230
+ * This enables declarative usage from React JSX.
231
+ */
232
+ addChild(...children) {
233
+ for (const child of children) {
234
+ if (!this._layoutChildren.includes(child)) {
235
+ this._layoutChildren.push(child);
236
+ this._layoutDirty = true;
237
+ }
238
+ }
239
+ const result = super.addChild(...children);
240
+ if (this._layoutDirty)
241
+ this.updateLayout();
242
+ return result;
243
+ }
244
+ removeChild(...children) {
245
+ for (const child of children) {
246
+ const idx = this._layoutChildren.indexOf(child);
247
+ if (idx !== -1) {
248
+ this._layoutChildren.splice(idx, 1);
249
+ this._layoutDirty = true;
250
+ }
251
+ }
252
+ return super.removeChild(...children);
253
+ }
254
+ /** Get all flex layout children (read-only) */
255
+ get flexChildren() {
256
+ return this._layoutChildren;
257
+ }
258
+ /** Update the container size and recalculate layout */
259
+ resize(width, height) {
260
+ this._explicitWidth = width;
261
+ this._explicitHeight = height;
262
+ this._layoutDirty = true;
263
+ this.updateLayout();
264
+ }
265
+ /** Update layout direction */
266
+ setDirection(direction) {
267
+ this._config.direction = direction;
268
+ this._layoutDirty = true;
269
+ }
270
+ /** Update justifyContent */
271
+ setJustifyContent(justify) {
272
+ this._config.justifyContent = justify;
273
+ this._layoutDirty = true;
274
+ }
275
+ /** Update alignItems */
276
+ setAlignItems(align) {
277
+ this._config.alignItems = align;
278
+ this._layoutDirty = true;
279
+ }
280
+ /** Update gap */
281
+ setGap(gap) {
282
+ this._config.gap = gap;
283
+ this._layoutDirty = true;
284
+ }
285
+ /** Update padding */
286
+ setPadding(padding) {
287
+ this._padding = normalizePadding(padding);
288
+ this._layoutDirty = true;
289
+ }
290
+ /**
291
+ * Recalculate and apply layout positions for all children.
292
+ * Called automatically by `resize()`. Call manually after
293
+ * adding/removing children without resize.
294
+ */
295
+ updateLayout() {
296
+ this._layoutDirty = false;
297
+ const { direction, justifyContent, alignItems, gap, flexWrap } = this._config;
298
+ const [pt, pr, pb, pl] = this._padding;
299
+ const isRow = direction === 'row';
300
+ const contentW = this._explicitWidth > 0 ? this._explicitWidth - pl - pr : Infinity;
301
+ const contentH = this._explicitHeight > 0 ? this._explicitHeight - pt - pb : Infinity;
302
+ const mainLimit = isRow ? contentW : contentH;
303
+ const crossLimit = isRow ? contentH : contentW;
304
+ // Measure children
305
+ const measured = this._layoutChildren.map((child) => {
306
+ const { w, h, ox, oy } = measureChild(child);
307
+ return { child, w, h, ox, oy };
308
+ });
309
+ // Split into lines (if wrapping)
310
+ const lines = [];
311
+ if (flexWrap && mainLimit < Infinity) {
312
+ let currentLine = [];
313
+ let lineMain = 0;
314
+ for (const item of measured) {
315
+ const itemMain = isRow ? item.w : item.h;
316
+ const wouldBe = lineMain + (currentLine.length > 0 ? gap : 0) + itemMain;
317
+ if (currentLine.length > 0 && wouldBe > mainLimit) {
318
+ lines.push(currentLine);
319
+ currentLine = [item];
320
+ lineMain = itemMain;
321
+ }
322
+ else {
323
+ currentLine.push(item);
324
+ lineMain = wouldBe;
325
+ }
326
+ }
327
+ if (currentLine.length > 0)
328
+ lines.push(currentLine);
329
+ }
330
+ else {
331
+ lines.push(measured);
332
+ }
333
+ // Compute cross size per line
334
+ const lineCrossSizes = lines.map((line) => {
335
+ let maxCross = 0;
336
+ for (const item of line) {
337
+ const cross = isRow ? item.h : item.w;
338
+ if (cross > maxCross)
339
+ maxCross = cross;
340
+ }
341
+ return maxCross;
342
+ });
343
+ // Layout each line
344
+ let crossOffset = isRow ? pt : pl;
345
+ for (let i = 0; i < lines.length; i++) {
346
+ const line = lines[i];
347
+ const lineCross = lineCrossSizes[i];
348
+ const mainStart = isRow ? pl : pt;
349
+ // Offset items by padding
350
+ const tempItems = line.map((item) => ({ ...item }));
351
+ layoutLine(tempItems, isRow, mainLimit < Infinity ? mainLimit : 0, mainLimit < Infinity ? justifyContent : 'start', alignItems, gap, crossOffset, crossLimit < Infinity ? Math.min(lineCross, crossLimit) : lineCross);
352
+ // Apply main-axis padding offset
353
+ for (const item of tempItems) {
354
+ const origChild = line.find((l) => l.child === item.child);
355
+ origChild.child.x = item.child.x + (isRow ? mainStart : 0);
356
+ origChild.child.y = item.child.y + (isRow ? 0 : mainStart);
357
+ }
358
+ crossOffset += lineCross + gap;
359
+ }
360
+ }
361
+ /** Computed content size (after layout) */
362
+ getContentSize() {
363
+ if (this._layoutDirty)
364
+ this.updateLayout();
365
+ let maxX = 0;
366
+ let maxY = 0;
367
+ for (const child of this._layoutChildren) {
368
+ const { w, h } = measureChild(child);
369
+ maxX = Math.max(maxX, child.x + w);
370
+ maxY = Math.max(maxY, child.y + h);
371
+ }
372
+ const [, pr, pb] = this._padding;
373
+ return { width: maxX + pr, height: maxY + pb };
374
+ }
375
+ /** React reconciler update hook — applies changed config props */
376
+ updateConfig(changed) {
377
+ if ('direction' in changed)
378
+ this.setDirection(changed.direction);
379
+ if ('justifyContent' in changed)
380
+ this.setJustifyContent(changed.justifyContent);
381
+ if ('alignItems' in changed)
382
+ this.setAlignItems(changed.alignItems);
383
+ if ('gap' in changed)
384
+ this.setGap(changed.gap);
385
+ if ('padding' in changed)
386
+ this.setPadding(changed.padding);
387
+ if ('flexWrap' in changed) {
388
+ this._config.flexWrap = changed.flexWrap;
389
+ this._layoutDirty = true;
390
+ }
391
+ if ('width' in changed || 'height' in changed) {
392
+ this.resize(changed.width ?? this._explicitWidth, changed.height ?? this._explicitHeight);
393
+ return; // resize calls updateLayout
394
+ }
395
+ if (this._layoutDirty)
396
+ this.updateLayout();
397
+ }
398
+ destroy(options) {
399
+ this._layoutChildren.length = 0;
400
+ super.destroy(options);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Collection of easing functions for use with Tween and Timeline.
406
+ *
407
+ * All functions take a progress value t (0..1) and return the eased value.
408
+ */
409
+ const Easing = {
410
+ easeOutQuad: (t) => t * (2 - t),
411
+ easeInCubic: (t) => t * t * t,
412
+ easeOutCubic: (t) => --t * t * t + 1,
413
+ easeOutBack: (t) => {
414
+ const c1 = 1.70158;
415
+ const c3 = c1 + 1;
416
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
417
+ }};
418
+
419
+ /**
420
+ * Lightweight tween system integrated with PixiJS Ticker.
421
+ * Zero external dependencies — no GSAP required.
422
+ *
423
+ * All tweens return a Promise that resolves on completion.
424
+ *
425
+ * @example
426
+ * ```ts
427
+ * // Fade in a sprite
428
+ * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
429
+ *
430
+ * // Move and wait
431
+ * await Tween.to(sprite, { x: 500 }, 300);
432
+ *
433
+ * // From a starting value
434
+ * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
435
+ * ```
436
+ */
437
+ class Tween {
438
+ static _tweens = [];
439
+ static _tickerAdded = false;
440
+ /**
441
+ * Animate properties from current values to target values.
442
+ *
443
+ * @param target - Object to animate (Sprite, Container, etc.)
444
+ * @param props - Target property values
445
+ * @param duration - Duration in milliseconds
446
+ * @param easing - Easing function (default: easeOutQuad)
447
+ * @param onUpdate - Progress callback (0..1)
448
+ */
449
+ static to(target, props, duration, easing, onUpdate) {
450
+ return new Promise((resolve) => {
451
+ // Capture starting values
452
+ const from = {};
453
+ for (const key of Object.keys(props)) {
454
+ from[key] = Tween.getProperty(target, key);
455
+ }
456
+ const tween = {
457
+ target,
458
+ from,
459
+ to: { ...props },
460
+ duration: Math.max(1, duration),
461
+ easing: easing ?? Easing.easeOutQuad,
462
+ elapsed: 0,
463
+ delay: 0,
464
+ resolve,
465
+ onUpdate,
466
+ };
467
+ Tween._tweens.push(tween);
468
+ Tween.ensureTicker();
469
+ });
470
+ }
471
+ /**
472
+ * Animate properties from given values to current values.
473
+ */
474
+ static from(target, props, duration, easing, onUpdate) {
475
+ // Capture current values as "to"
476
+ const to = {};
477
+ for (const key of Object.keys(props)) {
478
+ to[key] = Tween.getProperty(target, key);
479
+ Tween.setProperty(target, key, props[key]);
480
+ }
481
+ return Tween.to(target, to, duration, easing, onUpdate);
482
+ }
483
+ /**
484
+ * Animate from one set of values to another.
485
+ */
486
+ static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
487
+ // Set starting values
488
+ for (const key of Object.keys(fromProps)) {
489
+ Tween.setProperty(target, key, fromProps[key]);
490
+ }
491
+ return Tween.to(target, toProps, duration, easing, onUpdate);
492
+ }
493
+ /**
494
+ * Wait for a given duration (useful in timelines).
495
+ * Uses PixiJS Ticker for consistent timing with other tweens.
496
+ */
497
+ static delay(ms) {
498
+ return new Promise((resolve) => {
499
+ let elapsed = 0;
500
+ const onTick = (ticker) => {
501
+ elapsed += ticker.deltaMS;
502
+ if (elapsed >= ms) {
503
+ Ticker.shared.remove(onTick);
504
+ resolve();
505
+ }
506
+ };
507
+ Ticker.shared.add(onTick);
508
+ });
509
+ }
510
+ /**
511
+ * Kill all tweens on a target.
512
+ */
513
+ static killTweensOf(target) {
514
+ Tween._tweens = Tween._tweens.filter((tw) => {
515
+ if (tw.target === target) {
516
+ tw.resolve();
517
+ return false;
518
+ }
519
+ return true;
520
+ });
521
+ }
522
+ /**
523
+ * Kill all active tweens.
524
+ */
525
+ static killAll() {
526
+ for (const tw of Tween._tweens) {
527
+ tw.resolve();
528
+ }
529
+ Tween._tweens.length = 0;
530
+ }
531
+ /** Number of active tweens */
532
+ static get activeTweens() {
533
+ return Tween._tweens.length;
534
+ }
535
+ /**
536
+ * Reset the tween system — kill all tweens and remove the ticker.
537
+ * Useful for cleanup between game instances, tests, or hot-reload.
538
+ */
539
+ static reset() {
540
+ for (const tw of Tween._tweens) {
541
+ tw.resolve();
542
+ }
543
+ Tween._tweens.length = 0;
544
+ if (Tween._tickerAdded) {
545
+ Ticker.shared.remove(Tween.tick);
546
+ Tween._tickerAdded = false;
547
+ }
548
+ }
549
+ // ─── Internal ──────────────────────────────────────────
550
+ static ensureTicker() {
551
+ if (Tween._tickerAdded)
552
+ return;
553
+ Tween._tickerAdded = true;
554
+ Ticker.shared.add(Tween.tick);
555
+ }
556
+ static tick = (ticker) => {
557
+ const dt = ticker.deltaMS;
558
+ const completed = [];
559
+ for (const tw of Tween._tweens) {
560
+ tw.elapsed += dt;
561
+ if (tw.elapsed < tw.delay)
562
+ continue;
563
+ const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
564
+ const t = tw.easing(raw);
565
+ // Interpolate each property
566
+ for (const key of Object.keys(tw.to)) {
567
+ const start = tw.from[key];
568
+ const end = tw.to[key];
569
+ const value = start + (end - start) * t;
570
+ Tween.setProperty(tw.target, key, value);
571
+ }
572
+ tw.onUpdate?.(raw);
573
+ if (raw >= 1) {
574
+ completed.push(tw);
575
+ }
576
+ }
577
+ // Remove completed tweens
578
+ for (const tw of completed) {
579
+ const idx = Tween._tweens.indexOf(tw);
580
+ if (idx !== -1)
581
+ Tween._tweens.splice(idx, 1);
582
+ tw.resolve();
583
+ }
584
+ // Remove ticker when no active tweens
585
+ if (Tween._tweens.length === 0 && Tween._tickerAdded) {
586
+ Ticker.shared.remove(Tween.tick);
587
+ Tween._tickerAdded = false;
588
+ }
589
+ };
590
+ /**
591
+ * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
592
+ */
593
+ static getProperty(target, key) {
594
+ const parts = key.split('.');
595
+ let obj = target;
596
+ for (let i = 0; i < parts.length - 1; i++) {
597
+ obj = obj[parts[i]];
598
+ }
599
+ return obj[parts[parts.length - 1]] ?? 0;
600
+ }
601
+ /**
602
+ * Set a potentially nested property.
603
+ */
604
+ static setProperty(target, key, value) {
605
+ const parts = key.split('.');
606
+ let obj = target;
607
+ for (let i = 0; i < parts.length - 1; i++) {
608
+ obj = obj[parts[i]];
609
+ }
610
+ obj[parts[parts.length - 1]] = value;
611
+ }
612
+ }
5
613
 
6
614
  const DEFAULT_COLORS = {
7
615
  default: 0xffd700,
@@ -11,33 +619,55 @@ const DEFAULT_COLORS = {
11
619
  };
12
620
  function makeGraphicsView(w, h, radius, color) {
13
621
  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 });
622
+ g.roundRect(-w / 2, -h / 2, w, h, radius).fill(color);
17
623
  return g;
18
624
  }
19
625
  /**
20
- * Interactive button component powered by `@pixi/ui` FancyButton.
626
+ * Interactive button with per-state custom views and animations.
21
627
  *
22
- * Supports both texture-based and Graphics-based rendering with
23
- * per-state views, press animation, and text.
628
+ * Each visual state accepts a `ViewInput`: texture name, Texture, or any Container
629
+ * (Sprite, NineSliceSprite, AnimatedSprite, custom artwork, etc).
630
+ * Falls back to colored Graphics when no custom view is provided.
24
631
  *
25
632
  * @example
26
633
  * ```ts
634
+ * // Graphics-based (quick prototyping)
27
635
  * const btn = new Button({
28
636
  * width: 200, height: 60, borderRadius: 12,
29
637
  * colors: { default: 0x22aa22, hover: 0x33cc33 },
30
638
  * text: 'SPIN',
639
+ * onPress: () => spin(),
640
+ * });
641
+ *
642
+ * // Asset-based (production art)
643
+ * const btn = new Button({
644
+ * defaultView: 'btn-idle',
645
+ * hoverView: 'btn-hover',
646
+ * pressedView: 'btn-pressed',
647
+ * disabledView: 'btn-disabled',
648
+ * text: 'SPIN',
649
+ * onPress: () => spin(),
31
650
  * });
32
651
  *
33
- * btn.onPress.connect(() => console.log('Clicked!'));
34
- * scene.container.addChild(btn);
652
+ * // Custom Container view
653
+ * const btn = new Button({
654
+ * defaultView: myAnimatedSprite,
655
+ * text: 'SPIN',
656
+ * });
35
657
  * ```
36
658
  */
37
- class Button extends FancyButton {
38
- _buttonConfig;
659
+ class Button extends Container {
660
+ __uiComponent = true;
661
+ _views = new Map();
662
+ _state = 'default';
663
+ _enabled = true;
664
+ _config;
665
+ _textObj = null;
666
+ /** Press callback */
667
+ onPress;
39
668
  constructor(config = {}) {
40
- const resolvedConfig = {
669
+ super();
670
+ this._config = {
41
671
  width: config.width ?? 200,
42
672
  height: config.height ?? 60,
43
673
  borderRadius: config.borderRadius ?? 8,
@@ -45,81 +675,202 @@ class Button extends FancyButton {
45
675
  animationDuration: config.animationDuration ?? 100,
46
676
  ...config,
47
677
  };
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
- }
678
+ this.onPress = config.onPress;
679
+ this._buildViews(config);
82
680
  // Text
83
681
  if (config.text) {
84
- options.text = config.text;
682
+ this._textObj = new Text({
683
+ text: config.text,
684
+ style: {
685
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
686
+ fontSize: 20,
687
+ fill: 0xffffff,
688
+ fontWeight: 'bold',
689
+ ...config.textStyle,
690
+ },
691
+ });
692
+ this._textObj.anchor.set(0.5);
693
+ this.addChild(this._textObj);
85
694
  }
86
- super(options);
87
- this._buttonConfig = resolvedConfig;
695
+ // Interaction
696
+ this.eventMode = 'static';
697
+ this.cursor = 'pointer';
698
+ this.on('pointerover', this._onPointerOver, this);
699
+ this.on('pointerout', this._onPointerOut, this);
700
+ this.on('pointerdown', this._onPointerDown, this);
701
+ this.on('pointerup', this._onPointerUp, this);
702
+ this.on('pointerupoutside', this._onPointerUpOutside, this);
88
703
  if (config.disabled) {
89
704
  this.enabled = false;
90
705
  }
91
706
  }
92
- /** Enable the button */
93
- enable() {
94
- this.enabled = true;
707
+ /** Current button state */
708
+ get state() {
709
+ return this._state;
710
+ }
711
+ /** Enable the button */
712
+ enable() {
713
+ this.enabled = true;
714
+ }
715
+ /** Disable the button */
716
+ disable() {
717
+ this.enabled = false;
718
+ }
719
+ /** Whether the button is enabled */
720
+ get enabled() {
721
+ return this._enabled;
722
+ }
723
+ set enabled(value) {
724
+ this._enabled = value;
725
+ this.cursor = value ? 'pointer' : 'default';
726
+ this.eventMode = value ? 'static' : 'none';
727
+ this._setState(value ? 'default' : 'disabled');
728
+ }
729
+ /** Whether the button is disabled */
730
+ get disabled() {
731
+ return !this._enabled;
732
+ }
733
+ /** Update button text */
734
+ set text(value) {
735
+ if (this._textObj) {
736
+ this._textObj.text = value;
737
+ }
738
+ }
739
+ // ─── View building ──────────────────────────────────
740
+ _buildViews(config) {
741
+ const colorMap = { ...DEFAULT_COLORS, ...config.colors };
742
+ const { width, height, borderRadius } = this._config;
743
+ const stateViews = {
744
+ default: config.defaultView,
745
+ hover: config.hoverView,
746
+ pressed: config.pressedView,
747
+ disabled: config.disabledView,
748
+ };
749
+ const states = ['default', 'hover', 'pressed', 'disabled'];
750
+ for (const state of states) {
751
+ const customView = resolveView(stateViews[state]);
752
+ const view = customView ?? makeGraphicsView(width, height, borderRadius, colorMap[state]);
753
+ view.visible = state === 'default';
754
+ this._views.set(state, view);
755
+ this.addChild(view);
756
+ }
757
+ }
758
+ _rebuildViews() {
759
+ for (const [, view] of this._views) {
760
+ this.removeChild(view);
761
+ view.destroy();
762
+ }
763
+ this._views.clear();
764
+ this._buildViews(this._config);
765
+ // Re-insert views before text
766
+ if (this._textObj && this._textObj.parent === this) {
767
+ this.setChildIndex(this._textObj, this.children.length - 1);
768
+ }
769
+ }
770
+ // ─── State management ───────────────────────────────
771
+ _setState(state) {
772
+ if (this._state === state)
773
+ return;
774
+ this._state = state;
775
+ for (const [s, view] of this._views) {
776
+ view.visible = s === state;
777
+ }
778
+ }
779
+ _onPointerOver() {
780
+ if (!this._enabled)
781
+ return;
782
+ this._setState('hover');
783
+ Tween.killTweensOf(this);
784
+ Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
785
+ }
786
+ _onPointerOut() {
787
+ if (!this._enabled)
788
+ return;
789
+ this._setState('default');
790
+ Tween.killTweensOf(this);
791
+ Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
792
+ }
793
+ _onPointerDown() {
794
+ if (!this._enabled)
795
+ return;
796
+ this._setState('pressed');
797
+ Tween.killTweensOf(this);
798
+ const s = this._config.pressScale;
799
+ Tween.to(this, { 'scale.x': s, 'scale.y': s }, this._config.animationDuration, Easing.easeOutQuad);
95
800
  }
96
- /** Disable the button */
97
- disable() {
98
- this.enabled = false;
801
+ _onPointerUp() {
802
+ if (!this._enabled)
803
+ return;
804
+ this._setState('hover');
805
+ Tween.killTweensOf(this);
806
+ Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
807
+ this.onPress?.();
99
808
  }
100
- /** Whether the button is disabled */
101
- get disabled() {
102
- return !this.enabled;
809
+ _onPointerUpOutside() {
810
+ if (!this._enabled)
811
+ return;
812
+ this._setState('default');
813
+ Tween.killTweensOf(this);
814
+ Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
815
+ }
816
+ /** React reconciler update hook */
817
+ updateConfig(changed) {
818
+ if ('text' in changed && this._textObj)
819
+ this._textObj.text = changed.text;
820
+ if ('disabled' in changed)
821
+ this.enabled = !changed.disabled;
822
+ if ('onPress' in changed)
823
+ this.onPress = changed.onPress;
824
+ const structural = [
825
+ 'colors', 'width', 'height', 'borderRadius', 'textStyle',
826
+ 'defaultView', 'hoverView', 'pressedView', 'disabledView',
827
+ ];
828
+ const needsRebuild = structural.some((k) => k in changed);
829
+ if (needsRebuild) {
830
+ Object.assign(this._config, changed);
831
+ this._rebuildViews();
832
+ }
833
+ }
834
+ destroy(options) {
835
+ Tween.killTweensOf(this);
836
+ this.off('pointerover', this._onPointerOver, this);
837
+ this.off('pointerout', this._onPointerOut, this);
838
+ this.off('pointerdown', this._onPointerDown, this);
839
+ this.off('pointerup', this._onPointerUp, this);
840
+ this.off('pointerupoutside', this._onPointerUpOutside, this);
841
+ this._views.clear();
842
+ this._textObj = null;
843
+ super.destroy(options);
103
844
  }
104
845
  }
105
846
 
106
- function makeBarGraphics(w, h, radius, color) {
107
- return new Graphics().roundRect(0, 0, w, h, radius).fill(color);
108
- }
109
847
  /**
110
- * Horizontal progress bar powered by `@pixi/ui` ProgressBar.
848
+ * Horizontal progress bar with optional custom track/fill views.
111
849
  *
112
- * Provides optional smooth animated fill via per-frame `update()`.
850
+ * Supports asset-based skinning: provide `trackView` and/or `fillView`
851
+ * as texture names, Textures, or any Container (NineSliceSprite, custom artwork, etc).
852
+ * Falls back to colored Graphics when no custom views are provided.
113
853
  *
114
854
  * @example
115
855
  * ```ts
856
+ * // Graphics-based (quick prototyping)
116
857
  * const bar = new ProgressBar({ width: 300, height: 20, fillColor: 0x22cc22 });
117
- * scene.container.addChild(bar);
118
- * bar.progress = 0.5; // 50%
858
+ * bar.progress = 0.5;
859
+ *
860
+ * // Asset-based (production art)
861
+ * const bar = new ProgressBar({
862
+ * width: 300, height: 20,
863
+ * trackView: 'bar-track',
864
+ * fillView: new NineSliceSprite({ texture: 'bar-fill', ... }),
865
+ * });
866
+ * bar.progress = 0.75;
119
867
  * ```
120
868
  */
121
869
  class ProgressBar extends Container {
122
- _bar;
870
+ __uiComponent = true;
871
+ _track;
872
+ _fill;
873
+ _fillMask;
123
874
  _borderGfx;
124
875
  _config;
125
876
  _progress = 0;
@@ -138,21 +889,39 @@ class ProgressBar extends Container {
138
889
  animationSpeed: config.animationSpeed ?? 0.1,
139
890
  };
140
891
  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);
892
+ // Track background custom view or Graphics
893
+ const customTrack = resolveView(config.trackView);
894
+ if (customTrack) {
895
+ customTrack.width = width;
896
+ customTrack.height = height;
897
+ this._track = customTrack;
898
+ }
899
+ else {
900
+ const g = new Graphics();
901
+ g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
902
+ this._track = g;
903
+ }
904
+ this.addChild(this._track);
905
+ // Fill bar — custom view or Graphics
906
+ const customFill = resolveView(config.fillView);
907
+ if (customFill) {
908
+ customFill.x = borderWidth;
909
+ customFill.y = borderWidth;
910
+ customFill.width = width - borderWidth * 2;
911
+ customFill.height = height - borderWidth * 2;
912
+ this._fill = customFill;
913
+ }
914
+ else {
915
+ const g = new Graphics();
916
+ g.roundRect(borderWidth, borderWidth, width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1)).fill(fillColor);
917
+ this._fill = g;
918
+ }
919
+ this.addChild(this._fill);
920
+ // Mask for the fill (controls visible width)
921
+ this._fillMask = new Graphics();
922
+ this._fillMask.rect(0, 0, 0, height).fill(0xffffff);
923
+ this.addChild(this._fillMask);
924
+ this._fill.mask = this._fillMask;
156
925
  // Border overlay
157
926
  this._borderGfx = new Graphics();
158
927
  if (borderColor !== undefined && borderWidth > 0) {
@@ -170,7 +939,7 @@ class ProgressBar extends Container {
170
939
  this._progress = Math.max(0, Math.min(1, value));
171
940
  if (!this._config.animated) {
172
941
  this._displayedProgress = this._progress;
173
- this._bar.progress = this._displayedProgress * 100;
942
+ this.updateMask();
174
943
  }
175
944
  }
176
945
  /**
@@ -181,11 +950,26 @@ class ProgressBar extends Container {
181
950
  return;
182
951
  if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
183
952
  this._displayedProgress = this._progress;
953
+ this.updateMask();
184
954
  return;
185
955
  }
186
956
  this._displayedProgress +=
187
957
  (this._progress - this._displayedProgress) * this._config.animationSpeed;
188
- this._bar.progress = this._displayedProgress * 100;
958
+ this.updateMask();
959
+ }
960
+ /** React reconciler update hook */
961
+ updateConfig(changed) {
962
+ if ('progress' in changed)
963
+ this.progress = changed.progress;
964
+ if ('animated' in changed)
965
+ this._config.animated = changed.animated;
966
+ if ('animationSpeed' in changed)
967
+ this._config.animationSpeed = changed.animationSpeed;
968
+ }
969
+ updateMask() {
970
+ const w = this._config.width * this._displayedProgress;
971
+ this._fillMask.clear();
972
+ this._fillMask.rect(0, 0, w, this._config.height).fill(0xffffff);
189
973
  }
190
974
  }
191
975
 
@@ -203,6 +987,7 @@ class ProgressBar extends Container {
203
987
  * ```
204
988
  */
205
989
  class Label extends Container {
990
+ __uiComponent = true;
206
991
  _text;
207
992
  _maxWidth;
208
993
  _autoFit;
@@ -269,6 +1054,21 @@ class Label extends Container {
269
1054
  maximumFractionDigits: decimals,
270
1055
  }).format(value);
271
1056
  }
1057
+ /** React reconciler update hook */
1058
+ updateConfig(changed) {
1059
+ if ('text' in changed)
1060
+ this.text = changed.text;
1061
+ if ('maxWidth' in changed)
1062
+ this.maxWidth = changed.maxWidth;
1063
+ if ('autoFit' in changed) {
1064
+ this._autoFit = changed.autoFit;
1065
+ this.fitText();
1066
+ }
1067
+ if ('style' in changed && typeof changed.style === 'object') {
1068
+ Object.assign(this._text.style, changed.style);
1069
+ this.fitText();
1070
+ }
1071
+ }
272
1072
  fitText() {
273
1073
  if (!this._autoFit || this._maxWidth === Infinity)
274
1074
  return;
@@ -281,10 +1081,10 @@ class Label extends Container {
281
1081
  }
282
1082
 
283
1083
  /**
284
- * Background panel powered by `@pixi/layout` LayoutContainer.
1084
+ * Background panel with optional flexbox content layout.
285
1085
  *
286
1086
  * Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
287
- * Children added to `content` participate in flexbox layout automatically.
1087
+ * Children added via `addContent()` participate in flex layout automatically.
288
1088
  *
289
1089
  * @example
290
1090
  * ```ts
@@ -299,9 +1099,14 @@ class Label extends Container {
299
1099
  * });
300
1100
  * ```
301
1101
  */
302
- class Panel extends LayoutContainer {
1102
+ class Panel extends Container {
1103
+ __uiComponent = true;
1104
+ _bg;
1105
+ _content;
1106
+ _internalSetup = true;
303
1107
  _panelConfig;
304
1108
  constructor(config = {}) {
1109
+ super();
305
1110
  const resolvedConfig = {
306
1111
  width: config.width ?? 400,
307
1112
  height: config.height ?? 300,
@@ -309,8 +1114,8 @@ class Panel extends LayoutContainer {
309
1114
  backgroundAlpha: config.backgroundAlpha ?? 1,
310
1115
  ...config,
311
1116
  };
312
- // If using a 9-slice texture, pass it as a custom background
313
- let customBackground;
1117
+ this._panelConfig = resolvedConfig;
1118
+ // Create background
314
1119
  if (config.nineSliceTexture) {
315
1120
  const texture = typeof config.nineSliceTexture === 'string'
316
1121
  ? Texture.from(config.nineSliceTexture)
@@ -326,126 +1131,110 @@ class Panel extends LayoutContainer {
326
1131
  nineSlice.width = resolvedConfig.width;
327
1132
  nineSlice.height = resolvedConfig.height;
328
1133
  nineSlice.alpha = resolvedConfig.backgroundAlpha;
329
- customBackground = nineSlice;
1134
+ this._bg = nineSlice;
330
1135
  }
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;
1136
+ else {
1137
+ const g = new Graphics();
1138
+ const bgColor = config.backgroundColor ?? 0x1a1a2e;
1139
+ const radius = config.borderRadius ?? 0;
1140
+ g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius).fill(bgColor);
344
1141
  if (config.borderColor !== undefined && config.borderWidth) {
345
- layoutStyles.borderColor = config.borderColor;
346
- layoutStyles.borderWidth = config.borderWidth;
1142
+ g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius)
1143
+ .stroke({ color: config.borderColor, width: config.borderWidth });
347
1144
  }
1145
+ g.alpha = resolvedConfig.backgroundAlpha;
1146
+ this._bg = g;
348
1147
  }
349
- this.layout = layoutStyles;
350
- if (!config.nineSliceTexture) {
351
- this.background.alpha = resolvedConfig.backgroundAlpha;
352
- }
1148
+ this.addChild(this._bg);
1149
+ // Create content flex container
1150
+ this._content = new FlexContainer({
1151
+ ...config.layout,
1152
+ direction: config.layout?.direction ?? 'column',
1153
+ justifyContent: config.layout?.justifyContent ?? 'start',
1154
+ alignItems: config.layout?.alignItems ?? 'start',
1155
+ gap: config.layout?.gap ?? 0,
1156
+ padding: resolvedConfig.padding,
1157
+ width: resolvedConfig.width,
1158
+ height: resolvedConfig.height,
1159
+ });
1160
+ this.addChild(this._content);
1161
+ this._internalSetup = false;
353
1162
  }
354
- /** Access the content container (children added here participate in layout) */
1163
+ /** Access the content flex container — add children here for layout */
355
1164
  get content() {
356
- return this.overflowContainer;
1165
+ return this._content;
1166
+ }
1167
+ /** Convenience: add a child to the content layout */
1168
+ addContent(child) {
1169
+ this._content.addFlexChild(child);
1170
+ this._content.updateLayout();
1171
+ return this;
357
1172
  }
358
1173
  /** Resize the panel */
359
1174
  setSize(width, height) {
360
1175
  this._panelConfig.width = width;
361
1176
  this._panelConfig.height = height;
362
- this._layout?.setStyle({ width, height });
1177
+ // Resize background
1178
+ if (this._bg instanceof NineSliceSprite) {
1179
+ this._bg.width = width;
1180
+ this._bg.height = height;
1181
+ }
1182
+ else if (this._bg instanceof Graphics) {
1183
+ const radius = this._panelConfig.borderRadius ?? 0;
1184
+ const bgColor = this._panelConfig.backgroundColor ?? 0x1a1a2e;
1185
+ this._bg.clear();
1186
+ this._bg.roundRect(0, 0, width, height, radius).fill(bgColor);
1187
+ if (this._panelConfig.borderColor !== undefined && this._panelConfig.borderWidth) {
1188
+ this._bg.roundRect(0, 0, width, height, radius)
1189
+ .stroke({ color: this._panelConfig.borderColor, width: this._panelConfig.borderWidth });
1190
+ }
1191
+ this._bg.alpha = this._panelConfig.backgroundAlpha;
1192
+ }
1193
+ this._content.resize(width, height);
1194
+ }
1195
+ /**
1196
+ * Override addChild so external children are routed to content FlexContainer.
1197
+ * Enables `<panel><label /><button /></panel>` in React JSX.
1198
+ */
1199
+ addChild(...children) {
1200
+ if (this._internalSetup) {
1201
+ return super.addChild(...children);
1202
+ }
1203
+ for (const child of children) {
1204
+ this._content.addFlexChild(child);
1205
+ }
1206
+ this._content.updateLayout();
1207
+ return children[0];
1208
+ }
1209
+ removeChild(...children) {
1210
+ if (this._internalSetup) {
1211
+ return super.removeChild(...children);
1212
+ }
1213
+ for (const child of children) {
1214
+ this._content.removeFlexChild(child);
1215
+ }
1216
+ return children[0];
1217
+ }
1218
+ /** React reconciler update hook */
1219
+ updateConfig(changed) {
1220
+ if ('width' in changed || 'height' in changed) {
1221
+ this.setSize(changed.width ?? this._panelConfig.width, changed.height ?? this._panelConfig.height);
1222
+ }
1223
+ if ('backgroundAlpha' in changed) {
1224
+ this._panelConfig.backgroundAlpha = changed.backgroundAlpha;
1225
+ this._bg.alpha = changed.backgroundAlpha;
1226
+ }
1227
+ }
1228
+ destroy(options) {
1229
+ super.destroy(options);
363
1230
  }
364
1231
  }
365
1232
 
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
1233
  /**
445
1234
  * Reactive balance display component.
446
1235
  *
447
1236
  * Automatically formats currency and can animate value changes
448
- * with a smooth countup/countdown effect.
1237
+ * with a smooth countup/countdown effect using engine Tween.
449
1238
  *
450
1239
  * @example
451
1240
  * ```ts
@@ -457,13 +1246,14 @@ const Easing = {
457
1246
  * ```
458
1247
  */
459
1248
  class BalanceDisplay extends Container {
1249
+ __uiComponent = true;
460
1250
  _prefixLabel = null;
461
1251
  _valueLabel;
462
1252
  _config;
463
1253
  _currentValue = 0;
464
1254
  _displayedValue = 0;
465
- _animating = false;
466
- _animationCancelled = false;
1255
+ /** Internal target for Tween animation */
1256
+ _tweenTarget = { value: 0 };
467
1257
  constructor(config = {}) {
468
1258
  super();
469
1259
  this._config = {
@@ -524,37 +1314,13 @@ class BalanceDisplay extends Container {
524
1314
  this._config.currency = currency;
525
1315
  this.updateDisplay();
526
1316
  }
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);
1317
+ animateValue(from, to) {
1318
+ // Cancel any running animation
1319
+ Tween.killTweensOf(this._tweenTarget);
1320
+ this._tweenTarget.value = from;
1321
+ Tween.to(this._tweenTarget, { value: to }, this._config.animationDuration, Easing.easeOutCubic, () => {
1322
+ this._displayedValue = this._tweenTarget.value;
1323
+ this.updateDisplay();
558
1324
  });
559
1325
  }
560
1326
  updateDisplay() {
@@ -564,305 +1330,120 @@ class BalanceDisplay extends Container {
564
1330
  if (this._prefixLabel) {
565
1331
  this._prefixLabel.y = -14;
566
1332
  this._valueLabel.y = 14;
567
- }
568
- }
569
- }
570
-
571
- /**
572
- * Win amount display with countup animation.
573
- *
574
- * Shows a dramatic countup from 0 to the win amount, with optional
575
- * scale pop effect — typical of slot games.
576
- *
577
- * @example
578
- * ```ts
579
- * const winDisplay = new WinDisplay({ currency: 'USD' });
580
- * scene.container.addChild(winDisplay);
581
- * await winDisplay.showWin(150.50); // countup animation
582
- * winDisplay.hide();
583
- * ```
584
- */
585
- class WinDisplay extends Container {
586
- _label;
587
- _config;
588
- _cancelCountup = false;
589
- constructor(config = {}) {
590
- super();
591
- this._config = {
592
- currency: config.currency ?? 'USD',
593
- locale: config.locale ?? 'en-US',
594
- countupDuration: config.countupDuration ?? 1500,
595
- popScale: config.popScale ?? 1.2,
596
- };
597
- this._label = new Label({
598
- text: '',
599
- style: {
600
- fontSize: 48,
601
- fontWeight: 'bold',
602
- fill: 0xffd700,
603
- stroke: { color: 0x000000, width: 3 },
604
- ...config.style,
605
- },
606
- });
607
- this.addChild(this._label);
608
- this.visible = false;
609
- }
610
- /**
611
- * Show a win with countup animation.
612
- *
613
- * @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);
1333
+ }
1334
+ }
1335
+ /** React reconciler update hook */
1336
+ updateConfig(changed) {
1337
+ if ('value' in changed)
1338
+ this.setValue(changed.value);
1339
+ if ('currency' in changed)
1340
+ this.setCurrency(changed.currency);
1341
+ }
1342
+ destroy(options) {
1343
+ Tween.killTweensOf(this._tweenTarget);
1344
+ super.destroy(options);
1345
+ }
1346
+ }
1347
+
1348
+ /**
1349
+ * Win amount display with countup animation.
1350
+ *
1351
+ * Shows a dramatic countup from 0 to the win amount, with optional
1352
+ * scale pop effect — typical of slot games. Uses engine Tween system.
1353
+ *
1354
+ * @example
1355
+ * ```ts
1356
+ * const winDisplay = new WinDisplay({ currency: 'USD' });
1357
+ * scene.container.addChild(winDisplay);
1358
+ * await winDisplay.showWin(150.50); // countup animation
1359
+ * winDisplay.hide();
1360
+ * ```
1361
+ */
1362
+ class WinDisplay extends Container {
1363
+ __uiComponent = true;
1364
+ _label;
1365
+ _config;
1366
+ /** Internal target for Tween countup */
1367
+ _tweenTarget = { value: 0 };
1368
+ constructor(config = {}) {
1369
+ super();
1370
+ this._config = {
1371
+ currency: config.currency ?? 'USD',
1372
+ locale: config.locale ?? 'en-US',
1373
+ countupDuration: config.countupDuration ?? 1500,
1374
+ popScale: config.popScale ?? 1.2,
1375
+ };
1376
+ this._label = new Label({
1377
+ text: '',
1378
+ style: {
1379
+ fontSize: 48,
1380
+ fontWeight: 'bold',
1381
+ fill: 0xffd700,
1382
+ stroke: { color: 0x000000, width: 3 },
1383
+ ...config.style,
1384
+ },
763
1385
  });
1386
+ this.addChild(this._label);
1387
+ this.visible = false;
764
1388
  }
765
1389
  /**
766
- * Kill all tweens on a target.
1390
+ * Show a win with countup animation.
1391
+ *
1392
+ * @param amount - Win amount
1393
+ * @returns Promise that resolves when the animation completes
767
1394
  */
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;
1395
+ async showWin(amount) {
1396
+ this.visible = true;
1397
+ this.alpha = 1;
1398
+ // Cancel any running animation
1399
+ Tween.killTweensOf(this._tweenTarget);
1400
+ Tween.killTweensOf(this);
1401
+ // Setup countup
1402
+ this._tweenTarget.value = 0;
1403
+ this.scale.set(0.5);
1404
+ // Scale pop animation
1405
+ const scalePromise = Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, 300, Easing.easeOutBack);
1406
+ // Countup animation
1407
+ const countupPromise = Tween.to(this._tweenTarget, { value: amount }, this._config.countupDuration, Easing.easeOutCubic, () => {
1408
+ this.displayAmount(this._tweenTarget.value);
775
1409
  });
1410
+ await Promise.all([scalePromise, countupPromise]);
1411
+ // Ensure final value is exact
1412
+ this.displayAmount(amount);
1413
+ this.scale.set(1);
776
1414
  }
777
1415
  /**
778
- * Kill all active tweens.
1416
+ * Skip the countup animation and show the final amount immediately.
779
1417
  */
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;
1418
+ skipCountup(amount) {
1419
+ Tween.killTweensOf(this._tweenTarget);
1420
+ Tween.killTweensOf(this);
1421
+ this.displayAmount(amount);
1422
+ this.scale.set(1);
789
1423
  }
790
1424
  /**
791
- * Reset the tween system — kill all tweens and remove the ticker.
792
- * Useful for cleanup between game instances, tests, or hot-reload.
1425
+ * Hide the win display.
793
1426
  */
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
- }
1427
+ hide() {
1428
+ Tween.killTweensOf(this._tweenTarget);
1429
+ Tween.killTweensOf(this);
1430
+ this.visible = false;
1431
+ this._label.text = '';
803
1432
  }
804
- // ─── Internal ──────────────────────────────────────────
805
- static ensureTicker() {
806
- if (Tween._tickerAdded)
807
- return;
808
- Tween._tickerAdded = true;
809
- Ticker.shared.add(Tween.tick);
1433
+ displayAmount(amount) {
1434
+ this._label.setCurrency(amount, this._config.currency, this._config.locale);
810
1435
  }
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
- /**
846
- * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
847
- */
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;
1436
+ /** React reconciler update hook */
1437
+ updateConfig(changed) {
1438
+ if ('currency' in changed)
1439
+ this._config.currency = changed.currency;
1440
+ if ('locale' in changed)
1441
+ this._config.locale = changed.locale;
855
1442
  }
856
- /**
857
- * Set a potentially nested property.
858
- */
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;
1443
+ destroy(options) {
1444
+ Tween.killTweensOf(this._tweenTarget);
1445
+ Tween.killTweensOf(this);
1446
+ super.destroy(options);
866
1447
  }
867
1448
  }
868
1449
 
@@ -870,7 +1451,7 @@ class Tween {
870
1451
  * Modal overlay component.
871
1452
  * Shows content on top of a dark overlay with enter/exit animations.
872
1453
  *
873
- * The content container uses `@pixi/layout` for automatic centering.
1454
+ * Content is automatically centered via position calculations.
874
1455
  *
875
1456
  * @example
876
1457
  * ```ts
@@ -881,6 +1462,7 @@ class Tween {
881
1462
  * ```
882
1463
  */
883
1464
  class Modal extends Container {
1465
+ __uiComponent = true;
884
1466
  _overlay;
885
1467
  _contentContainer;
886
1468
  _config;
@@ -950,6 +1532,17 @@ class Modal extends Container {
950
1532
  this._showing = false;
951
1533
  this.onClose?.();
952
1534
  }
1535
+ /** React reconciler update hook */
1536
+ updateConfig(changed) {
1537
+ if ('overlayAlpha' in changed)
1538
+ this._config.overlayAlpha = changed.overlayAlpha;
1539
+ if ('closeOnOverlay' in changed)
1540
+ this._config.closeOnOverlay = changed.closeOnOverlay;
1541
+ if ('animationDuration' in changed)
1542
+ this._config.animationDuration = changed.animationDuration;
1543
+ if ('onClose' in changed)
1544
+ this.onClose = changed.onClose;
1545
+ }
953
1546
  }
954
1547
 
955
1548
  const TOAST_COLORS = {
@@ -969,17 +1562,21 @@ const TOAST_COLORS = {
969
1562
  * ```
970
1563
  */
971
1564
  class Toast extends Container {
1565
+ __uiComponent = true;
972
1566
  _bg;
1567
+ _customBg;
973
1568
  _text;
974
1569
  _config;
975
- _dismissTimeout = null;
1570
+ _dismissPending = false;
976
1571
  constructor(config = {}) {
977
1572
  super();
978
1573
  this._config = {
979
1574
  duration: config.duration ?? 3000,
980
1575
  bottomOffset: config.bottomOffset ?? 60,
981
1576
  };
982
- this._bg = new Graphics();
1577
+ const customBg = resolveView(config.backgroundView);
1578
+ this._customBg = !!customBg;
1579
+ this._bg = customBg ?? new Graphics();
983
1580
  this.addChild(this._bg);
984
1581
  this._text = new Text({
985
1582
  text: '',
@@ -997,18 +1594,27 @@ class Toast extends Container {
997
1594
  * Show a toast message.
998
1595
  */
999
1596
  async show(message, type = 'info', viewWidth, viewHeight) {
1000
- if (this._dismissTimeout) {
1001
- clearTimeout(this._dismissTimeout);
1002
- }
1597
+ // Cancel any pending dismiss
1598
+ Tween.killTweensOf(this);
1599
+ this._dismissPending = false;
1003
1600
  this._text.text = message;
1004
1601
  const padding = 20;
1005
1602
  const width = Math.max(200, this._text.width + padding * 2);
1006
1603
  const height = 44;
1007
1604
  const radius = 8;
1008
1605
  // 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]);
1606
+ if (this._customBg) {
1607
+ this._bg.width = width;
1608
+ this._bg.height = height;
1609
+ this._bg.x = -width / 2;
1610
+ this._bg.y = -height / 2;
1611
+ }
1612
+ else {
1613
+ const g = this._bg;
1614
+ g.clear();
1615
+ g.roundRect(-width / 2, -height / 2, width, height, radius);
1616
+ g.fill(TOAST_COLORS[type]);
1617
+ }
1012
1618
  // Position
1013
1619
  if (viewWidth && viewHeight) {
1014
1620
  this.x = viewWidth / 2;
@@ -1019,9 +1625,12 @@ class Toast extends Container {
1019
1625
  this.y += 20;
1020
1626
  await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
1021
1627
  if (this._config.duration > 0) {
1022
- this._dismissTimeout = setTimeout(() => {
1023
- this.dismiss();
1024
- }, this._config.duration);
1628
+ this._dismissPending = true;
1629
+ await Tween.delay(this._config.duration);
1630
+ if (this._dismissPending) {
1631
+ this._dismissPending = false;
1632
+ await this.dismiss();
1633
+ }
1025
1634
  }
1026
1635
  }
1027
1636
  /**
@@ -1030,57 +1639,36 @@ class Toast extends Container {
1030
1639
  async dismiss() {
1031
1640
  if (!this.visible)
1032
1641
  return;
1033
- if (this._dismissTimeout) {
1034
- clearTimeout(this._dismissTimeout);
1035
- this._dismissTimeout = null;
1036
- }
1642
+ this._dismissPending = false;
1643
+ Tween.killTweensOf(this);
1037
1644
  await Tween.to(this, { alpha: 0, y: this.y + 20 }, 200, Easing.easeInCubic);
1038
1645
  this.visible = false;
1039
1646
  }
1647
+ /** React reconciler update hook */
1648
+ updateConfig(changed) {
1649
+ if ('duration' in changed)
1650
+ this._config.duration = changed.duration;
1651
+ if ('bottomOffset' in changed)
1652
+ this._config.bottomOffset = changed.bottomOffset;
1653
+ }
1654
+ destroy(options) {
1655
+ this._dismissPending = false;
1656
+ Tween.killTweensOf(this);
1657
+ super.destroy(options);
1658
+ }
1040
1659
  }
1041
1660
 
1042
1661
  // ─── 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) {
1662
+ function directionToFlex(direction) {
1055
1663
  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
- };
1664
+ case 'horizontal': return { direction: 'row', wrap: false };
1665
+ case 'vertical': return { direction: 'column', wrap: false };
1666
+ case 'grid': return { direction: 'row', wrap: true };
1667
+ case 'wrap': return { direction: 'row', wrap: true };
1068
1668
  }
1069
1669
  }
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
1670
  /**
1083
- * Responsive layout container powered by `@pixi/layout` (Yoga flexbox engine).
1671
+ * Responsive layout container powered by a lightweight built-in flex layout solver.
1084
1672
  *
1085
1673
  * Supports horizontal, vertical, grid, and wrap layout modes with
1086
1674
  * alignment, padding, gap, and viewport-anchor positioning.
@@ -1107,6 +1695,7 @@ function buildLayoutStyles(config) {
1107
1695
  * ```
1108
1696
  */
1109
1697
  class Layout extends Container {
1698
+ __uiComponent = true;
1110
1699
  _layoutConfig;
1111
1700
  _padding;
1112
1701
  _anchor;
@@ -1115,6 +1704,7 @@ class Layout extends Container {
1115
1704
  _items = [];
1116
1705
  _viewportWidth = 0;
1117
1706
  _viewportHeight = 0;
1707
+ _flex;
1118
1708
  constructor(config = {}) {
1119
1709
  super();
1120
1710
  this._layoutConfig = {
@@ -1124,7 +1714,7 @@ class Layout extends Container {
1124
1714
  autoLayout: config.autoLayout ?? true,
1125
1715
  columns: config.columns ?? 2,
1126
1716
  };
1127
- this._padding = normalizePadding(config.padding ?? 0);
1717
+ this._padding = config.padding ?? 0;
1128
1718
  this._anchor = config.anchor ?? 'top-left';
1129
1719
  this._maxWidth = config.maxWidth ?? Infinity;
1130
1720
  this._breakpoints = config.breakpoints
@@ -1132,14 +1722,18 @@ class Layout extends Container {
1132
1722
  .map(([w, cfg]) => [Number(w), cfg])
1133
1723
  .sort((a, b) => a[0] - b[0])
1134
1724
  : [];
1725
+ // Create internal FlexContainer
1726
+ this._flex = new FlexContainer();
1727
+ super.addChild(this._flex);
1135
1728
  this.applyLayoutStyles();
1136
1729
  }
1137
1730
  /** Add an item to the layout */
1138
1731
  addItem(child) {
1139
1732
  this._items.push(child);
1140
- this.addChild(child);
1141
- if (this._layoutConfig.direction === 'grid') {
1142
- this.applyGridChildWidth(child);
1733
+ const flexConfig = this.buildFlexItemConfig(child);
1734
+ this._flex.addFlexChild(child, flexConfig);
1735
+ if (this._layoutConfig.autoLayout) {
1736
+ this.applyLayoutStyles();
1143
1737
  }
1144
1738
  return this;
1145
1739
  }
@@ -1148,15 +1742,13 @@ class Layout extends Container {
1148
1742
  const idx = this._items.indexOf(child);
1149
1743
  if (idx !== -1) {
1150
1744
  this._items.splice(idx, 1);
1151
- this.removeChild(child);
1745
+ this._flex.removeFlexChild(child);
1152
1746
  }
1153
1747
  return this;
1154
1748
  }
1155
1749
  /** Remove all items */
1156
1750
  clearItems() {
1157
- for (const item of this._items) {
1158
- this.removeChild(item);
1159
- }
1751
+ this._flex.clearFlexChildren();
1160
1752
  this._items.length = 0;
1161
1753
  return this;
1162
1754
  }
@@ -1179,43 +1771,58 @@ class Layout extends Container {
1179
1771
  const direction = effective.direction ?? this._layoutConfig.direction;
1180
1772
  const gap = effective.gap ?? this._layoutConfig.gap;
1181
1773
  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;
1774
+ const padding = effective.padding ?? this._padding;
1186
1775
  const maxWidth = effective.maxWidth ?? this._maxWidth;
1187
- const styles = buildLayoutStyles({ direction, gap, alignment, padding, maxWidth });
1188
- this.layout = styles;
1776
+ const { direction: flexDir, wrap } = directionToFlex(direction);
1777
+ this._flex.setDirection(flexDir);
1778
+ this._flex.setJustifyContent('start');
1779
+ this._flex.setAlignItems(alignment);
1780
+ this._flex.setGap(gap);
1781
+ this._flex.setPadding(padding);
1782
+ // Wrap and maxWidth
1783
+ if (wrap) {
1784
+ this._flex._config.flexWrap = true;
1785
+ if (direction === 'grid' && maxWidth < Infinity) {
1786
+ this._flex._maxWidth = maxWidth;
1787
+ }
1788
+ if (maxWidth < Infinity) {
1789
+ this._flex._maxWidth = maxWidth;
1790
+ }
1791
+ }
1792
+ else {
1793
+ this._flex._config.flexWrap = false;
1794
+ }
1795
+ // Update grid child widths
1189
1796
  if (direction === 'grid') {
1190
1797
  for (const item of this._items) {
1191
- this.applyGridChildWidth(item);
1798
+ const flexConfig = this.buildFlexItemConfig(item);
1799
+ item._flexConfig = flexConfig;
1192
1800
  }
1193
1801
  }
1802
+ // Set explicit size if we have viewport dimensions
1803
+ if (this._viewportWidth > 0 && this._viewportHeight > 0) {
1804
+ this._flex.resize(this._viewportWidth, this._viewportHeight);
1805
+ }
1806
+ else {
1807
+ this._flex.updateLayout();
1808
+ }
1194
1809
  }
1195
- applyGridChildWidth(child) {
1810
+ buildFlexItemConfig(_child) {
1196
1811
  const effective = this.resolveConfig();
1812
+ const direction = effective.direction ?? this._layoutConfig.direction;
1197
1813
  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;
1814
+ if (direction === 'grid' && columns > 0) {
1815
+ // For grid, give each item a proportional width
1816
+ // The actual pixel width will be computed during layout
1817
+ return { flexGrow: 1 };
1210
1818
  }
1819
+ return undefined;
1211
1820
  }
1212
1821
  applyAnchor() {
1213
1822
  const anchor = this.resolveConfig().anchor ?? this._anchor;
1214
1823
  if (this._viewportWidth === 0 || this._viewportHeight === 0)
1215
1824
  return;
1216
- const bounds = this.getLocalBounds();
1217
- const contentW = bounds.width * this.scale.x;
1218
- const contentH = bounds.height * this.scale.y;
1825
+ const { width: contentW, height: contentH } = this._flex.getContentSize();
1219
1826
  const vw = this._viewportWidth;
1220
1827
  const vh = this._viewportHeight;
1221
1828
  let anchorX = 0;
@@ -1238,8 +1845,8 @@ class Layout extends Container {
1238
1845
  else {
1239
1846
  anchorY = (vh - contentH) / 2;
1240
1847
  }
1241
- this.x = anchorX - bounds.x * this.scale.x;
1242
- this.y = anchorY - bounds.y * this.scale.y;
1848
+ this.x = anchorX;
1849
+ this.y = anchorY;
1243
1850
  }
1244
1851
  resolveConfig() {
1245
1852
  if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
@@ -1252,18 +1859,34 @@ class Layout extends Container {
1252
1859
  }
1253
1860
  return {};
1254
1861
  }
1862
+ /** React reconciler update hook */
1863
+ updateConfig(changed) {
1864
+ if ('direction' in changed)
1865
+ this._layoutConfig.direction = changed.direction;
1866
+ if ('gap' in changed)
1867
+ this._layoutConfig.gap = changed.gap;
1868
+ if ('alignment' in changed)
1869
+ this._layoutConfig.alignment = changed.alignment;
1870
+ if ('anchor' in changed)
1871
+ this._anchor = changed.anchor;
1872
+ if ('padding' in changed)
1873
+ this._padding = changed.padding;
1874
+ if ('columns' in changed)
1875
+ this._layoutConfig.columns = changed.columns;
1876
+ this.applyLayoutStyles();
1877
+ if (this._viewportWidth > 0)
1878
+ this.applyAnchor();
1879
+ }
1880
+ destroy(options) {
1881
+ this._items.length = 0;
1882
+ super.destroy(options);
1883
+ }
1255
1884
  }
1256
1885
 
1257
- const DIRECTION_MAP = {
1258
- vertical: 'vertical',
1259
- horizontal: 'horizontal',
1260
- both: 'bidirectional',
1261
- };
1886
+ const DECELERATION = 0.95;
1887
+ const MIN_VELOCITY = 0.5;
1262
1888
  /**
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.
1889
+ * Scrollable container with touch/drag, mouse wheel, and inertia.
1267
1890
  *
1268
1891
  * @example
1269
1892
  * ```ts
@@ -1281,55 +1904,351 @@ const DIRECTION_MAP = {
1281
1904
  * scene.container.addChild(scroll);
1282
1905
  * ```
1283
1906
  */
1284
- class ScrollContainer extends ScrollBox {
1907
+ class ScrollContainer extends Container {
1908
+ __uiComponent = true;
1909
+ _viewport;
1910
+ _internalSetup = true;
1911
+ _content;
1912
+ _maskGfx;
1913
+ _bg = null;
1285
1914
  _scrollConfig;
1915
+ _items = [];
1916
+ // Scrollbar
1917
+ _scrollbar = null;
1918
+ _scrollbarConfig;
1919
+ // Drag state
1920
+ _dragging = false;
1921
+ _dragStart = { x: 0, y: 0 };
1922
+ _contentStart = { x: 0, y: 0 };
1923
+ _velocity = { x: 0, y: 0 };
1924
+ _lastDragPos = { x: 0, y: 0 };
1925
+ _lastDragTime = 0;
1926
+ _inertiaActive = false;
1927
+ // Bound handlers for cleanup
1928
+ _onTickBound = null;
1929
+ _onWheelBound = null;
1286
1930
  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,
1931
+ super();
1932
+ this._viewport = { width: config.width, height: config.height };
1933
+ this._scrollConfig = {
1934
+ direction: config.direction ?? 'vertical',
1292
1935
  elementsMargin: config.elementsMargin ?? 0,
1293
1936
  padding: config.padding ?? 0,
1294
- disableDynamicRendering: config.disableDynamicRendering ?? false,
1937
+ borderRadius: config.borderRadius ?? 0,
1295
1938
  disableEasing: config.disableEasing ?? false,
1296
- globalScroll: config.globalScroll ?? true,
1297
1939
  };
1940
+ // Background
1298
1941
  if (config.backgroundColor !== undefined) {
1299
- options.background = config.backgroundColor;
1942
+ this._bg = new Graphics();
1943
+ this._bg.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
1944
+ .fill(config.backgroundColor);
1945
+ this.addChild(this._bg);
1946
+ }
1947
+ // Mask
1948
+ this._maskGfx = new Graphics();
1949
+ this._maskGfx.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
1950
+ .fill(0xffffff);
1951
+ this.addChild(this._maskGfx);
1952
+ // Content container
1953
+ this._content = new Container();
1954
+ this._content.mask = this._maskGfx;
1955
+ this.addChild(this._content);
1956
+ // Interaction
1957
+ this.eventMode = 'static';
1958
+ this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
1959
+ this.on('pointerdown', this._onPointerDown, this);
1960
+ this.on('pointermove', this._onPointerMove, this);
1961
+ this.on('pointerup', this._onPointerUp, this);
1962
+ this.on('pointerupoutside', this._onPointerUp, this);
1963
+ // Mouse wheel
1964
+ this._onWheelBound = this._onWheel.bind(this);
1965
+ // Scrollbar
1966
+ const sbWidth = config.scrollbarWidth ?? 6;
1967
+ const sbPadding = config.scrollbarPadding ?? 4;
1968
+ this._scrollbarConfig = { width: sbWidth, padding: sbPadding };
1969
+ if (config.scrollbar) {
1970
+ const customThumb = resolveView(config.thumbView);
1971
+ if (customThumb) {
1972
+ this._scrollbar = customThumb;
1973
+ }
1974
+ else {
1975
+ const g = new Graphics();
1976
+ g.roundRect(0, 0, sbWidth, 40, sbWidth / 2).fill(config.scrollbarColor ?? 0xaaaaaa);
1977
+ g.alpha = config.scrollbarAlpha ?? 0.5;
1978
+ this._scrollbar = g;
1979
+ }
1980
+ this._scrollbar.visible = false;
1981
+ super.addChild(this._scrollbar);
1300
1982
  }
1301
- super(options);
1302
- this._scrollConfig = config;
1983
+ this._internalSetup = false;
1303
1984
  }
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);
1985
+ /**
1986
+ * Override addChild so external children are routed to scroll content.
1987
+ * Enables `<scrollContainer><label /><panel /></scrollContainer>` in React JSX.
1988
+ */
1989
+ addChild(...children) {
1990
+ if (this._internalSetup) {
1991
+ return super.addChild(...children);
1992
+ }
1993
+ for (const child of children) {
1994
+ this.addItem(child);
1995
+ }
1996
+ return children[0];
1997
+ }
1998
+ removeChild(...children) {
1999
+ if (this._internalSetup) {
2000
+ return super.removeChild(...children);
2001
+ }
2002
+ for (const child of children) {
2003
+ const idx = this._items.indexOf(child);
2004
+ if (idx !== -1) {
2005
+ this._items.splice(idx, 1);
2006
+ this._content.removeChild(child);
1311
2007
  }
1312
2008
  }
1313
- // Add all children from the content container
2009
+ this.layoutItems();
2010
+ return children[0];
2011
+ }
2012
+ /** React reconciler update hook */
2013
+ updateConfig(changed) {
2014
+ if ('width' in changed || 'height' in changed) {
2015
+ this.setViewportSize(changed.width ?? this._viewport.width, changed.height ?? this._viewport.height);
2016
+ }
2017
+ }
2018
+ /** Enable mouse wheel scrolling (call after adding to stage) */
2019
+ enableWheel(canvas) {
2020
+ if (this._onWheelBound) {
2021
+ canvas.addEventListener('wheel', this._onWheelBound, { passive: false });
2022
+ }
2023
+ }
2024
+ /** Set scrollable content. Replaces any existing items. */
2025
+ setContent(content) {
2026
+ this.clearItems();
1314
2027
  const children = [...content.children];
1315
- if (children.length > 0) {
1316
- this.addItems(children);
2028
+ for (const child of children) {
2029
+ this.addItem(child);
1317
2030
  }
1318
2031
  }
1319
2032
  /** Add a single item */
1320
- addItem(...items) {
1321
- this.addItems(items);
1322
- return items[0];
2033
+ addItem(child) {
2034
+ this._items.push(child);
2035
+ this._content.addChild(child);
2036
+ this.layoutItems();
2037
+ return this;
1323
2038
  }
1324
- /** Scroll to make a specific item/child visible */
2039
+ /** Remove all items */
2040
+ clearItems() {
2041
+ for (const item of this._items) {
2042
+ this._content.removeChild(item);
2043
+ }
2044
+ this._items.length = 0;
2045
+ }
2046
+ /** Get items */
2047
+ get items() {
2048
+ return this._items;
2049
+ }
2050
+ /** Scroll to make a specific item index visible */
1325
2051
  scrollToItem(index) {
1326
- this.scrollTo(index);
2052
+ if (index < 0 || index >= this._items.length)
2053
+ return;
2054
+ const item = this._items[index];
2055
+ const isVert = this._scrollConfig.direction !== 'horizontal';
2056
+ if (isVert) {
2057
+ this._content.y = -item.y + this._scrollConfig.padding;
2058
+ }
2059
+ else {
2060
+ this._content.x = -item.x + this._scrollConfig.padding;
2061
+ }
2062
+ this.clampScroll();
1327
2063
  }
1328
2064
  /** Current scroll position */
1329
2065
  get scrollPosition() {
1330
- return { x: this.scrollX, y: this.scrollY };
2066
+ return { x: this._content.x, y: this._content.y };
2067
+ }
2068
+ /** Resize the scroll viewport */
2069
+ setViewportSize(width, height) {
2070
+ this._viewport.width = width;
2071
+ this._viewport.height = height;
2072
+ this._maskGfx.clear();
2073
+ this._maskGfx.roundRect(0, 0, width, height, this._scrollConfig.borderRadius).fill(0xffffff);
2074
+ if (this._bg) {
2075
+ this._bg.clear();
2076
+ this._bg.roundRect(0, 0, width, height, this._scrollConfig.borderRadius)
2077
+ .fill(0xffffff); // color will be overridden if needed
2078
+ }
2079
+ this.clampScroll();
2080
+ }
2081
+ // ─── Layout ──────────────────────────────────────────
2082
+ layoutItems() {
2083
+ const { direction, elementsMargin, padding } = this._scrollConfig;
2084
+ const isVert = direction !== 'horizontal';
2085
+ let pos = padding;
2086
+ for (const item of this._items) {
2087
+ if (isVert) {
2088
+ item.x = padding;
2089
+ item.y = pos;
2090
+ pos += item.height + elementsMargin;
2091
+ }
2092
+ else {
2093
+ item.x = pos;
2094
+ item.y = padding;
2095
+ pos += item.width + elementsMargin;
2096
+ }
2097
+ }
2098
+ }
2099
+ // ─── Drag handling ───────────────────────────────────
2100
+ _onPointerDown(e) {
2101
+ this._dragging = true;
2102
+ this._inertiaActive = false;
2103
+ this._dragStart.x = e.globalX;
2104
+ this._dragStart.y = e.globalY;
2105
+ this._contentStart.x = this._content.x;
2106
+ this._contentStart.y = this._content.y;
2107
+ this._lastDragPos.x = e.globalX;
2108
+ this._lastDragPos.y = e.globalY;
2109
+ this._lastDragTime = Date.now();
2110
+ this._velocity.x = 0;
2111
+ this._velocity.y = 0;
2112
+ this.stopInertia();
2113
+ }
2114
+ _onPointerMove(e) {
2115
+ if (!this._dragging)
2116
+ return;
2117
+ const dx = e.globalX - this._dragStart.x;
2118
+ const dy = e.globalY - this._dragStart.y;
2119
+ const { direction } = this._scrollConfig;
2120
+ if (direction !== 'horizontal') {
2121
+ this._content.y = this._contentStart.y + dy;
2122
+ }
2123
+ if (direction !== 'vertical') {
2124
+ this._content.x = this._contentStart.x + dx;
2125
+ }
2126
+ // Track velocity
2127
+ const now = Date.now();
2128
+ const dt = now - this._lastDragTime;
2129
+ if (dt > 0) {
2130
+ this._velocity.x = (e.globalX - this._lastDragPos.x) / dt * 16;
2131
+ this._velocity.y = (e.globalY - this._lastDragPos.y) / dt * 16;
2132
+ }
2133
+ this._lastDragPos.x = e.globalX;
2134
+ this._lastDragPos.y = e.globalY;
2135
+ this._lastDragTime = now;
2136
+ this.clampScroll();
2137
+ }
2138
+ _onPointerUp() {
2139
+ if (!this._dragging)
2140
+ return;
2141
+ this._dragging = false;
2142
+ if (!this._scrollConfig.disableEasing &&
2143
+ (Math.abs(this._velocity.x) > MIN_VELOCITY || Math.abs(this._velocity.y) > MIN_VELOCITY)) {
2144
+ this.startInertia();
2145
+ }
2146
+ }
2147
+ // ─── Inertia ─────────────────────────────────────────
2148
+ startInertia() {
2149
+ this._inertiaActive = true;
2150
+ this._onTickBound = this._inertiaTick.bind(this);
2151
+ Ticker.shared.add(this._onTickBound);
2152
+ }
2153
+ stopInertia() {
2154
+ if (this._onTickBound && this._inertiaActive) {
2155
+ Ticker.shared.remove(this._onTickBound);
2156
+ this._inertiaActive = false;
2157
+ }
2158
+ }
2159
+ _inertiaTick() {
2160
+ const { direction } = this._scrollConfig;
2161
+ if (direction !== 'horizontal') {
2162
+ this._content.y += this._velocity.y;
2163
+ this._velocity.y *= DECELERATION;
2164
+ }
2165
+ if (direction !== 'vertical') {
2166
+ this._content.x += this._velocity.x;
2167
+ this._velocity.x *= DECELERATION;
2168
+ }
2169
+ this.clampScroll();
2170
+ if (Math.abs(this._velocity.x) < MIN_VELOCITY && Math.abs(this._velocity.y) < MIN_VELOCITY) {
2171
+ this.stopInertia();
2172
+ }
2173
+ }
2174
+ // ─── Mouse wheel ─────────────────────────────────────
2175
+ _onWheel(e) {
2176
+ const { direction } = this._scrollConfig;
2177
+ e.preventDefault();
2178
+ if (direction !== 'horizontal') {
2179
+ this._content.y -= e.deltaY;
2180
+ }
2181
+ if (direction !== 'vertical') {
2182
+ this._content.x -= e.deltaX;
2183
+ }
2184
+ this.clampScroll();
2185
+ }
2186
+ // ─── Scroll bounds ───────────────────────────────────
2187
+ clampScroll() {
2188
+ const { direction } = this._scrollConfig;
2189
+ const bounds = this._content.getLocalBounds();
2190
+ if (direction !== 'horizontal') {
2191
+ const contentHeight = bounds.height + bounds.y;
2192
+ const maxScroll = Math.min(0, this._viewport.height - contentHeight);
2193
+ this._content.y = Math.max(maxScroll, Math.min(0, this._content.y));
2194
+ }
2195
+ if (direction !== 'vertical') {
2196
+ const contentWidth = bounds.width + bounds.x;
2197
+ const maxScroll = Math.min(0, this._viewport.width - contentWidth);
2198
+ this._content.x = Math.max(maxScroll, Math.min(0, this._content.x));
2199
+ }
2200
+ this.updateScrollbar();
2201
+ }
2202
+ updateScrollbar() {
2203
+ if (!this._scrollbar)
2204
+ return;
2205
+ const { direction } = this._scrollConfig;
2206
+ const { width: sbW, padding: sbPad } = this._scrollbarConfig;
2207
+ const bounds = this._content.getLocalBounds();
2208
+ const isVert = direction !== 'horizontal';
2209
+ if (isVert) {
2210
+ const contentH = bounds.height + bounds.y;
2211
+ if (contentH <= this._viewport.height) {
2212
+ this._scrollbar.visible = false;
2213
+ return;
2214
+ }
2215
+ this._scrollbar.visible = true;
2216
+ const ratio = this._viewport.height / contentH;
2217
+ const thumbH = Math.max(20, this._viewport.height * ratio);
2218
+ const scrollRange = this._viewport.height - thumbH;
2219
+ const scrollProgress = -this._content.y / (contentH - this._viewport.height);
2220
+ this._scrollbar.x = this._viewport.width - sbW - sbPad;
2221
+ this._scrollbar.y = scrollProgress * scrollRange;
2222
+ this._scrollbar.height = thumbH;
2223
+ this._scrollbar.width = sbW;
2224
+ }
2225
+ else {
2226
+ const contentW = bounds.width + bounds.x;
2227
+ if (contentW <= this._viewport.width) {
2228
+ this._scrollbar.visible = false;
2229
+ return;
2230
+ }
2231
+ this._scrollbar.visible = true;
2232
+ const ratio = this._viewport.width / contentW;
2233
+ const thumbW = Math.max(20, this._viewport.width * ratio);
2234
+ const scrollRange = this._viewport.width - thumbW;
2235
+ const scrollProgress = -this._content.x / (contentW - this._viewport.width);
2236
+ this._scrollbar.y = this._viewport.height - sbW - sbPad;
2237
+ this._scrollbar.x = scrollProgress * scrollRange;
2238
+ this._scrollbar.width = thumbW;
2239
+ this._scrollbar.height = sbW;
2240
+ }
2241
+ }
2242
+ destroy(options) {
2243
+ this.stopInertia();
2244
+ this.off('pointerdown', this._onPointerDown, this);
2245
+ this.off('pointermove', this._onPointerMove, this);
2246
+ this.off('pointerup', this._onPointerUp, this);
2247
+ this.off('pointerupoutside', this._onPointerUp, this);
2248
+ this._items.length = 0;
2249
+ super.destroy(options);
1331
2250
  }
1332
2251
  }
1333
2252
 
1334
- export { BalanceDisplay, Button, Label, Layout, Modal, Panel, ProgressBar, ScrollContainer, Toast, WinDisplay };
2253
+ export { BalanceDisplay, Button, FlexContainer, Label, Layout, Modal, Panel, ProgressBar, ScrollContainer, Toast, WinDisplay, resolveView };
1335
2254
  //# sourceMappingURL=ui.esm.js.map