@energy8platform/game-engine 0.2.1 → 0.4.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 (63) hide show
  1. package/README.md +400 -35
  2. package/dist/animation.cjs.js +191 -1
  3. package/dist/animation.cjs.js.map +1 -1
  4. package/dist/animation.d.ts +117 -1
  5. package/dist/animation.esm.js +192 -3
  6. package/dist/animation.esm.js.map +1 -1
  7. package/dist/audio.cjs.js +66 -16
  8. package/dist/audio.cjs.js.map +1 -1
  9. package/dist/audio.d.ts +4 -0
  10. package/dist/audio.esm.js +66 -16
  11. package/dist/audio.esm.js.map +1 -1
  12. package/dist/core.cjs.js +307 -85
  13. package/dist/core.cjs.js.map +1 -1
  14. package/dist/core.d.ts +60 -1
  15. package/dist/core.esm.js +308 -86
  16. package/dist/core.esm.js.map +1 -1
  17. package/dist/debug.cjs.js +36 -68
  18. package/dist/debug.cjs.js.map +1 -1
  19. package/dist/debug.d.ts +4 -6
  20. package/dist/debug.esm.js +36 -68
  21. package/dist/debug.esm.js.map +1 -1
  22. package/dist/index.cjs.js +997 -475
  23. package/dist/index.cjs.js.map +1 -1
  24. package/dist/index.d.ts +356 -79
  25. package/dist/index.esm.js +983 -478
  26. package/dist/index.esm.js.map +1 -1
  27. package/dist/ui.cjs.js +816 -529
  28. package/dist/ui.cjs.js.map +1 -1
  29. package/dist/ui.d.ts +179 -41
  30. package/dist/ui.esm.js +798 -531
  31. package/dist/ui.esm.js.map +1 -1
  32. package/dist/vite.cjs.js +85 -68
  33. package/dist/vite.cjs.js.map +1 -1
  34. package/dist/vite.d.ts +17 -23
  35. package/dist/vite.esm.js +86 -68
  36. package/dist/vite.esm.js.map +1 -1
  37. package/package.json +19 -5
  38. package/src/animation/SpriteAnimation.ts +210 -0
  39. package/src/animation/Tween.ts +27 -1
  40. package/src/animation/index.ts +2 -0
  41. package/src/audio/AudioManager.ts +64 -15
  42. package/src/core/EventEmitter.ts +7 -1
  43. package/src/core/GameApplication.ts +19 -7
  44. package/src/core/SceneManager.ts +3 -1
  45. package/src/debug/DevBridge.ts +49 -80
  46. package/src/index.ts +22 -0
  47. package/src/input/InputManager.ts +26 -0
  48. package/src/loading/CSSPreloader.ts +7 -33
  49. package/src/loading/LoadingScene.ts +17 -41
  50. package/src/loading/index.ts +1 -0
  51. package/src/loading/logo.ts +95 -0
  52. package/src/types.ts +4 -0
  53. package/src/ui/BalanceDisplay.ts +12 -1
  54. package/src/ui/Button.ts +71 -130
  55. package/src/ui/Layout.ts +286 -0
  56. package/src/ui/Modal.ts +6 -5
  57. package/src/ui/Panel.ts +52 -55
  58. package/src/ui/ProgressBar.ts +52 -57
  59. package/src/ui/ScrollContainer.ts +126 -0
  60. package/src/ui/Toast.ts +19 -13
  61. package/src/ui/index.ts +17 -0
  62. package/src/viewport/ViewportManager.ts +2 -0
  63. package/src/vite/index.ts +103 -83
