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