package/dist/ui.esm.js CHANGED
@@ -1,409 +1,117 @@
1
- import { Ticker, Container, Graphics, Texture, Sprite, Text, NineSliceSprite } from 'pixi.js';
2
-
3
- /**
4
- * Collection of easing functions for use with Tween and Timeline.
5
- *
6
- * All functions take a progress value t (0..1) and return the eased value.
7
- */
8
- const Easing = {
9
- linear: (t) => t,
10
- easeInQuad: (t) => t * t,
11
- easeOutQuad: (t) => t * (2 - t),
12
- easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
13
- easeInCubic: (t) => t * t * t,
14
- easeOutCubic: (t) => --t * t * t + 1,
15
- easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
16
- easeInQuart: (t) => t * t * t * t,
17
- easeOutQuart: (t) => 1 - --t * t * t * t,
18
- easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
19
- easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
20
- easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
21
- easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
22
- easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
23
- easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
24
- easeInOutExpo: (t) => t === 0
25
- ? 0
26
- : t === 1
27
- ? 1
28
- : t < 0.5
29
- ? Math.pow(2, 20 * t - 10) / 2
30
- : (2 - Math.pow(2, -20 * t + 10)) / 2,
31
- easeInBack: (t) => {
32
- const c1 = 1.70158;
33
- const c3 = c1 + 1;
34
- return c3 * t * t * t - c1 * t * t;
35
- },
36
- easeOutBack: (t) => {
37
- const c1 = 1.70158;
38
- const c3 = c1 + 1;
39
- return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
40
- },
41
- easeInOutBack: (t) => {
42
- const c1 = 1.70158;
43
- const c2 = c1 * 1.525;
44
- return t < 0.5
45
- ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
46
- : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
47
- },
48
- easeOutBounce: (t) => {
49
- const n1 = 7.5625;
50
- const d1 = 2.75;
51
- if (t < 1 / d1)
52
- return n1 * t * t;
53
- if (t < 2 / d1)
54
- return n1 * (t -= 1.5 / d1) * t + 0.75;
55
- if (t < 2.5 / d1)
56
- return n1 * (t -= 2.25 / d1) * t + 0.9375;
57
- return n1 * (t -= 2.625 / d1) * t + 0.984375;
58
- },
59
- easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
60
- easeInOutBounce: (t) => t < 0.5
61
- ? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
62
- : (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
63
- easeOutElastic: (t) => {
64
- const c4 = (2 * Math.PI) / 3;
65
- return t === 0
66
- ? 0
67
- : t === 1
68
- ? 1
69
- : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
70
- },
71
- easeInElastic: (t) => {
72
- const c4 = (2 * Math.PI) / 3;
73
- return t === 0
74
- ? 0
75
- : t === 1
76
- ? 1
77
- : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
78
- },
79
- };
80
-
81
- /**
82
- * Lightweight tween system integrated with PixiJS Ticker.
83
- * Zero external dependencies — no GSAP required.
84
- *
85
- * All tweens return a Promise that resolves on completion.
86
- *
87
- * @example
88
- * ```ts
89
- * // Fade in a sprite
90
- * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
91
- *
92
- * // Move and wait
93
- * await Tween.to(sprite, { x: 500 }, 300);
94
- *
95
- * // From a starting value
96
- * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
97
- * ```
98
- */
99
- class Tween {
100
- static _tweens = [];
101
- static _tickerAdded = false;
102
- /**
103
- * Animate properties from current values to target values.
104
- *
105
- * @param target - Object to animate (Sprite, Container, etc.)
106
- * @param props - Target property values
107
- * @param duration - Duration in milliseconds
108
- * @param easing - Easing function (default: easeOutQuad)
109
- * @param onUpdate - Progress callback (0..1)
110
- */
111
- static to(target, props, duration, easing, onUpdate) {
112
- return new Promise((resolve) => {
113
- // Capture starting values
114
- const from = {};
115
- for (const key of Object.keys(props)) {
116
- from[key] = Tween.getProperty(target, key);
117
- }
118
- const tween = {
119
- target,
120
- from,
121
- to: { ...props },
122
- duration: Math.max(1, duration),
123
- easing: easing ?? Easing.easeOutQuad,
124
- elapsed: 0,
125
- delay: 0,
126
- resolve,
127
- onUpdate,
128
- };
129
- Tween._tweens.push(tween);
130
- Tween.ensureTicker();
131
- });
132
- }
133
- /**
134
- * Animate properties from given values to current values.
135
- */
136
- static from(target, props, duration, easing, onUpdate) {
137
- // Capture current values as "to"
138
- const to = {};
139
- for (const key of Object.keys(props)) {
140
- to[key] = Tween.getProperty(target, key);
141
- Tween.setProperty(target, key, props[key]);
142
- }
143
- return Tween.to(target, to, duration, easing, onUpdate);
144
- }
145
- /**
146
- * Animate from one set of values to another.
147
- */
148
- static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
149
- // Set starting values
150
- for (const key of Object.keys(fromProps)) {
151
- Tween.setProperty(target, key, fromProps[key]);
152
- }
153
- return Tween.to(target, toProps, duration, easing, onUpdate);
154
- }
155
- /**
156
- * Wait for a given duration (useful in timelines).
157
- */
158
- static delay(ms) {
159
- return new Promise((resolve) => setTimeout(resolve, ms));
160
- }
161
- /**
162
- * Kill all tweens on a target.
163
- */
164
- static killTweensOf(target) {
165
- Tween._tweens = Tween._tweens.filter((tw) => {
166
- if (tw.target === target) {
167
- tw.resolve();
168
- return false;
169
- }
170
- return true;
171
- });
172
- }
173
- /**
174
- * Kill all active tweens.
175
- */
176
- static killAll() {
177
- for (const tw of Tween._tweens) {
178
- tw.resolve();
179
- }
180
- Tween._tweens.length = 0;
181
- }
182
- /** Number of active tweens */
183
- static get activeTweens() {
184
- return Tween._tweens.length;
185
- }
186
- // ─── Internal ──────────────────────────────────────────
187
- static ensureTicker() {
188
- if (Tween._tickerAdded)
189
- return;
190
- Tween._tickerAdded = true;
191
- Ticker.shared.add(Tween.tick);
192
- }
193
- static tick = (ticker) => {
194
- const dt = ticker.deltaMS;
195
- const completed = [];
196
- for (const tw of Tween._tweens) {
197
- tw.elapsed += dt;
198
- if (tw.elapsed < tw.delay)
199
- continue;
200
- const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
201
- const t = tw.easing(raw);
202
- // Interpolate each property
203
- for (const key of Object.keys(tw.to)) {
204
- const start = tw.from[key];
205
- const end = tw.to[key];
206
- const value = start + (end - start) * t;
207
- Tween.setProperty(tw.target, key, value);
208
- }
209
- tw.onUpdate?.(raw);
210
- if (raw >= 1) {
211
- completed.push(tw);
212
- }
213
- }
214
- // Remove completed tweens
215
- for (const tw of completed) {
216
- const idx = Tween._tweens.indexOf(tw);
217
- if (idx !== -1)
218
- Tween._tweens.splice(idx, 1);
219
- tw.resolve();
220
- }
221
- // Remove ticker when no active tweens
222
- if (Tween._tweens.length === 0 && Tween._tickerAdded) {
223
- Ticker.shared.remove(Tween.tick);
224
- Tween._tickerAdded = false;
225
- }
226
- };
227
- /**
228
- * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
229
- */
230
- static getProperty(target, key) {
231
- const parts = key.split('.');
232
- let obj = target;
233
- for (let i = 0; i < parts.length - 1; i++) {
234
- obj = obj[parts[i]];
235
- }
236
- return obj[parts[parts.length - 1]] ?? 0;
237
- }
238
- /**
239
- * Set a potentially nested property.
240
- */
241
- static setProperty(target, key, value) {
242
- const parts = key.split('.');
243
- let obj = target;
244
- for (let i = 0; i < parts.length - 1; i++) {
245
- obj = obj[parts[i]];
246
- }
247
- obj[parts[parts.length - 1]] = value;
248
- }
249
- }
1
+ export { Layout as PixiLayout } from '@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
+ export { ButtonContainer, FancyButton, ScrollBox } from '@pixi/ui';
5
+ import { LayoutContainer } from '@pixi/layout/components';
6
+ export { LayoutContainer } from '@pixi/layout/components';
250
7
 
251
8
  const DEFAULT_COLORS = {
252
- normal: 0xffd700,
9
+ default: 0xffd700,
253
10
  hover: 0xffe44d,
254
11
  pressed: 0xccac00,
255
12
  disabled: 0x666666,
256
13
  };
14
+ function makeGraphicsView(w, h, radius, color) {
15
+ const g = new 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 });
19
+ return g;
20
+ }
257
21
  /**
258
- * Interactive button component with state management and animation.
22
+ * Interactive button component powered by `@pixi/ui` FancyButton.
259
23
  *
260
- * Supports both texture-based and Graphics-based rendering.
24
+ * Supports both texture-based and Graphics-based rendering with
25
+ * per-state views, press animation, and text.
261
26
  *
262
27
  * @example
263
28
  * ```ts
264
29
  * const btn = new Button({
265
30
  * width: 200, height: 60, borderRadius: 12,
266
- * colors: { normal: 0x22aa22, hover: 0x33cc33 },
31
+ * colors: { default: 0x22aa22, hover: 0x33cc33 },
32
+ * text: 'SPIN',
267
33
  * });
268
34
  *
269
- * btn.onTap = () => console.log('Clicked!');
35
+ * btn.onPress.connect(() => console.log('Clicked!'));
270
36
  * scene.container.addChild(btn);
271
37
  * ```
272
38
  */
273
- class Button extends Container {
274
- _state = 'normal';
275
- _bg;
276
- _sprites = {};
277
- _config;
278
- /** Called when the button is tapped/clicked */
279
- onTap;
280
- /** Called when the button state changes */
281
- onStateChange;
39
+ class Button extends FancyButton {
40
+ _buttonConfig;
282
41
  constructor(config = {}) {
283
- super();
284
- this._config = {
285
- width: 200,
286
- height: 60,
287
- borderRadius: 8,
288
- pressScale: 0.95,
289
- animationDuration: 100,
42
+ const resolvedConfig = {
43
+ width: config.width ?? 200,
44
+ height: config.height ?? 60,
45
+ borderRadius: config.borderRadius ?? 8,
46
+ pressScale: config.pressScale ?? 0.95,
47
+ animationDuration: config.animationDuration ?? 100,
290
48
  ...config,
291
49
  };
292
- // Create Graphics background
293
- this._bg = new Graphics();
294
- this.addChild(this._bg);
295
- // Create texture sprites if provided
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
296
67
  if (config.textures) {
297
- for (const [state, tex] of Object.entries(config.textures)) {
298
- const texture = typeof tex === 'string' ? Texture.from(tex) : tex;
299
- const sprite = new Sprite(texture);
300
- sprite.anchor.set(0.5);
301
- sprite.visible = state === 'normal';
302
- this._sprites[state] = sprite;
303
- this.addChild(sprite);
304
- }
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
+ }
84
+ // Text
85
+ if (config.text) {
86
+ options.text = config.text;
305
87
  }
306
- // Make interactive
307
- this.eventMode = 'static';
308
- this.cursor = 'pointer';
309
- // Set up hit area for Graphics-based
310
- this.pivot.set(this._config.width / 2, this._config.height / 2);
311
- // Bind events
312
- this.on('pointerover', this.onPointerOver);
313
- this.on('pointerout', this.onPointerOut);
314
- this.on('pointerdown', this.onPointerDown);
315
- this.on('pointerup', this.onPointerUp);
316
- this.on('pointertap', this.onPointerTap);
317
- // Initial render
318
- this.setState('normal');
88
+ super(options);
89
+ this._buttonConfig = resolvedConfig;
319
90
  if (config.disabled) {
320
- this.disable();
91
+ this.enabled = false;
321
92
  }
322
93
  }
323
- /** Current button state */
324
- get state() {
325
- return this._state;
326
- }
327
94
  /** Enable the button */
328
95
  enable() {
329
- if (this._state === 'disabled') {
330
- this.setState('normal');
331
- this.eventMode = 'static';
332
- this.cursor = 'pointer';
333
- }
96
+ this.enabled = true;
334
97
  }
335
98
  /** Disable the button */
336
99
  disable() {
337
- this.setState('disabled');
338
- this.eventMode = 'none';
339
- this.cursor = 'default';
100
+ this.enabled = false;
340
101
  }
341
102
  /** Whether the button is disabled */
342
103
  get disabled() {
343
- return this._state === 'disabled';
104
+ return !this.enabled;
344
105
  }
345
- setState(state) {
346
- if (this._state === state)
347
- return;
348
- this._state = state;
349
- this.render();
350
- this.onStateChange?.(state);
351
- }
352
- render() {
353
- const { width, height, borderRadius, colors } = this._config;
354
- const colorMap = { ...DEFAULT_COLORS, ...colors };
355
- // Update Graphics
356
- this._bg.clear();
357
- this._bg.roundRect(0, 0, width, height, borderRadius).fill(colorMap[this._state]);
358
- // Add highlight for normal/hover
359
- if (this._state === 'normal' || this._state === 'hover') {
360
- this._bg
361
- .roundRect(2, 2, width - 4, height * 0.45, borderRadius)
362
- .fill({ color: 0xffffff, alpha: 0.1 });
363
- }
364
- // Update sprite visibility
365
- for (const [state, sprite] of Object.entries(this._sprites)) {
366
- if (sprite)
367
- sprite.visible = state === this._state;
368
- }
369
- // Fall back to normal sprite if state sprite doesn't exist
370
- if (!this._sprites[this._state] && this._sprites.normal) {
371
- this._sprites.normal.visible = true;
372
- }
373
- }
374
- onPointerOver = () => {
375
- if (this._state === 'disabled')
376
- return;
377
- this.setState('hover');
378
- };
379
- onPointerOut = () => {
380
- if (this._state === 'disabled')
381
- return;
382
- this.setState('normal');
383
- Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration);
384
- };
385
- onPointerDown = () => {
386
- if (this._state === 'disabled')
387
- return;
388
- this.setState('pressed');
389
- const s = this._config.pressScale;
390
- Tween.to(this.scale, { x: s, y: s }, this._config.animationDuration, Easing.easeOutQuad);
391
- };
392
- onPointerUp = () => {
393
- if (this._state === 'disabled')
394
- return;
395
- this.setState('hover');
396
- Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration, Easing.easeOutBack);
397
- };
398
- onPointerTap = () => {
399
- if (this._state === 'disabled')
400
- return;
401
- this.onTap?.();
402
- };
403
106
  }
404
107
 
108
+ function makeBarGraphics(w, h, radius, color) {
109
+ return new Graphics().roundRect(0, 0, w, h, radius).fill(color);
110
+ }
405
111
  /**
406
- * Horizontal progress bar with optional smooth fill animation.
112
+ * Horizontal progress bar powered by `@pixi/ui` ProgressBar.
113
+ *
114
+ * Provides optional smooth animated fill via per-frame `update()`.
407
115
  *
408
116
  * @example
409
117
  * ```ts
@@ -413,33 +121,48 @@ class Button extends Container {
413
121
  * ```
414
122
  */
415
123
  class ProgressBar extends Container {
416
- _track;
417
- _fill;
418
- _border;
124
+ _bar;
125
+ _borderGfx;
419
126
  _config;
420
127
  _progress = 0;
421
128
  _displayedProgress = 0;
422
129
  constructor(config = {}) {
423
130
  super();
424
131
  this._config = {
425
- width: 300,
426
- height: 16,
427
- borderRadius: 8,
428
- fillColor: 0xffd700,
429
- trackColor: 0x333333,
430
- borderColor: 0x555555,
431
- borderWidth: 1,
432
- animated: true,
433
- animationSpeed: 0.1,
434
- ...config,
132
+ width: config.width ?? 300,
133
+ height: config.height ?? 16,
134
+ borderRadius: config.borderRadius ?? 8,
135
+ fillColor: config.fillColor ?? 0xffd700,
136
+ trackColor: config.trackColor ?? 0x333333,
137
+ borderColor: config.borderColor ?? 0x555555,
138
+ borderWidth: config.borderWidth ?? 1,
139
+ animated: config.animated ?? true,
140
+ animationSpeed: config.animationSpeed ?? 0.1,
141
+ };
142
+ 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,
435
155
  };
436
- this._track = new Graphics();
437
- this._fill = new Graphics();
438
- this._border = new Graphics();
439
- this.addChild(this._track, this._fill, this._border);
440
- this.drawTrack();
441
- this.drawBorder();
442
- this.drawFill(0);
156
+ this._bar = new ProgressBar$1(options);
157
+ this.addChild(this._bar);
158
+ // Border overlay
159
+ this._borderGfx = new Graphics();
160
+ if (borderColor !== undefined && borderWidth > 0) {
161
+ this._borderGfx
162
+ .roundRect(0, 0, width, height, borderRadius)
163
+ .stroke({ color: borderColor, width: borderWidth });
164
+ }
165
+ this.addChild(this._borderGfx);
443
166
  }
444
167
  /** Get/set progress (0..1) */
445
168
  get progress() {
@@ -449,13 +172,13 @@ class ProgressBar extends Container {
449
172
  this._progress = Math.max(0, Math.min(1, value));
450
173
  if (!this._config.animated) {
451
174
  this._displayedProgress = this._progress;
452
- this.drawFill(this._displayedProgress);
175
+ this._bar.progress = this._displayedProgress * 100;
453
176
  }
454
177
  }
455
178
  /**
456
179
  * Call each frame if animated is true.
457
180
  */
458
- update(dt) {
181
+ update(_dt) {
459
182
  if (!this._config.animated)
460
183
  return;
461
184
  if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
@@ -464,35 +187,7 @@ class ProgressBar extends Container {
464
187
  }
465
188
  this._displayedProgress +=
466
189
  (this._progress - this._displayedProgress) * this._config.animationSpeed;
467
- this.drawFill(this._displayedProgress);
468
- }
469
- drawTrack() {
470
- const { width, height, borderRadius, trackColor } = this._config;
471
- this._track.clear();
472
- this._track.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
473
- }
474
- drawBorder() {
475
- const { width, height, borderRadius, borderColor, borderWidth } = this._config;
476
- this._border.clear();
477
- this._border
478
- .roundRect(0, 0, width, height, borderRadius)
479
- .stroke({ color: borderColor, width: borderWidth });
480
- }
481
- drawFill(progress) {
482
- const { width, height, borderRadius, fillColor, borderWidth } = this._config;
483
- const innerWidth = width - borderWidth * 2;
484
- const innerHeight = height - borderWidth * 2;
485
- const fillWidth = Math.max(0, innerWidth * progress);
486
- this._fill.clear();
487
- if (fillWidth > 0) {
488
- this._fill.x = borderWidth;
489
- this._fill.y = borderWidth;
490
- this._fill.roundRect(0, 0, fillWidth, innerHeight, borderRadius - 1).fill(fillColor);
491
- // Highlight
492
- this._fill
493
- .roundRect(0, 0, fillWidth, innerHeight * 0.4, borderRadius - 1)
494
- .fill({ color: 0xffffff, alpha: 0.15 });
495
- }
190
+ this._bar.progress = this._displayedProgress * 100;
496
191
  }
497
192
  }
498
193
 
@@ -588,7 +283,10 @@ class Label extends Container {
588
283
  }
589
284
 
590
285
  /**
591
- * Background panel that can use either Graphics or 9-slice sprite.
286
+ * Background panel powered by `@pixi/layout` LayoutContainer.
287
+ *
288
+ * Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
289
+ * Children added to `content` participate in flexbox layout automatically.
592
290
  *
593
291
  * @example
594
292
  * ```ts
@@ -603,75 +301,148 @@ class Label extends Container {
603
301
  * });
604
302
  * ```
605
303
  */
606
- class Panel extends Container {
607
- _bg;
608
- _content;
609
- _config;
304
+ class Panel extends LayoutContainer {
305
+ _panelConfig;
610
306
  constructor(config = {}) {
611
- super();
612
- this._config = {
613
- width: 400,
614
- height: 300,
615
- padding: 16,
616
- backgroundAlpha: 1,
307
+ const resolvedConfig = {
308
+ width: config.width ?? 400,
309
+ height: config.height ?? 300,
310
+ padding: config.padding ?? 16,
311
+ backgroundAlpha: config.backgroundAlpha ?? 1,
617
312
  ...config,
618
313
  };
619
- // Create background
314
+ // If using a 9-slice texture, pass it as a custom background
315
+ let customBackground;
620
316
  if (config.nineSliceTexture) {
621
317
  const texture = typeof config.nineSliceTexture === 'string'
622
318
  ? Texture.from(config.nineSliceTexture)
623
319
  : config.nineSliceTexture;
624
320
  const [left, top, right, bottom] = config.nineSliceBorders ?? [10, 10, 10, 10];
625
- this._bg = new NineSliceSprite({
321
+ const nineSlice = new NineSliceSprite({
626
322
  texture,
627
323
  leftWidth: left,
628
324
  topHeight: top,
629
325
  rightWidth: right,
630
326
  bottomHeight: bottom,
631
327
  });
632
- this._bg.width = this._config.width;
633
- this._bg.height = this._config.height;
328
+ nineSlice.width = resolvedConfig.width;
329
+ nineSlice.height = resolvedConfig.height;
330
+ nineSlice.alpha = resolvedConfig.backgroundAlpha;
331
+ customBackground = nineSlice;
634
332
  }
635
- else {
636
- this._bg = new Graphics();
637
- this.drawGraphicsBg();
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;
346
+ if (config.borderColor !== undefined && config.borderWidth) {
347
+ layoutStyles.borderColor = config.borderColor;
348
+ layoutStyles.borderWidth = config.borderWidth;
349
+ }
350
+ }
351
+ this.layout = layoutStyles;
352
+ if (!config.nineSliceTexture) {
353
+ this.background.alpha = resolvedConfig.backgroundAlpha;
638
354
  }
639
- this._bg.alpha = this._config.backgroundAlpha;
640
- this.addChild(this._bg);
641
- // Content container with padding
642
- this._content = new Container();
643
- this._content.x = this._config.padding;
644
- this._content.y = this._config.padding;
645
- this.addChild(this._content);
646
355
  }
647
- /** Content container add children here */
356
+ /** Access the content container (children added here participate in layout) */
648
357
  get content() {
649
- return this._content;
358
+ return this.overflowContainer;
650
359
  }
651
360
  /** Resize the panel */
652
361
  setSize(width, height) {
653
- this._config.width = width;
654
- this._config.height = height;
655
- if (this._bg instanceof Graphics) {
656
- this.drawGraphicsBg();
657
- }
658
- else {
659
- this._bg.width = width;
660
- this._bg.height = height;
661
- }
662
- }
663
- drawGraphicsBg() {
664
- const bg = this._bg;
665
- const { width, height, backgroundColor, borderRadius, borderColor, borderWidth, } = this._config;
666
- bg.clear();
667
- bg.roundRect(0, 0, width, height, borderRadius ?? 0).fill(backgroundColor ?? 0x1a1a2e);
668
- if (borderColor !== undefined && borderWidth) {
669
- bg.roundRect(0, 0, width, height, borderRadius ?? 0)
670
- .stroke({ color: borderColor, width: borderWidth });
671
- }
362
+ this._panelConfig.width = width;
363
+ this._panelConfig.height = height;
364
+ this._layout?.setStyle({ width, height });
672
365
  }
673
366
  }
674
367
 
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
+
675
446
  /**
676
447
  * Reactive balance display component.
677
448
  *
@@ -694,6 +465,7 @@ class BalanceDisplay extends Container {
694
465
  _currentValue = 0;
695
466
  _displayedValue = 0;
696
467
  _animating = false;
468
+ _animationCancelled = false;
697
469
  constructor(config = {}) {
698
470
  super();
699
471
  this._config = {
@@ -755,11 +527,20 @@ class BalanceDisplay extends Container {
755
527
  this.updateDisplay();
756
528
  }
757
529
  async animateValue(from, to) {
530
+ if (this._animating) {
531
+ this._animationCancelled = true;
532
+ }
758
533
  this._animating = true;
534
+ this._animationCancelled = false;
759
535
  const duration = this._config.animationDuration;
760
536
  const startTime = Date.now();
761
537
  return new Promise((resolve) => {
762
538
  const tick = () => {
539
+ if (this._animationCancelled) {
540
+ this._animating = false;
541
+ resolve();
542
+ return;
543
+ }
763
544
  const elapsed = Date.now() - startTime;
764
545
  const t = Math.min(elapsed / duration, 1);
765
546
  const eased = Easing.easeOutCubic(t);
@@ -825,70 +606,265 @@ class WinDisplay extends Container {
825
606
  ...config.style,
826
607
  },
827
608
  });
828
- this.addChild(this._label);
829
- this.visible = false;
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
+ });
830
727
  }
831
728
  /**
832
- * Show a win with countup animation.
833
- *
834
- * @param amount - Win amount
835
- * @returns Promise that resolves when the animation completes
729
+ * Animate properties from given values to current values.
836
730
  */
837
- async showWin(amount) {
838
- this.visible = true;
839
- this._cancelCountup = false;
840
- this.alpha = 1;
841
- const duration = this._config.countupDuration;
842
- const startTime = Date.now();
843
- // Scale pop
844
- this.scale.set(0.5);
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) {
845
755
  return new Promise((resolve) => {
846
- const tick = () => {
847
- if (this._cancelCountup) {
848
- this.displayAmount(amount);
849
- resolve();
850
- return;
851
- }
852
- const elapsed = Date.now() - startTime;
853
- const t = Math.min(elapsed / duration, 1);
854
- const eased = Easing.easeOutCubic(t);
855
- // Countup
856
- const current = amount * eased;
857
- this.displayAmount(current);
858
- // Scale animation
859
- const scaleT = Math.min(elapsed / 300, 1);
860
- const scaleEased = Easing.easeOutBack(scaleT);
861
- const targetScale = 1;
862
- this.scale.set(0.5 + (targetScale - 0.5) * scaleEased);
863
- if (t < 1) {
864
- requestAnimationFrame(tick);
865
- }
866
- else {
867
- this.displayAmount(amount);
868
- this.scale.set(1);
756
+ let elapsed = 0;
757
+ const onTick = (ticker) => {
758
+ elapsed += ticker.deltaMS;
759
+ if (elapsed >= ms) {
760
+ Ticker.shared.remove(onTick);
869
761
  resolve();
870
762
  }
871
763
  };
872
- requestAnimationFrame(tick);
764
+ Ticker.shared.add(onTick);
873
765
  });
874
766
  }
875
767
  /**
876
- * Skip the countup animation and show the final amount immediately.
768
+ * Kill all tweens on a target.
877
769
  */
878
- skipCountup(amount) {
879
- this._cancelCountup = true;
880
- this.displayAmount(amount);
881
- this.scale.set(1);
770
+ static killTweensOf(target) {
771
+ Tween._tweens = Tween._tweens.filter((tw) => {
772
+ if (tw.target === target) {
773
+ tw.resolve();
774
+ return false;
775
+ }
776
+ return true;
777
+ });
882
778
  }
883
779
  /**
884
- * Hide the win display.
780
+ * Kill all active tweens.
885
781
  */
886
- hide() {
887
- this.visible = false;
888
- this._label.text = '';
782
+ static killAll() {
783
+ for (const tw of Tween._tweens) {
784
+ tw.resolve();
785
+ }
786
+ Tween._tweens.length = 0;
889
787
  }
890
- displayAmount(amount) {
891
- this._label.setCurrency(amount, this._config.currency, this._config.locale);
788
+ /** Number of active tweens */
789
+ static get activeTweens() {
790
+ return Tween._tweens.length;
791
+ }
792
+ /**
793
+ * Reset the tween system — kill all tweens and remove the ticker.
794
+ * Useful for cleanup between game instances, tests, or hot-reload.
795
+ */
796
+ static reset() {
797
+ for (const tw of Tween._tweens) {
798
+ tw.resolve();
799
+ }
800
+ Tween._tweens.length = 0;
801
+ if (Tween._tickerAdded) {
802
+ Ticker.shared.remove(Tween.tick);
803
+ Tween._tickerAdded = false;
804
+ }
805
+ }
806
+ // ─── Internal ──────────────────────────────────────────
807
+ static ensureTicker() {
808
+ if (Tween._tickerAdded)
809
+ return;
810
+ Tween._tickerAdded = true;
811
+ Ticker.shared.add(Tween.tick);
812
+ }
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
+ 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;
857
+ }
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;
892
868
  }
893
869
  }
894
870
 
@@ -896,6 +872,8 @@ class WinDisplay extends Container {
896
872
  * Modal overlay component.
897
873
  * Shows content on top of a dark overlay with enter/exit animations.
898
874
  *
875
+ * The content container uses `@pixi/layout` for automatic centering.
876
+ *
899
877
  * @example
900
878
  * ```ts
901
879
  * const modal = new Modal({ closeOnOverlay: true });
@@ -914,11 +892,10 @@ class Modal extends Container {
914
892
  constructor(config = {}) {
915
893
  super();
916
894
  this._config = {
917
- overlayColor: 0x000000,
918
- overlayAlpha: 0.7,
919
- closeOnOverlay: true,
920
- animationDuration: 300,
921
- ...config,
895
+ overlayColor: config.overlayColor ?? 0x000000,
896
+ overlayAlpha: config.overlayAlpha ?? 0.7,
897
+ closeOnOverlay: config.closeOnOverlay ?? true,
898
+ animationDuration: config.animationDuration ?? 300,
922
899
  };
923
900
  // Overlay
924
901
  this._overlay = new Graphics();
@@ -986,6 +963,8 @@ const TOAST_COLORS = {
986
963
  /**
987
964
  * Toast notification component for displaying transient messages.
988
965
  *
966
+ * Uses `@pixi/layout` LayoutContainer for auto-sized background.
967
+ *
989
968
  * @example
990
969
  * ```ts
991
970
  * const toast = new Toast();
@@ -1001,11 +980,10 @@ class Toast extends Container {
1001
980
  constructor(config = {}) {
1002
981
  super();
1003
982
  this._config = {
1004
- duration: 3000,
1005
- bottomOffset: 60,
1006
- ...config,
983
+ duration: config.duration ?? 3000,
984
+ bottomOffset: config.bottomOffset ?? 60,
1007
985
  };
1008
- this._bg = new Graphics();
986
+ this._bg = new LayoutContainer();
1009
987
  this.addChild(this._bg);
1010
988
  this._text = new Text({
1011
989
  text: '',
@@ -1023,7 +1001,6 @@ class Toast extends Container {
1023
1001
  * Show a toast message.
1024
1002
  */
1025
1003
  async show(message, type = 'info', viewWidth, viewHeight) {
1026
- // Clear previous dismiss
1027
1004
  if (this._dismissTimeout) {
1028
1005
  clearTimeout(this._dismissTimeout);
1029
1006
  }
@@ -1032,10 +1009,16 @@ class Toast extends Container {
1032
1009
  const width = Math.max(200, this._text.width + padding * 2);
1033
1010
  const height = 44;
1034
1011
  const radius = 8;
1035
- this._bg.clear();
1036
- this._bg.roundRect(-width / 2, -height / 2, width, height, radius).fill(TOAST_COLORS[type]);
1037
- this._bg.roundRect(-width / 2, -height / 2, width, height, radius)
1038
- .fill({ color: 0x000000, alpha: 0.2 });
1012
+ // Style the background
1013
+ this._bg.layout = {
1014
+ width,
1015
+ height,
1016
+ borderRadius: radius,
1017
+ backgroundColor: TOAST_COLORS[type],
1018
+ };
1019
+ // Center the bg around origin
1020
+ this._bg.x = -width / 2;
1021
+ this._bg.y = -height / 2;
1039
1022
  // Position
1040
1023
  if (viewWidth && viewHeight) {
1041
1024
  this.x = viewWidth / 2;
@@ -1044,9 +1027,7 @@ class Toast extends Container {
1044
1027
  this.visible = true;
1045
1028
  this.alpha = 0;
1046
1029
  this.y += 20;
1047
- // Animate in
1048
1030
  await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
1049
- // Auto-dismiss
1050
1031
  if (this._config.duration > 0) {
1051
1032
  this._dismissTimeout = setTimeout(() => {
1052
1033
  this.dismiss();
@@ -1068,5 +1049,291 @@ class Toast extends Container {
1068
1049
  }
1069
1050
  }
1070
1051
 
1071
- export { BalanceDisplay, Button, Label, Modal, Panel, ProgressBar, Toast, WinDisplay };
1052
+ // ─── Helpers ─────────────────────────────────────────────
1053
+ const ALIGNMENT_MAP = {
1054
+ start: 'flex-start',
1055
+ center: 'center',
1056
+ end: 'flex-end',
1057
+ stretch: 'stretch',
1058
+ };
1059
+ function normalizePadding(padding) {
1060
+ if (typeof padding === 'number')
1061
+ return [padding, padding, padding, padding];
1062
+ return padding;
1063
+ }
1064
+ function directionToFlexStyles(direction, maxWidth) {
1065
+ switch (direction) {
1066
+ case 'horizontal':
1067
+ return { flexDirection: 'row', flexWrap: 'nowrap' };
1068
+ case 'vertical':
1069
+ return { flexDirection: 'column', flexWrap: 'nowrap' };
1070
+ case 'grid':
1071
+ return { flexDirection: 'row', flexWrap: 'wrap' };
1072
+ case 'wrap':
1073
+ return {
1074
+ flexDirection: 'row',
1075
+ flexWrap: 'wrap',
1076
+ ...(maxWidth < Infinity ? { maxWidth } : {}),
1077
+ };
1078
+ }
1079
+ }
1080
+ function buildLayoutStyles(config) {
1081
+ const [pt, pr, pb, pl] = config.padding;
1082
+ return {
1083
+ ...directionToFlexStyles(config.direction, config.maxWidth),
1084
+ gap: config.gap,
1085
+ alignItems: ALIGNMENT_MAP[config.alignment],
1086
+ paddingTop: pt,
1087
+ paddingRight: pr,
1088
+ paddingBottom: pb,
1089
+ paddingLeft: pl,
1090
+ };
1091
+ }
1092
+ /**
1093
+ * Responsive layout container powered by `@pixi/layout` (Yoga flexbox engine).
1094
+ *
1095
+ * Supports horizontal, vertical, grid, and wrap layout modes with
1096
+ * alignment, padding, gap, and viewport-anchor positioning.
1097
+ * Breakpoints allow different layouts for different screen sizes.
1098
+ *
1099
+ * @example
1100
+ * ```ts
1101
+ * const toolbar = new Layout({
1102
+ * direction: 'horizontal',
1103
+ * gap: 20,
1104
+ * alignment: 'center',
1105
+ * anchor: 'bottom-center',
1106
+ * padding: 16,
1107
+ * breakpoints: {
1108
+ * 768: { direction: 'vertical', gap: 10 },
1109
+ * },
1110
+ * });
1111
+ *
1112
+ * toolbar.addItem(spinButton);
1113
+ * toolbar.addItem(betLabel);
1114
+ * scene.container.addChild(toolbar);
1115
+ *
1116
+ * toolbar.updateViewport(width, height);
1117
+ * ```
1118
+ */
1119
+ class Layout extends Container {
1120
+ _layoutConfig;
1121
+ _padding;
1122
+ _anchor;
1123
+ _maxWidth;
1124
+ _breakpoints;
1125
+ _items = [];
1126
+ _viewportWidth = 0;
1127
+ _viewportHeight = 0;
1128
+ constructor(config = {}) {
1129
+ super();
1130
+ this._layoutConfig = {
1131
+ direction: config.direction ?? 'vertical',
1132
+ gap: config.gap ?? 0,
1133
+ alignment: config.alignment ?? 'start',
1134
+ autoLayout: config.autoLayout ?? true,
1135
+ columns: config.columns ?? 2,
1136
+ };
1137
+ this._padding = normalizePadding(config.padding ?? 0);
1138
+ this._anchor = config.anchor ?? 'top-left';
1139
+ this._maxWidth = config.maxWidth ?? Infinity;
1140
+ this._breakpoints = config.breakpoints
1141
+ ? Object.entries(config.breakpoints)
1142
+ .map(([w, cfg]) => [Number(w), cfg])
1143
+ .sort((a, b) => a[0] - b[0])
1144
+ : [];
1145
+ this.applyLayoutStyles();
1146
+ }
1147
+ /** Add an item to the layout */
1148
+ addItem(child) {
1149
+ this._items.push(child);
1150
+ this.addChild(child);
1151
+ if (this._layoutConfig.direction === 'grid') {
1152
+ this.applyGridChildWidth(child);
1153
+ }
1154
+ return this;
1155
+ }
1156
+ /** Remove an item from the layout */
1157
+ removeItem(child) {
1158
+ const idx = this._items.indexOf(child);
1159
+ if (idx !== -1) {
1160
+ this._items.splice(idx, 1);
1161
+ this.removeChild(child);
1162
+ }
1163
+ return this;
1164
+ }
1165
+ /** Remove all items */
1166
+ clearItems() {
1167
+ for (const item of this._items) {
1168
+ this.removeChild(item);
1169
+ }
1170
+ this._items.length = 0;
1171
+ return this;
1172
+ }
1173
+ /** Get all layout items */
1174
+ get items() {
1175
+ return this._items;
1176
+ }
1177
+ /**
1178
+ * Update the viewport size and recalculate layout.
1179
+ * Should be called from `Scene.onResize()`.
1180
+ */
1181
+ updateViewport(width, height) {
1182
+ this._viewportWidth = width;
1183
+ this._viewportHeight = height;
1184
+ this.applyLayoutStyles();
1185
+ this.applyAnchor();
1186
+ }
1187
+ applyLayoutStyles() {
1188
+ const effective = this.resolveConfig();
1189
+ const direction = effective.direction ?? this._layoutConfig.direction;
1190
+ const gap = effective.gap ?? this._layoutConfig.gap;
1191
+ const alignment = effective.alignment ?? this._layoutConfig.alignment;
1192
+ effective.columns ?? this._layoutConfig.columns;
1193
+ const padding = effective.padding !== undefined
1194
+ ? normalizePadding(effective.padding)
1195
+ : this._padding;
1196
+ const maxWidth = effective.maxWidth ?? this._maxWidth;
1197
+ const styles = buildLayoutStyles({ direction, gap, alignment, padding, maxWidth });
1198
+ this.layout = styles;
1199
+ if (direction === 'grid') {
1200
+ for (const item of this._items) {
1201
+ this.applyGridChildWidth(item);
1202
+ }
1203
+ }
1204
+ }
1205
+ applyGridChildWidth(child) {
1206
+ const effective = this.resolveConfig();
1207
+ const columns = effective.columns ?? this._layoutConfig.columns;
1208
+ const pct = `${(100 / columns).toFixed(2)}%`;
1209
+ if (child._layout) {
1210
+ child._layout.setStyle({ width: pct });
1211
+ }
1212
+ else {
1213
+ child.layout = { width: pct };
1214
+ }
1215
+ }
1216
+ applyAnchor() {
1217
+ const anchor = this.resolveConfig().anchor ?? this._anchor;
1218
+ if (this._viewportWidth === 0 || this._viewportHeight === 0)
1219
+ return;
1220
+ const bounds = this.getLocalBounds();
1221
+ const contentW = bounds.width * this.scale.x;
1222
+ const contentH = bounds.height * this.scale.y;
1223
+ const vw = this._viewportWidth;
1224
+ const vh = this._viewportHeight;
1225
+ let anchorX = 0;
1226
+ let anchorY = 0;
1227
+ if (anchor.includes('left')) {
1228
+ anchorX = 0;
1229
+ }
1230
+ else if (anchor.includes('right')) {
1231
+ anchorX = vw - contentW;
1232
+ }
1233
+ else {
1234
+ anchorX = (vw - contentW) / 2;
1235
+ }
1236
+ if (anchor.startsWith('top')) {
1237
+ anchorY = 0;
1238
+ }
1239
+ else if (anchor.startsWith('bottom')) {
1240
+ anchorY = vh - contentH;
1241
+ }
1242
+ else {
1243
+ anchorY = (vh - contentH) / 2;
1244
+ }
1245
+ this.x = anchorX - bounds.x * this.scale.x;
1246
+ this.y = anchorY - bounds.y * this.scale.y;
1247
+ }
1248
+ resolveConfig() {
1249
+ if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
1250
+ return {};
1251
+ }
1252
+ for (const [maxWidth, overrides] of this._breakpoints) {
1253
+ if (this._viewportWidth <= maxWidth) {
1254
+ return overrides;
1255
+ }
1256
+ }
1257
+ return {};
1258
+ }
1259
+ }
1260
+
1261
+ const DIRECTION_MAP = {
1262
+ vertical: 'vertical',
1263
+ horizontal: 'horizontal',
1264
+ both: 'bidirectional',
1265
+ };
1266
+ /**
1267
+ * Scrollable container powered by `@pixi/ui` ScrollBox.
1268
+ *
1269
+ * Provides touch/drag scrolling, mouse wheel support, inertia, and
1270
+ * dynamic rendering optimization for off-screen items.
1271
+ *
1272
+ * @example
1273
+ * ```ts
1274
+ * const scroll = new ScrollContainer({
1275
+ * width: 600,
1276
+ * height: 400,
1277
+ * direction: 'vertical',
1278
+ * elementsMargin: 8,
1279
+ * });
1280
+ *
1281
+ * for (let i = 0; i < 50; i++) {
1282
+ * scroll.addItem(createRow(i));
1283
+ * }
1284
+ *
1285
+ * scene.container.addChild(scroll);
1286
+ * ```
1287
+ */
1288
+ class ScrollContainer extends ScrollBox {
1289
+ _scrollConfig;
1290
+ constructor(config) {
1291
+ const options = {
1292
+ width: config.width,
1293
+ height: config.height,
1294
+ type: DIRECTION_MAP[config.direction ?? 'vertical'],
1295
+ radius: config.borderRadius ?? 0,
1296
+ elementsMargin: config.elementsMargin ?? 0,
1297
+ padding: config.padding ?? 0,
1298
+ disableDynamicRendering: config.disableDynamicRendering ?? false,
1299
+ disableEasing: config.disableEasing ?? false,
1300
+ globalScroll: config.globalScroll ?? true,
1301
+ };
1302
+ if (config.backgroundColor !== undefined) {
1303
+ options.background = config.backgroundColor;
1304
+ }
1305
+ super(options);
1306
+ this._scrollConfig = config;
1307
+ }
1308
+ /** Set scrollable content. Replaces any existing content. */
1309
+ setContent(content) {
1310
+ // Remove existing items
1311
+ const existing = this.items;
1312
+ if (existing.length > 0) {
1313
+ for (let i = existing.length - 1; i >= 0; i--) {
1314
+ this.removeItem(i);
1315
+ }
1316
+ }
1317
+ // Add all children from the content container
1318
+ const children = [...content.children];
1319
+ if (children.length > 0) {
1320
+ this.addItems(children);
1321
+ }
1322
+ }
1323
+ /** Add a single item */
1324
+ addItem(...items) {
1325
+ this.addItems(items);
1326
+ return items[0];
1327
+ }
1328
+ /** Scroll to make a specific item/child visible */
1329
+ scrollToItem(index) {
1330
+ this.scrollTo(index);
1331
+ }
1332
+ /** Current scroll position */
1333
+ get scrollPosition() {
1334
+ return { x: this.scrollX, y: this.scrollY };
1335
+ }
1336
+ }
1337
+
1338
+ export { BalanceDisplay, Button, Label, Layout, Modal, Panel, ProgressBar, ScrollContainer, Toast, WinDisplay };
1072
1339
  //# sourceMappingURL=ui.esm.js.map