@energy8platform/game-engine 0.3.0 → 0.5.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.
package/dist/ui.cjs.js CHANGED
@@ -1,436 +1,117 @@
1
1
  'use strict';
2
2
 
3
+ require('@pixi/layout');
3
4
  var pixi_js = require('pixi.js');
4
-
5
- /**
6
- * Collection of easing functions for use with Tween and Timeline.
7
- *
8
- * All functions take a progress value t (0..1) and return the eased value.
9
- */
10
- const Easing = {
11
- linear: (t) => t,
12
- easeInQuad: (t) => t * t,
13
- easeOutQuad: (t) => t * (2 - t),
14
- easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
15
- easeInCubic: (t) => t * t * t,
16
- easeOutCubic: (t) => --t * t * t + 1,
17
- easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
18
- easeInQuart: (t) => t * t * t * t,
19
- easeOutQuart: (t) => 1 - --t * t * t * t,
20
- easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
21
- easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
22
- easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
23
- easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
24
- easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
25
- easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
26
- easeInOutExpo: (t) => t === 0
27
- ? 0
28
- : t === 1
29
- ? 1
30
- : t < 0.5
31
- ? Math.pow(2, 20 * t - 10) / 2
32
- : (2 - Math.pow(2, -20 * t + 10)) / 2,
33
- easeInBack: (t) => {
34
- const c1 = 1.70158;
35
- const c3 = c1 + 1;
36
- return c3 * t * t * t - c1 * t * t;
37
- },
38
- easeOutBack: (t) => {
39
- const c1 = 1.70158;
40
- const c3 = c1 + 1;
41
- return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
42
- },
43
- easeInOutBack: (t) => {
44
- const c1 = 1.70158;
45
- const c2 = c1 * 1.525;
46
- return t < 0.5
47
- ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
48
- : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
49
- },
50
- easeOutBounce: (t) => {
51
- const n1 = 7.5625;
52
- const d1 = 2.75;
53
- if (t < 1 / d1)
54
- return n1 * t * t;
55
- if (t < 2 / d1)
56
- return n1 * (t -= 1.5 / d1) * t + 0.75;
57
- if (t < 2.5 / d1)
58
- return n1 * (t -= 2.25 / d1) * t + 0.9375;
59
- return n1 * (t -= 2.625 / d1) * t + 0.984375;
60
- },
61
- easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
62
- easeInOutBounce: (t) => t < 0.5
63
- ? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
64
- : (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
65
- easeOutElastic: (t) => {
66
- const c4 = (2 * Math.PI) / 3;
67
- return t === 0
68
- ? 0
69
- : t === 1
70
- ? 1
71
- : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
72
- },
73
- easeInElastic: (t) => {
74
- const c4 = (2 * Math.PI) / 3;
75
- return t === 0
76
- ? 0
77
- : t === 1
78
- ? 1
79
- : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
80
- },
81
- };
82
-
83
- /**
84
- * Lightweight tween system integrated with PixiJS Ticker.
85
- * Zero external dependencies — no GSAP required.
86
- *
87
- * All tweens return a Promise that resolves on completion.
88
- *
89
- * @example
90
- * ```ts
91
- * // Fade in a sprite
92
- * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
93
- *
94
- * // Move and wait
95
- * await Tween.to(sprite, { x: 500 }, 300);
96
- *
97
- * // From a starting value
98
- * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
99
- * ```
100
- */
101
- class Tween {
102
- static _tweens = [];
103
- static _tickerAdded = false;
104
- /**
105
- * Animate properties from current values to target values.
106
- *
107
- * @param target - Object to animate (Sprite, Container, etc.)
108
- * @param props - Target property values
109
- * @param duration - Duration in milliseconds
110
- * @param easing - Easing function (default: easeOutQuad)
111
- * @param onUpdate - Progress callback (0..1)
112
- */
113
- static to(target, props, duration, easing, onUpdate) {
114
- return new Promise((resolve) => {
115
- // Capture starting values
116
- const from = {};
117
- for (const key of Object.keys(props)) {
118
- from[key] = Tween.getProperty(target, key);
119
- }
120
- const tween = {
121
- target,
122
- from,
123
- to: { ...props },
124
- duration: Math.max(1, duration),
125
- easing: easing ?? Easing.easeOutQuad,
126
- elapsed: 0,
127
- delay: 0,
128
- resolve,
129
- onUpdate,
130
- };
131
- Tween._tweens.push(tween);
132
- Tween.ensureTicker();
133
- });
134
- }
135
- /**
136
- * Animate properties from given values to current values.
137
- */
138
- static from(target, props, duration, easing, onUpdate) {
139
- // Capture current values as "to"
140
- const to = {};
141
- for (const key of Object.keys(props)) {
142
- to[key] = Tween.getProperty(target, key);
143
- Tween.setProperty(target, key, props[key]);
144
- }
145
- return Tween.to(target, to, duration, easing, onUpdate);
146
- }
147
- /**
148
- * Animate from one set of values to another.
149
- */
150
- static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
151
- // Set starting values
152
- for (const key of Object.keys(fromProps)) {
153
- Tween.setProperty(target, key, fromProps[key]);
154
- }
155
- return Tween.to(target, toProps, duration, easing, onUpdate);
156
- }
157
- /**
158
- * Wait for a given duration (useful in timelines).
159
- * Uses PixiJS Ticker for consistent timing with other tweens.
160
- */
161
- static delay(ms) {
162
- return new Promise((resolve) => {
163
- let elapsed = 0;
164
- const onTick = (ticker) => {
165
- elapsed += ticker.deltaMS;
166
- if (elapsed >= ms) {
167
- pixi_js.Ticker.shared.remove(onTick);
168
- resolve();
169
- }
170
- };
171
- pixi_js.Ticker.shared.add(onTick);
172
- });
173
- }
174
- /**
175
- * Kill all tweens on a target.
176
- */
177
- static killTweensOf(target) {
178
- Tween._tweens = Tween._tweens.filter((tw) => {
179
- if (tw.target === target) {
180
- tw.resolve();
181
- return false;
182
- }
183
- return true;
184
- });
185
- }
186
- /**
187
- * Kill all active tweens.
188
- */
189
- static killAll() {
190
- for (const tw of Tween._tweens) {
191
- tw.resolve();
192
- }
193
- Tween._tweens.length = 0;
194
- }
195
- /** Number of active tweens */
196
- static get activeTweens() {
197
- return Tween._tweens.length;
198
- }
199
- /**
200
- * Reset the tween system — kill all tweens and remove the ticker.
201
- * Useful for cleanup between game instances, tests, or hot-reload.
202
- */
203
- static reset() {
204
- for (const tw of Tween._tweens) {
205
- tw.resolve();
206
- }
207
- Tween._tweens.length = 0;
208
- if (Tween._tickerAdded) {
209
- pixi_js.Ticker.shared.remove(Tween.tick);
210
- Tween._tickerAdded = false;
211
- }
212
- }
213
- // ─── Internal ──────────────────────────────────────────
214
- static ensureTicker() {
215
- if (Tween._tickerAdded)
216
- return;
217
- Tween._tickerAdded = true;
218
- pixi_js.Ticker.shared.add(Tween.tick);
219
- }
220
- static tick = (ticker) => {
221
- const dt = ticker.deltaMS;
222
- const completed = [];
223
- for (const tw of Tween._tweens) {
224
- tw.elapsed += dt;
225
- if (tw.elapsed < tw.delay)
226
- continue;
227
- const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
228
- const t = tw.easing(raw);
229
- // Interpolate each property
230
- for (const key of Object.keys(tw.to)) {
231
- const start = tw.from[key];
232
- const end = tw.to[key];
233
- const value = start + (end - start) * t;
234
- Tween.setProperty(tw.target, key, value);
235
- }
236
- tw.onUpdate?.(raw);
237
- if (raw >= 1) {
238
- completed.push(tw);
239
- }
240
- }
241
- // Remove completed tweens
242
- for (const tw of completed) {
243
- const idx = Tween._tweens.indexOf(tw);
244
- if (idx !== -1)
245
- Tween._tweens.splice(idx, 1);
246
- tw.resolve();
247
- }
248
- // Remove ticker when no active tweens
249
- if (Tween._tweens.length === 0 && Tween._tickerAdded) {
250
- pixi_js.Ticker.shared.remove(Tween.tick);
251
- Tween._tickerAdded = false;
252
- }
253
- };
254
- /**
255
- * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
256
- */
257
- static getProperty(target, key) {
258
- const parts = key.split('.');
259
- let obj = target;
260
- for (let i = 0; i < parts.length - 1; i++) {
261
- obj = obj[parts[i]];
262
- }
263
- return obj[parts[parts.length - 1]] ?? 0;
264
- }
265
- /**
266
- * Set a potentially nested property.
267
- */
268
- static setProperty(target, key, value) {
269
- const parts = key.split('.');
270
- let obj = target;
271
- for (let i = 0; i < parts.length - 1; i++) {
272
- obj = obj[parts[i]];
273
- }
274
- obj[parts[parts.length - 1]] = value;
275
- }
276
- }
5
+ var ui = require('@pixi/ui');
6
+ var components = require('@pixi/layout/components');
277
7
 
278
8
  const DEFAULT_COLORS = {
279
- normal: 0xffd700,
9
+ default: 0xffd700,
280
10
  hover: 0xffe44d,
281
11
  pressed: 0xccac00,
282
12
  disabled: 0x666666,
283
13
  };
14
+ function makeGraphicsView(w, h, radius, color) {
15
+ 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 });
19
+ return g;
20
+ }
284
21
  /**
285
- * Interactive button component with state management and animation.
22
+ * Interactive button component powered by `@pixi/ui` FancyButton.
286
23
  *
287
- * 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.
288
26
  *
289
27
  * @example
290
28
  * ```ts
291
29
  * const btn = new Button({
292
30
  * width: 200, height: 60, borderRadius: 12,
293
- * colors: { normal: 0x22aa22, hover: 0x33cc33 },
31
+ * colors: { default: 0x22aa22, hover: 0x33cc33 },
32
+ * text: 'SPIN',
294
33
  * });
295
34
  *
296
- * btn.onTap = () => console.log('Clicked!');
35
+ * btn.onPress.connect(() => console.log('Clicked!'));
297
36
  * scene.container.addChild(btn);
298
37
  * ```
299
38
  */
300
- class Button extends pixi_js.Container {
301
- _state = 'normal';
302
- _bg;
303
- _sprites = {};
304
- _config;
305
- /** Called when the button is tapped/clicked */
306
- onTap;
307
- /** Called when the button state changes */
308
- onStateChange;
39
+ class Button extends ui.FancyButton {
40
+ _buttonConfig;
309
41
  constructor(config = {}) {
310
- super();
311
- this._config = {
312
- width: 200,
313
- height: 60,
314
- borderRadius: 8,
315
- pressScale: 0.95,
316
- 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,
317
48
  ...config,
318
49
  };
319
- // Create Graphics background
320
- this._bg = new pixi_js.Graphics();
321
- this.addChild(this._bg);
322
- // 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
323
67
  if (config.textures) {
324
- for (const [state, tex] of Object.entries(config.textures)) {
325
- const texture = typeof tex === 'string' ? pixi_js.Texture.from(tex) : tex;
326
- const sprite = new pixi_js.Sprite(texture);
327
- sprite.anchor.set(0.5);
328
- sprite.visible = state === 'normal';
329
- this._sprites[state] = sprite;
330
- this.addChild(sprite);
331
- }
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;
332
76
  }
333
- // Make interactive
334
- this.eventMode = 'static';
335
- this.cursor = 'pointer';
336
- // Set up hit area for Graphics-based
337
- this.pivot.set(this._config.width / 2, this._config.height / 2);
338
- // Bind events
339
- this.on('pointerover', this.onPointerOver);
340
- this.on('pointerout', this.onPointerOut);
341
- this.on('pointerdown', this.onPointerDown);
342
- this.on('pointerup', this.onPointerUp);
343
- this.on('pointertap', this.onPointerTap);
344
- // Initial render
345
- this.setState('normal');
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;
87
+ }
88
+ super(options);
89
+ this._buttonConfig = resolvedConfig;
346
90
  if (config.disabled) {
347
- this.disable();
91
+ this.enabled = false;
348
92
  }
349
93
  }
350
- /** Current button state */
351
- get state() {
352
- return this._state;
353
- }
354
94
  /** Enable the button */
355
95
  enable() {
356
- if (this._state === 'disabled') {
357
- this.setState('normal');
358
- this.eventMode = 'static';
359
- this.cursor = 'pointer';
360
- }
96
+ this.enabled = true;
361
97
  }
362
98
  /** Disable the button */
363
99
  disable() {
364
- this.setState('disabled');
365
- this.eventMode = 'none';
366
- this.cursor = 'default';
100
+ this.enabled = false;
367
101
  }
368
102
  /** Whether the button is disabled */
369
103
  get disabled() {
370
- return this._state === 'disabled';
104
+ return !this.enabled;
371
105
  }
372
- setState(state) {
373
- if (this._state === state)
374
- return;
375
- this._state = state;
376
- this.render();
377
- this.onStateChange?.(state);
378
- }
379
- render() {
380
- const { width, height, borderRadius, colors } = this._config;
381
- const colorMap = { ...DEFAULT_COLORS, ...colors };
382
- // Update Graphics
383
- this._bg.clear();
384
- this._bg.roundRect(0, 0, width, height, borderRadius).fill(colorMap[this._state]);
385
- // Add highlight for normal/hover
386
- if (this._state === 'normal' || this._state === 'hover') {
387
- this._bg
388
- .roundRect(2, 2, width - 4, height * 0.45, borderRadius)
389
- .fill({ color: 0xffffff, alpha: 0.1 });
390
- }
391
- // Update sprite visibility
392
- for (const [state, sprite] of Object.entries(this._sprites)) {
393
- if (sprite)
394
- sprite.visible = state === this._state;
395
- }
396
- // Fall back to normal sprite if state sprite doesn't exist
397
- if (!this._sprites[this._state] && this._sprites.normal) {
398
- this._sprites.normal.visible = true;
399
- }
400
- }
401
- onPointerOver = () => {
402
- if (this._state === 'disabled')
403
- return;
404
- this.setState('hover');
405
- };
406
- onPointerOut = () => {
407
- if (this._state === 'disabled')
408
- return;
409
- this.setState('normal');
410
- Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration);
411
- };
412
- onPointerDown = () => {
413
- if (this._state === 'disabled')
414
- return;
415
- this.setState('pressed');
416
- const s = this._config.pressScale;
417
- Tween.to(this.scale, { x: s, y: s }, this._config.animationDuration, Easing.easeOutQuad);
418
- };
419
- onPointerUp = () => {
420
- if (this._state === 'disabled')
421
- return;
422
- this.setState('hover');
423
- Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration, Easing.easeOutBack);
424
- };
425
- onPointerTap = () => {
426
- if (this._state === 'disabled')
427
- return;
428
- this.onTap?.();
429
- };
430
106
  }
431
107
 
108
+ function makeBarGraphics(w, h, radius, color) {
109
+ return new pixi_js.Graphics().roundRect(0, 0, w, h, radius).fill(color);
110
+ }
432
111
  /**
433
- * 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()`.
434
115
  *
435
116
  * @example
436
117
  * ```ts
@@ -440,33 +121,48 @@ class Button extends pixi_js.Container {
440
121
  * ```
441
122
  */
442
123
  class ProgressBar extends pixi_js.Container {
443
- _track;
444
- _fill;
445
- _border;
124
+ _bar;
125
+ _borderGfx;
446
126
  _config;
447
127
  _progress = 0;
448
128
  _displayedProgress = 0;
449
129
  constructor(config = {}) {
450
130
  super();
451
131
  this._config = {
452
- width: 300,
453
- height: 16,
454
- borderRadius: 8,
455
- fillColor: 0xffd700,
456
- trackColor: 0x333333,
457
- borderColor: 0x555555,
458
- borderWidth: 1,
459
- animated: true,
460
- animationSpeed: 0.1,
461
- ...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,
462
141
  };
463
- this._track = new pixi_js.Graphics();
464
- this._fill = new pixi_js.Graphics();
465
- this._border = new pixi_js.Graphics();
466
- this.addChild(this._track, this._fill, this._border);
467
- this.drawTrack();
468
- this.drawBorder();
469
- this.drawFill(0);
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,
155
+ };
156
+ this._bar = new ui.ProgressBar(options);
157
+ this.addChild(this._bar);
158
+ // Border overlay
159
+ this._borderGfx = new pixi_js.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);
470
166
  }
471
167
  /** Get/set progress (0..1) */
472
168
  get progress() {
@@ -476,13 +172,13 @@ class ProgressBar extends pixi_js.Container {
476
172
  this._progress = Math.max(0, Math.min(1, value));
477
173
  if (!this._config.animated) {
478
174
  this._displayedProgress = this._progress;
479
- this.drawFill(this._displayedProgress);
175
+ this._bar.progress = this._displayedProgress * 100;
480
176
  }
481
177
  }
482
178
  /**
483
179
  * Call each frame if animated is true.
484
180
  */
485
- update(dt) {
181
+ update(_dt) {
486
182
  if (!this._config.animated)
487
183
  return;
488
184
  if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
@@ -491,35 +187,7 @@ class ProgressBar extends pixi_js.Container {
491
187
  }
492
188
  this._displayedProgress +=
493
189
  (this._progress - this._displayedProgress) * this._config.animationSpeed;
494
- this.drawFill(this._displayedProgress);
495
- }
496
- drawTrack() {
497
- const { width, height, borderRadius, trackColor } = this._config;
498
- this._track.clear();
499
- this._track.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
500
- }
501
- drawBorder() {
502
- const { width, height, borderRadius, borderColor, borderWidth } = this._config;
503
- this._border.clear();
504
- this._border
505
- .roundRect(0, 0, width, height, borderRadius)
506
- .stroke({ color: borderColor, width: borderWidth });
507
- }
508
- drawFill(progress) {
509
- const { width, height, borderRadius, fillColor, borderWidth } = this._config;
510
- const innerWidth = width - borderWidth * 2;
511
- const innerHeight = height - borderWidth * 2;
512
- const fillWidth = Math.max(0, innerWidth * progress);
513
- this._fill.clear();
514
- if (fillWidth > 0) {
515
- this._fill.x = borderWidth;
516
- this._fill.y = borderWidth;
517
- this._fill.roundRect(0, 0, fillWidth, innerHeight, borderRadius - 1).fill(fillColor);
518
- // Highlight
519
- this._fill
520
- .roundRect(0, 0, fillWidth, innerHeight * 0.4, borderRadius - 1)
521
- .fill({ color: 0xffffff, alpha: 0.15 });
522
- }
190
+ this._bar.progress = this._displayedProgress * 100;
523
191
  }
524
192
  }
525
193
 
@@ -615,7 +283,10 @@ class Label extends pixi_js.Container {
615
283
  }
616
284
 
617
285
  /**
618
- * 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.
619
290
  *
620
291
  * @example
621
292
  * ```ts
@@ -630,75 +301,148 @@ class Label extends pixi_js.Container {
630
301
  * });
631
302
  * ```
632
303
  */
633
- class Panel extends pixi_js.Container {
634
- _bg;
635
- _content;
636
- _config;
304
+ class Panel extends components.LayoutContainer {
305
+ _panelConfig;
637
306
  constructor(config = {}) {
638
- super();
639
- this._config = {
640
- width: 400,
641
- height: 300,
642
- padding: 16,
643
- 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,
644
312
  ...config,
645
313
  };
646
- // Create background
314
+ // If using a 9-slice texture, pass it as a custom background
315
+ let customBackground;
647
316
  if (config.nineSliceTexture) {
648
317
  const texture = typeof config.nineSliceTexture === 'string'
649
318
  ? pixi_js.Texture.from(config.nineSliceTexture)
650
319
  : config.nineSliceTexture;
651
320
  const [left, top, right, bottom] = config.nineSliceBorders ?? [10, 10, 10, 10];
652
- this._bg = new pixi_js.NineSliceSprite({
321
+ const nineSlice = new pixi_js.NineSliceSprite({
653
322
  texture,
654
323
  leftWidth: left,
655
324
  topHeight: top,
656
325
  rightWidth: right,
657
326
  bottomHeight: bottom,
658
327
  });
659
- this._bg.width = this._config.width;
660
- this._bg.height = this._config.height;
328
+ nineSlice.width = resolvedConfig.width;
329
+ nineSlice.height = resolvedConfig.height;
330
+ nineSlice.alpha = resolvedConfig.backgroundAlpha;
331
+ customBackground = nineSlice;
332
+ }
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
+ }
661
350
  }
662
- else {
663
- this._bg = new pixi_js.Graphics();
664
- this.drawGraphicsBg();
351
+ this.layout = layoutStyles;
352
+ if (!config.nineSliceTexture) {
353
+ this.background.alpha = resolvedConfig.backgroundAlpha;
665
354
  }
666
- this._bg.alpha = this._config.backgroundAlpha;
667
- this.addChild(this._bg);
668
- // Content container with padding
669
- this._content = new pixi_js.Container();
670
- this._content.x = this._config.padding;
671
- this._content.y = this._config.padding;
672
- this.addChild(this._content);
673
355
  }
674
- /** Content container add children here */
356
+ /** Access the content container (children added here participate in layout) */
675
357
  get content() {
676
- return this._content;
358
+ return this.overflowContainer;
677
359
  }
678
360
  /** Resize the panel */
679
361
  setSize(width, height) {
680
- this._config.width = width;
681
- this._config.height = height;
682
- if (this._bg instanceof pixi_js.Graphics) {
683
- this.drawGraphicsBg();
684
- }
685
- else {
686
- this._bg.width = width;
687
- this._bg.height = height;
688
- }
689
- }
690
- drawGraphicsBg() {
691
- const bg = this._bg;
692
- const { width, height, backgroundColor, borderRadius, borderColor, borderWidth, } = this._config;
693
- bg.clear();
694
- bg.roundRect(0, 0, width, height, borderRadius ?? 0).fill(backgroundColor ?? 0x1a1a2e);
695
- if (borderColor !== undefined && borderWidth) {
696
- bg.roundRect(0, 0, width, height, borderRadius ?? 0)
697
- .stroke({ color: borderColor, width: borderWidth });
698
- }
362
+ this._panelConfig.width = width;
363
+ this._panelConfig.height = height;
364
+ this._layout?.setStyle({ width, height });
699
365
  }
700
366
  }
701
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
+
702
446
  /**
703
447
  * Reactive balance display component.
704
448
  *
@@ -783,7 +527,6 @@ class BalanceDisplay extends pixi_js.Container {
783
527
  this.updateDisplay();
784
528
  }
785
529
  async animateValue(from, to) {
786
- // Cancel any ongoing animation
787
530
  if (this._animating) {
788
531
  this._animationCancelled = true;
789
532
  }
@@ -793,7 +536,6 @@ class BalanceDisplay extends pixi_js.Container {
793
536
  const startTime = Date.now();
794
537
  return new Promise((resolve) => {
795
538
  const tick = () => {
796
- // If cancelled by a newer animation, stop immediately
797
539
  if (this._animationCancelled) {
798
540
  this._animating = false;
799
541
  resolve();
@@ -868,66 +610,261 @@ class WinDisplay extends pixi_js.Container {
868
610
  this.visible = false;
869
611
  }
870
612
  /**
871
- * Show a win with countup animation.
872
- *
873
- * @param amount - Win amount
874
- * @returns Promise that resolves when the animation completes
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.
875
753
  */
876
- async showWin(amount) {
877
- this.visible = true;
878
- this._cancelCountup = false;
879
- this.alpha = 1;
880
- const duration = this._config.countupDuration;
881
- const startTime = Date.now();
882
- // Scale pop
883
- this.scale.set(0.5);
754
+ static delay(ms) {
884
755
  return new Promise((resolve) => {
885
- const tick = () => {
886
- if (this._cancelCountup) {
887
- this.displayAmount(amount);
888
- resolve();
889
- return;
890
- }
891
- const elapsed = Date.now() - startTime;
892
- const t = Math.min(elapsed / duration, 1);
893
- const eased = Easing.easeOutCubic(t);
894
- // Countup
895
- const current = amount * eased;
896
- this.displayAmount(current);
897
- // Scale animation
898
- const scaleT = Math.min(elapsed / 300, 1);
899
- const scaleEased = Easing.easeOutBack(scaleT);
900
- const targetScale = 1;
901
- this.scale.set(0.5 + (targetScale - 0.5) * scaleEased);
902
- if (t < 1) {
903
- requestAnimationFrame(tick);
904
- }
905
- else {
906
- this.displayAmount(amount);
907
- this.scale.set(1);
756
+ let elapsed = 0;
757
+ const onTick = (ticker) => {
758
+ elapsed += ticker.deltaMS;
759
+ if (elapsed >= ms) {
760
+ pixi_js.Ticker.shared.remove(onTick);
908
761
  resolve();
909
762
  }
910
763
  };
911
- requestAnimationFrame(tick);
764
+ pixi_js.Ticker.shared.add(onTick);
912
765
  });
913
766
  }
914
767
  /**
915
- * Skip the countup animation and show the final amount immediately.
768
+ * Kill all tweens on a target.
916
769
  */
917
- skipCountup(amount) {
918
- this._cancelCountup = true;
919
- this.displayAmount(amount);
920
- 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
+ });
921
778
  }
922
779
  /**
923
- * Hide the win display.
780
+ * Kill all active tweens.
924
781
  */
925
- hide() {
926
- this.visible = false;
927
- this._label.text = '';
782
+ static killAll() {
783
+ for (const tw of Tween._tweens) {
784
+ tw.resolve();
785
+ }
786
+ Tween._tweens.length = 0;
928
787
  }
929
- displayAmount(amount) {
930
- 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
+ pixi_js.Ticker.shared.remove(Tween.tick);
803
+ Tween._tickerAdded = false;
804
+ }
805
+ }
806
+ // ─── Internal ──────────────────────────────────────────
807
+ static ensureTicker() {
808
+ if (Tween._tickerAdded)
809
+ return;
810
+ Tween._tickerAdded = true;
811
+ pixi_js.Ticker.shared.add(Tween.tick);
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
+ 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;
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;
931
868
  }
932
869
  }
933
870
 
@@ -935,6 +872,8 @@ class WinDisplay extends pixi_js.Container {
935
872
  * Modal overlay component.
936
873
  * Shows content on top of a dark overlay with enter/exit animations.
937
874
  *
875
+ * The content container uses `@pixi/layout` for automatic centering.
876
+ *
938
877
  * @example
939
878
  * ```ts
940
879
  * const modal = new Modal({ closeOnOverlay: true });
@@ -953,11 +892,10 @@ class Modal extends pixi_js.Container {
953
892
  constructor(config = {}) {
954
893
  super();
955
894
  this._config = {
956
- overlayColor: 0x000000,
957
- overlayAlpha: 0.7,
958
- closeOnOverlay: true,
959
- animationDuration: 300,
960
- ...config,
895
+ overlayColor: config.overlayColor ?? 0x000000,
896
+ overlayAlpha: config.overlayAlpha ?? 0.7,
897
+ closeOnOverlay: config.closeOnOverlay ?? true,
898
+ animationDuration: config.animationDuration ?? 300,
961
899
  };
962
900
  // Overlay
963
901
  this._overlay = new pixi_js.Graphics();
@@ -1040,9 +978,8 @@ class Toast extends pixi_js.Container {
1040
978
  constructor(config = {}) {
1041
979
  super();
1042
980
  this._config = {
1043
- duration: 3000,
1044
- bottomOffset: 60,
1045
- ...config,
981
+ duration: config.duration ?? 3000,
982
+ bottomOffset: config.bottomOffset ?? 60,
1046
983
  };
1047
984
  this._bg = new pixi_js.Graphics();
1048
985
  this.addChild(this._bg);
@@ -1062,7 +999,6 @@ class Toast extends pixi_js.Container {
1062
999
  * Show a toast message.
1063
1000
  */
1064
1001
  async show(message, type = 'info', viewWidth, viewHeight) {
1065
- // Clear previous dismiss
1066
1002
  if (this._dismissTimeout) {
1067
1003
  clearTimeout(this._dismissTimeout);
1068
1004
  }
@@ -1071,10 +1007,10 @@ class Toast extends pixi_js.Container {
1071
1007
  const width = Math.max(200, this._text.width + padding * 2);
1072
1008
  const height = 44;
1073
1009
  const radius = 8;
1010
+ // Draw the background
1074
1011
  this._bg.clear();
1075
- this._bg.roundRect(-width / 2, -height / 2, width, height, radius).fill(TOAST_COLORS[type]);
1076
- this._bg.roundRect(-width / 2, -height / 2, width, height, radius)
1077
- .fill({ color: 0x000000, alpha: 0.2 });
1012
+ this._bg.roundRect(-width / 2, -height / 2, width, height, radius);
1013
+ this._bg.fill(TOAST_COLORS[type]);
1078
1014
  // Position
1079
1015
  if (viewWidth && viewHeight) {
1080
1016
  this.x = viewWidth / 2;
@@ -1083,9 +1019,7 @@ class Toast extends pixi_js.Container {
1083
1019
  this.visible = true;
1084
1020
  this.alpha = 0;
1085
1021
  this.y += 20;
1086
- // Animate in
1087
1022
  await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
1088
- // Auto-dismiss
1089
1023
  if (this._config.duration > 0) {
1090
1024
  this._dismissTimeout = setTimeout(() => {
1091
1025
  this.dismiss();
@@ -1107,8 +1041,48 @@ class Toast extends pixi_js.Container {
1107
1041
  }
1108
1042
  }
1109
1043
 
1044
+ // ─── 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) {
1057
+ 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
+ };
1070
+ }
1071
+ }
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
+ }
1110
1084
  /**
1111
- * Responsive layout container that automatically arranges its children.
1085
+ * Responsive layout container powered by `@pixi/layout` (Yoga flexbox engine).
1112
1086
  *
1113
1087
  * Supports horizontal, vertical, grid, and wrap layout modes with
1114
1088
  * alignment, padding, gap, and viewport-anchor positioning.
@@ -1131,47 +1105,44 @@ class Toast extends pixi_js.Container {
1131
1105
  * toolbar.addItem(betLabel);
1132
1106
  * scene.container.addChild(toolbar);
1133
1107
  *
1134
- * // On resize, update layout position relative to viewport
1135
1108
  * toolbar.updateViewport(width, height);
1136
1109
  * ```
1137
1110
  */
1138
1111
  class Layout extends pixi_js.Container {
1139
- _config;
1112
+ _layoutConfig;
1140
1113
  _padding;
1141
1114
  _anchor;
1142
1115
  _maxWidth;
1143
1116
  _breakpoints;
1144
- _content;
1145
1117
  _items = [];
1146
1118
  _viewportWidth = 0;
1147
1119
  _viewportHeight = 0;
1148
1120
  constructor(config = {}) {
1149
1121
  super();
1150
- this._config = {
1122
+ this._layoutConfig = {
1151
1123
  direction: config.direction ?? 'vertical',
1152
1124
  gap: config.gap ?? 0,
1153
1125
  alignment: config.alignment ?? 'start',
1154
1126
  autoLayout: config.autoLayout ?? true,
1155
1127
  columns: config.columns ?? 2,
1156
1128
  };
1157
- this._padding = Layout.normalizePadding(config.padding ?? 0);
1129
+ this._padding = normalizePadding(config.padding ?? 0);
1158
1130
  this._anchor = config.anchor ?? 'top-left';
1159
1131
  this._maxWidth = config.maxWidth ?? Infinity;
1160
- // Sort breakpoints by width ascending for correct resolution
1161
1132
  this._breakpoints = config.breakpoints
1162
1133
  ? Object.entries(config.breakpoints)
1163
1134
  .map(([w, cfg]) => [Number(w), cfg])
1164
1135
  .sort((a, b) => a[0] - b[0])
1165
1136
  : [];
1166
- this._content = new pixi_js.Container();
1167
- this.addChild(this._content);
1137
+ this.applyLayoutStyles();
1168
1138
  }
1169
1139
  /** Add an item to the layout */
1170
1140
  addItem(child) {
1171
1141
  this._items.push(child);
1172
- this._content.addChild(child);
1173
- if (this._config.autoLayout)
1174
- this.layout();
1142
+ this.addChild(child);
1143
+ if (this._layoutConfig.direction === 'grid') {
1144
+ this.applyGridChildWidth(child);
1145
+ }
1175
1146
  return this;
1176
1147
  }
1177
1148
  /** Remove an item from the layout */
@@ -1179,20 +1150,16 @@ class Layout extends pixi_js.Container {
1179
1150
  const idx = this._items.indexOf(child);
1180
1151
  if (idx !== -1) {
1181
1152
  this._items.splice(idx, 1);
1182
- this._content.removeChild(child);
1183
- if (this._config.autoLayout)
1184
- this.layout();
1153
+ this.removeChild(child);
1185
1154
  }
1186
1155
  return this;
1187
1156
  }
1188
1157
  /** Remove all items */
1189
1158
  clearItems() {
1190
1159
  for (const item of this._items) {
1191
- this._content.removeChild(item);
1160
+ this.removeChild(item);
1192
1161
  }
1193
1162
  this._items.length = 0;
1194
- if (this._config.autoLayout)
1195
- this.layout();
1196
1163
  return this;
1197
1164
  }
1198
1165
  /** Get all layout items */
@@ -1206,129 +1173,55 @@ class Layout extends pixi_js.Container {
1206
1173
  updateViewport(width, height) {
1207
1174
  this._viewportWidth = width;
1208
1175
  this._viewportHeight = height;
1209
- this.layout();
1176
+ this.applyLayoutStyles();
1177
+ this.applyAnchor();
1210
1178
  }
1211
- /**
1212
- * Recalculate layout positions of all children.
1213
- */
1214
- layout() {
1215
- if (this._items.length === 0)
1216
- return;
1217
- // Resolve effective config (apply breakpoint overrides)
1179
+ applyLayoutStyles() {
1218
1180
  const effective = this.resolveConfig();
1219
- const gap = effective.gap ?? this._config.gap;
1220
- const direction = effective.direction ?? this._config.direction;
1221
- const alignment = effective.alignment ?? this._config.alignment;
1222
- const columns = effective.columns ?? this._config.columns;
1181
+ const direction = effective.direction ?? this._layoutConfig.direction;
1182
+ const gap = effective.gap ?? this._layoutConfig.gap;
1183
+ const alignment = effective.alignment ?? this._layoutConfig.alignment;
1184
+ effective.columns ?? this._layoutConfig.columns;
1223
1185
  const padding = effective.padding !== undefined
1224
- ? Layout.normalizePadding(effective.padding)
1186
+ ? normalizePadding(effective.padding)
1225
1187
  : this._padding;
1226
1188
  const maxWidth = effective.maxWidth ?? this._maxWidth;
1227
- const [pt, pr, pb, pl] = padding;
1228
- switch (direction) {
1229
- case 'horizontal':
1230
- this.layoutLinear('x', 'y', gap, alignment, pl, pt);
1231
- break;
1232
- case 'vertical':
1233
- this.layoutLinear('y', 'x', gap, alignment, pt, pl);
1234
- break;
1235
- case 'grid':
1236
- this.layoutGrid(columns, gap, alignment, pl, pt);
1237
- break;
1238
- case 'wrap':
1239
- this.layoutWrap(maxWidth - pl - pr, gap, alignment, pl, pt);
1240
- break;
1241
- }
1242
- // Apply anchor positioning relative to viewport
1243
- this.applyAnchor(effective.anchor ?? this._anchor);
1244
- }
1245
- // ─── Private layout helpers ────────────────────────────
1246
- layoutLinear(mainAxis, crossAxis, gap, alignment, mainOffset, crossOffset) {
1247
- let pos = mainOffset;
1248
- const sizes = this._items.map(item => this.getItemSize(item));
1249
- const maxCross = Math.max(...sizes.map(s => (crossAxis === 'x' ? s.width : s.height)));
1250
- for (let i = 0; i < this._items.length; i++) {
1251
- const item = this._items[i];
1252
- const size = sizes[i];
1253
- item[mainAxis] = pos;
1254
- // Cross-axis alignment
1255
- const itemCross = crossAxis === 'x' ? size.width : size.height;
1256
- switch (alignment) {
1257
- case 'start':
1258
- item[crossAxis] = crossOffset;
1259
- break;
1260
- case 'center':
1261
- item[crossAxis] = crossOffset + (maxCross - itemCross) / 2;
1262
- break;
1263
- case 'end':
1264
- item[crossAxis] = crossOffset + maxCross - itemCross;
1265
- break;
1266
- case 'stretch':
1267
- item[crossAxis] = crossOffset;
1268
- // Note: stretch doesn't resize children — that's up to the item
1269
- break;
1189
+ const styles = buildLayoutStyles({ direction, gap, alignment, padding, maxWidth });
1190
+ this.layout = styles;
1191
+ if (direction === 'grid') {
1192
+ for (const item of this._items) {
1193
+ this.applyGridChildWidth(item);
1270
1194
  }
1271
- const mainSize = mainAxis === 'x' ? size.width : size.height;
1272
- pos += mainSize + gap;
1273
1195
  }
1274
1196
  }
1275
- layoutGrid(columns, gap, alignment, offsetX, offsetY) {
1276
- const sizes = this._items.map(item => this.getItemSize(item));
1277
- const maxItemWidth = Math.max(...sizes.map(s => s.width));
1278
- const maxItemHeight = Math.max(...sizes.map(s => s.height));
1279
- const cellW = maxItemWidth + gap;
1280
- const cellH = maxItemHeight + gap;
1281
- for (let i = 0; i < this._items.length; i++) {
1282
- const item = this._items[i];
1283
- const col = i % columns;
1284
- const row = Math.floor(i / columns);
1285
- const size = sizes[i];
1286
- // X alignment within cell
1287
- switch (alignment) {
1288
- case 'center':
1289
- item.x = offsetX + col * cellW + (maxItemWidth - size.width) / 2;
1290
- break;
1291
- case 'end':
1292
- item.x = offsetX + col * cellW + maxItemWidth - size.width;
1293
- break;
1294
- default:
1295
- item.x = offsetX + col * cellW;
1296
- }
1297
- item.y = offsetY + row * cellH;
1197
+ applyGridChildWidth(child) {
1198
+ const effective = this.resolveConfig();
1199
+ 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);
1298
1209
  }
1299
- }
1300
- layoutWrap(maxWidth, gap, alignment, offsetX, offsetY) {
1301
- let x = offsetX;
1302
- let y = offsetY;
1303
- let rowHeight = 0;
1304
- const sizes = this._items.map(item => this.getItemSize(item));
1305
- for (let i = 0; i < this._items.length; i++) {
1306
- const item = this._items[i];
1307
- const size = sizes[i];
1308
- // Check if item fits in current row
1309
- if (x + size.width > maxWidth + offsetX && x > offsetX) {
1310
- // Wrap to next row
1311
- x = offsetX;
1312
- y += rowHeight + gap;
1313
- rowHeight = 0;
1314
- }
1315
- item.x = x;
1316
- item.y = y;
1317
- x += size.width + gap;
1318
- rowHeight = Math.max(rowHeight, size.height);
1210
+ else {
1211
+ child.layout = styles;
1319
1212
  }
1320
1213
  }
1321
- applyAnchor(anchor) {
1214
+ applyAnchor() {
1215
+ const anchor = this.resolveConfig().anchor ?? this._anchor;
1322
1216
  if (this._viewportWidth === 0 || this._viewportHeight === 0)
1323
1217
  return;
1324
- const bounds = this._content.getBounds();
1325
- const contentW = bounds.width;
1326
- const contentH = bounds.height;
1218
+ const bounds = this.getLocalBounds();
1219
+ const contentW = bounds.width * this.scale.x;
1220
+ const contentH = bounds.height * this.scale.y;
1327
1221
  const vw = this._viewportWidth;
1328
1222
  const vh = this._viewportHeight;
1329
1223
  let anchorX = 0;
1330
1224
  let anchorY = 0;
1331
- // Horizontal
1332
1225
  if (anchor.includes('left')) {
1333
1226
  anchorX = 0;
1334
1227
  }
@@ -1336,10 +1229,8 @@ class Layout extends pixi_js.Container {
1336
1229
  anchorX = vw - contentW;
1337
1230
  }
1338
1231
  else {
1339
- // center
1340
1232
  anchorX = (vw - contentW) / 2;
1341
1233
  }
1342
- // Vertical
1343
1234
  if (anchor.startsWith('top')) {
1344
1235
  anchorY = 0;
1345
1236
  }
@@ -1347,44 +1238,34 @@ class Layout extends pixi_js.Container {
1347
1238
  anchorY = vh - contentH;
1348
1239
  }
1349
1240
  else {
1350
- // center
1351
1241
  anchorY = (vh - contentH) / 2;
1352
1242
  }
1353
- // Compensate for content's local bounds offset
1354
- this.x = anchorX - bounds.x;
1355
- this.y = anchorY - bounds.y;
1243
+ this.x = anchorX - bounds.x * this.scale.x;
1244
+ this.y = anchorY - bounds.y * this.scale.y;
1356
1245
  }
1357
1246
  resolveConfig() {
1358
1247
  if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
1359
1248
  return {};
1360
1249
  }
1361
- // Find the largest breakpoint that's ≤ current viewport width
1362
- let resolved = {};
1363
1250
  for (const [maxWidth, overrides] of this._breakpoints) {
1364
1251
  if (this._viewportWidth <= maxWidth) {
1365
- resolved = overrides;
1366
- break;
1252
+ return overrides;
1367
1253
  }
1368
1254
  }
1369
- return resolved;
1370
- }
1371
- getItemSize(item) {
1372
- const bounds = item.getBounds();
1373
- return { width: bounds.width, height: bounds.height };
1374
- }
1375
- static normalizePadding(padding) {
1376
- if (typeof padding === 'number') {
1377
- return [padding, padding, padding, padding];
1378
- }
1379
- return padding;
1255
+ return {};
1380
1256
  }
1381
1257
  }
1382
1258
 
1259
+ const DIRECTION_MAP = {
1260
+ vertical: 'vertical',
1261
+ horizontal: 'horizontal',
1262
+ both: 'bidirectional',
1263
+ };
1383
1264
  /**
1384
- * Scrollable container with touch/drag, mouse wheel, inertia, and optional scrollbar.
1265
+ * Scrollable container powered by `@pixi/ui` ScrollBox.
1385
1266
  *
1386
- * Perfect for paytables, settings panels, bet history, and any scrollable content
1387
- * that doesn't fit on screen.
1267
+ * Provides touch/drag scrolling, mouse wheel support, inertia, and
1268
+ * dynamic rendering optimization for off-screen items.
1388
1269
  *
1389
1270
  * @example
1390
1271
  * ```ts
@@ -1392,435 +1273,63 @@ class Layout extends pixi_js.Container {
1392
1273
  * width: 600,
1393
1274
  * height: 400,
1394
1275
  * direction: 'vertical',
1395
- * showScrollbar: true,
1396
- * elasticity: 0.3,
1276
+ * elementsMargin: 8,
1397
1277
  * });
1398
1278
  *
1399
- * // Add content taller than 400px
1400
- * const list = new Container();
1401
1279
  * for (let i = 0; i < 50; i++) {
1402
- * const row = createRow(i);
1403
- * row.y = i * 40;
1404
- * list.addChild(row);
1280
+ * scroll.addItem(createRow(i));
1405
1281
  * }
1406
- * scroll.setContent(list);
1407
1282
  *
1408
1283
  * scene.container.addChild(scroll);
1409
1284
  * ```
1410
1285
  */
1411
- class ScrollContainer extends pixi_js.Container {
1412
- _config;
1413
- _viewport;
1414
- _content = null;
1415
- _mask;
1416
- _bg;
1417
- _scrollbarV = null;
1418
- _scrollbarH = null;
1419
- _scrollbarFadeTimeout = null;
1420
- // Scroll state
1421
- _scrollX = 0;
1422
- _scrollY = 0;
1423
- _velocityX = 0;
1424
- _velocityY = 0;
1425
- _isDragging = false;
1426
- _dragStart = { x: 0, y: 0 };
1427
- _scrollStart = { x: 0, y: 0 };
1428
- _lastDragPos = { x: 0, y: 0 };
1429
- _lastDragTime = 0;
1430
- _isAnimating = false;
1431
- _animationFrame = null;
1286
+ class ScrollContainer extends ui.ScrollBox {
1287
+ _scrollConfig;
1432
1288
  constructor(config) {
1433
- super();
1434
- this._config = {
1289
+ const options = {
1435
1290
  width: config.width,
1436
1291
  height: config.height,
1437
- direction: config.direction ?? 'vertical',
1438
- showScrollbar: config.showScrollbar ?? true,
1439
- scrollbarWidth: config.scrollbarWidth ?? 6,
1440
- scrollbarColor: config.scrollbarColor ?? 0xffffff,
1441
- scrollbarAlpha: config.scrollbarAlpha ?? 0.4,
1442
- elasticity: config.elasticity ?? 0.3,
1443
- inertia: config.inertia ?? 0.92,
1444
- snapSize: config.snapSize ?? 0,
1445
- borderRadius: config.borderRadius ?? 0,
1292
+ type: DIRECTION_MAP[config.direction ?? 'vertical'],
1293
+ radius: config.borderRadius ?? 0,
1294
+ elementsMargin: config.elementsMargin ?? 0,
1295
+ padding: config.padding ?? 0,
1296
+ disableDynamicRendering: config.disableDynamicRendering ?? false,
1297
+ disableEasing: config.disableEasing ?? false,
1298
+ globalScroll: config.globalScroll ?? true,
1446
1299
  };
1447
- // Background
1448
- this._bg = new pixi_js.Graphics();
1449
1300
  if (config.backgroundColor !== undefined) {
1450
- this._bg.roundRect(0, 0, config.width, config.height, this._config.borderRadius)
1451
- .fill({ color: config.backgroundColor, alpha: config.backgroundAlpha ?? 1 });
1301
+ options.background = config.backgroundColor;
1452
1302
  }
1453
- this.addChild(this._bg);
1454
- // Viewport (masked area)
1455
- this._viewport = new pixi_js.Container();
1456
- this.addChild(this._viewport);
1457
- // Mask
1458
- this._mask = new pixi_js.Graphics();
1459
- this._mask.roundRect(0, 0, config.width, config.height, this._config.borderRadius)
1460
- .fill(0xffffff);
1461
- this.addChild(this._mask);
1462
- this._viewport.mask = this._mask;
1463
- // Scrollbars
1464
- if (this._config.showScrollbar) {
1465
- if (this._config.direction !== 'horizontal') {
1466
- this._scrollbarV = new pixi_js.Graphics();
1467
- this._scrollbarV.alpha = 0;
1468
- this.addChild(this._scrollbarV);
1469
- }
1470
- if (this._config.direction !== 'vertical') {
1471
- this._scrollbarH = new pixi_js.Graphics();
1472
- this._scrollbarH.alpha = 0;
1473
- this.addChild(this._scrollbarH);
1474
- }
1475
- }
1476
- // Interaction
1477
- this.eventMode = 'static';
1478
- this.cursor = 'grab';
1479
- this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
1480
- this.on('pointerdown', this.onPointerDown);
1481
- this.on('pointermove', this.onPointerMove);
1482
- this.on('pointerup', this.onPointerUp);
1483
- this.on('pointerupoutside', this.onPointerUp);
1484
- this.on('wheel', this.onWheel);
1303
+ super(options);
1304
+ this._scrollConfig = config;
1485
1305
  }
1486
1306
  /** Set scrollable content. Replaces any existing content. */
1487
1307
  setContent(content) {
1488
- if (this._content) {
1489
- this._viewport.removeChild(this._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);
1313
+ }
1490
1314
  }
1491
- this._content = content;
1492
- this._viewport.addChild(content);
1493
- this._scrollX = 0;
1494
- this._scrollY = 0;
1495
- this.applyScroll();
1496
- }
1497
- /** Get the content container */
1498
- get content() {
1499
- return this._content;
1500
- }
1501
- /** Scroll to a specific position (in content coordinates) */
1502
- scrollTo(x, y, animate = true) {
1503
- if (!animate) {
1504
- this._scrollX = x;
1505
- this._scrollY = y;
1506
- this.clampScroll();
1507
- this.applyScroll();
1508
- return;
1315
+ // Add all children from the content container
1316
+ const children = [...content.children];
1317
+ if (children.length > 0) {
1318
+ this.addItems(children);
1509
1319
  }
1510
- this.animateScrollTo(x, y);
1320
+ }
1321
+ /** Add a single item */
1322
+ addItem(...items) {
1323
+ this.addItems(items);
1324
+ return items[0];
1511
1325
  }
1512
1326
  /** Scroll to make a specific item/child visible */
1513
1327
  scrollToItem(index) {
1514
- if (this._config.snapSize > 0) {
1515
- const pos = index * this._config.snapSize;
1516
- if (this._config.direction === 'horizontal') {
1517
- this.scrollTo(pos, this._scrollY);
1518
- }
1519
- else {
1520
- this.scrollTo(this._scrollX, pos);
1521
- }
1522
- }
1328
+ this.scrollTo(index);
1523
1329
  }
1524
1330
  /** Current scroll position */
1525
1331
  get scrollPosition() {
1526
- return { x: this._scrollX, y: this._scrollY };
1527
- }
1528
- /** Resize the scroll viewport */
1529
- resize(width, height) {
1530
- this._config.width = width;
1531
- this._config.height = height;
1532
- // Redraw mask and background
1533
- this._mask.clear();
1534
- this._mask.roundRect(0, 0, width, height, this._config.borderRadius).fill(0xffffff);
1535
- this._bg.clear();
1536
- this.hitArea = { contains: (x, y) => x >= 0 && x <= width && y >= 0 && y <= height };
1537
- this.clampScroll();
1538
- this.applyScroll();
1539
- }
1540
- /** Destroy and clean up */
1541
- destroy(options) {
1542
- this.stopAnimation();
1543
- if (this._scrollbarFadeTimeout !== null) {
1544
- clearTimeout(this._scrollbarFadeTimeout);
1545
- }
1546
- this.off('pointerdown', this.onPointerDown);
1547
- this.off('pointermove', this.onPointerMove);
1548
- this.off('pointerup', this.onPointerUp);
1549
- this.off('pointerupoutside', this.onPointerUp);
1550
- this.off('wheel', this.onWheel);
1551
- super.destroy(options);
1552
- }
1553
- // ─── Scroll mechanics ─────────────────────────────────
1554
- get contentWidth() {
1555
- if (!this._content)
1556
- return 0;
1557
- const bounds = this._content.getBounds();
1558
- return bounds.width;
1559
- }
1560
- get contentHeight() {
1561
- if (!this._content)
1562
- return 0;
1563
- const bounds = this._content.getBounds();
1564
- return bounds.height;
1565
- }
1566
- get maxScrollX() {
1567
- return Math.max(0, this.contentWidth - this._config.width);
1568
- }
1569
- get maxScrollY() {
1570
- return Math.max(0, this.contentHeight - this._config.height);
1571
- }
1572
- canScrollX() {
1573
- return this._config.direction === 'horizontal' || this._config.direction === 'both';
1574
- }
1575
- canScrollY() {
1576
- return this._config.direction === 'vertical' || this._config.direction === 'both';
1577
- }
1578
- clampScroll() {
1579
- if (this.canScrollX()) {
1580
- this._scrollX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
1581
- }
1582
- else {
1583
- this._scrollX = 0;
1584
- }
1585
- if (this.canScrollY()) {
1586
- this._scrollY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
1587
- }
1588
- else {
1589
- this._scrollY = 0;
1590
- }
1591
- }
1592
- applyScroll() {
1593
- if (!this._content)
1594
- return;
1595
- this._content.x = -this._scrollX;
1596
- this._content.y = -this._scrollY;
1597
- this.updateScrollbars();
1598
- }
1599
- // ─── Input handlers ────────────────────────────────────
1600
- onPointerDown = (e) => {
1601
- this._isDragging = true;
1602
- this._isAnimating = false;
1603
- this.stopAnimation();
1604
- this.cursor = 'grabbing';
1605
- const local = e.getLocalPosition(this);
1606
- this._dragStart = { x: local.x, y: local.y };
1607
- this._scrollStart = { x: this._scrollX, y: this._scrollY };
1608
- this._lastDragPos = { x: local.x, y: local.y };
1609
- this._lastDragTime = Date.now();
1610
- this._velocityX = 0;
1611
- this._velocityY = 0;
1612
- this.showScrollbars();
1613
- };
1614
- onPointerMove = (e) => {
1615
- if (!this._isDragging)
1616
- return;
1617
- const local = e.getLocalPosition(this);
1618
- const dx = local.x - this._dragStart.x;
1619
- const dy = local.y - this._dragStart.y;
1620
- const now = Date.now();
1621
- const dt = Math.max(1, now - this._lastDragTime);
1622
- // Calculate velocity for inertia
1623
- this._velocityX = (local.x - this._lastDragPos.x) / dt * 16; // normalize to ~60fps
1624
- this._velocityY = (local.y - this._lastDragPos.y) / dt * 16;
1625
- this._lastDragPos = { x: local.x, y: local.y };
1626
- this._lastDragTime = now;
1627
- // Apply scroll with elasticity for overscroll
1628
- let newX = this._scrollStart.x - dx;
1629
- let newY = this._scrollStart.y - dy;
1630
- const elasticity = this._config.elasticity;
1631
- if (this.canScrollX()) {
1632
- if (newX < 0)
1633
- newX *= elasticity;
1634
- else if (newX > this.maxScrollX)
1635
- newX = this.maxScrollX + (newX - this.maxScrollX) * elasticity;
1636
- this._scrollX = newX;
1637
- }
1638
- if (this.canScrollY()) {
1639
- if (newY < 0)
1640
- newY *= elasticity;
1641
- else if (newY > this.maxScrollY)
1642
- newY = this.maxScrollY + (newY - this.maxScrollY) * elasticity;
1643
- this._scrollY = newY;
1644
- }
1645
- this.applyScroll();
1646
- };
1647
- onPointerUp = () => {
1648
- if (!this._isDragging)
1649
- return;
1650
- this._isDragging = false;
1651
- this.cursor = 'grab';
1652
- // Start inertia
1653
- if (Math.abs(this._velocityX) > 0.5 || Math.abs(this._velocityY) > 0.5) {
1654
- this.startInertia();
1655
- }
1656
- else {
1657
- this.snapAndBounce();
1658
- }
1659
- };
1660
- onWheel = (e) => {
1661
- e.preventDefault?.();
1662
- const delta = e.deltaY ?? 0;
1663
- const deltaX = e.deltaX ?? 0;
1664
- if (this.canScrollY()) {
1665
- this._scrollY += delta * 0.5;
1666
- }
1667
- if (this.canScrollX()) {
1668
- this._scrollX += deltaX * 0.5;
1669
- }
1670
- this.clampScroll();
1671
- this.applyScroll();
1672
- this.showScrollbars();
1673
- this.scheduleScrollbarFade();
1674
- };
1675
- // ─── Inertia & snap ───────────────────────────────────
1676
- startInertia() {
1677
- this._isAnimating = true;
1678
- const tick = () => {
1679
- if (!this._isAnimating)
1680
- return;
1681
- this._velocityX *= this._config.inertia;
1682
- this._velocityY *= this._config.inertia;
1683
- if (this.canScrollX())
1684
- this._scrollX -= this._velocityX;
1685
- if (this.canScrollY())
1686
- this._scrollY -= this._velocityY;
1687
- // Bounce back if overscrolled
1688
- let bounced = false;
1689
- if (this.canScrollX()) {
1690
- if (this._scrollX < 0) {
1691
- this._scrollX *= 0.8;
1692
- bounced = true;
1693
- }
1694
- else if (this._scrollX > this.maxScrollX) {
1695
- this._scrollX = this.maxScrollX + (this._scrollX - this.maxScrollX) * 0.8;
1696
- bounced = true;
1697
- }
1698
- }
1699
- if (this.canScrollY()) {
1700
- if (this._scrollY < 0) {
1701
- this._scrollY *= 0.8;
1702
- bounced = true;
1703
- }
1704
- else if (this._scrollY > this.maxScrollY) {
1705
- this._scrollY = this.maxScrollY + (this._scrollY - this.maxScrollY) * 0.8;
1706
- bounced = true;
1707
- }
1708
- }
1709
- this.applyScroll();
1710
- const speed = Math.abs(this._velocityX) + Math.abs(this._velocityY);
1711
- if (speed < 0.1 && !bounced) {
1712
- this._isAnimating = false;
1713
- this.snapAndBounce();
1714
- return;
1715
- }
1716
- this._animationFrame = requestAnimationFrame(tick);
1717
- };
1718
- this._animationFrame = requestAnimationFrame(tick);
1719
- }
1720
- snapAndBounce() {
1721
- // Clamp first
1722
- let targetX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
1723
- let targetY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
1724
- // Snap
1725
- if (this._config.snapSize > 0) {
1726
- if (this.canScrollY()) {
1727
- targetY = Math.round(targetY / this._config.snapSize) * this._config.snapSize;
1728
- targetY = Math.max(0, Math.min(targetY, this.maxScrollY));
1729
- }
1730
- if (this.canScrollX()) {
1731
- targetX = Math.round(targetX / this._config.snapSize) * this._config.snapSize;
1732
- targetX = Math.max(0, Math.min(targetX, this.maxScrollX));
1733
- }
1734
- }
1735
- if (Math.abs(targetX - this._scrollX) < 0.5 && Math.abs(targetY - this._scrollY) < 0.5) {
1736
- this._scrollX = targetX;
1737
- this._scrollY = targetY;
1738
- this.applyScroll();
1739
- this.scheduleScrollbarFade();
1740
- return;
1741
- }
1742
- this.animateScrollTo(targetX, targetY);
1743
- }
1744
- animateScrollTo(targetX, targetY) {
1745
- this._isAnimating = true;
1746
- const startX = this._scrollX;
1747
- const startY = this._scrollY;
1748
- const startTime = Date.now();
1749
- const duration = 300;
1750
- const tick = () => {
1751
- if (!this._isAnimating)
1752
- return;
1753
- const elapsed = Date.now() - startTime;
1754
- const t = Math.min(elapsed / duration, 1);
1755
- // easeOutCubic
1756
- const eased = 1 - Math.pow(1 - t, 3);
1757
- this._scrollX = startX + (targetX - startX) * eased;
1758
- this._scrollY = startY + (targetY - startY) * eased;
1759
- this.applyScroll();
1760
- if (t < 1) {
1761
- this._animationFrame = requestAnimationFrame(tick);
1762
- }
1763
- else {
1764
- this._isAnimating = false;
1765
- this.scheduleScrollbarFade();
1766
- }
1767
- };
1768
- this._animationFrame = requestAnimationFrame(tick);
1769
- }
1770
- stopAnimation() {
1771
- this._isAnimating = false;
1772
- if (this._animationFrame !== null) {
1773
- cancelAnimationFrame(this._animationFrame);
1774
- this._animationFrame = null;
1775
- }
1776
- }
1777
- // ─── Scrollbars ────────────────────────────────────────
1778
- updateScrollbars() {
1779
- const { width, height, scrollbarWidth, scrollbarColor, scrollbarAlpha } = this._config;
1780
- if (this._scrollbarV && this.canScrollY() && this.contentHeight > height) {
1781
- const ratio = height / this.contentHeight;
1782
- const barH = Math.max(20, height * ratio);
1783
- const barY = (this._scrollY / this.maxScrollY) * (height - barH);
1784
- this._scrollbarV.clear();
1785
- this._scrollbarV.roundRect(width - scrollbarWidth - 2, Math.max(0, barY), scrollbarWidth, barH, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
1786
- }
1787
- if (this._scrollbarH && this.canScrollX() && this.contentWidth > width) {
1788
- const ratio = width / this.contentWidth;
1789
- const barW = Math.max(20, width * ratio);
1790
- const barX = (this._scrollX / this.maxScrollX) * (width - barW);
1791
- this._scrollbarH.clear();
1792
- this._scrollbarH.roundRect(Math.max(0, barX), height - scrollbarWidth - 2, barW, scrollbarWidth, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
1793
- }
1794
- }
1795
- showScrollbars() {
1796
- if (this._scrollbarV)
1797
- this._scrollbarV.alpha = 1;
1798
- if (this._scrollbarH)
1799
- this._scrollbarH.alpha = 1;
1800
- }
1801
- scheduleScrollbarFade() {
1802
- if (this._scrollbarFadeTimeout !== null) {
1803
- clearTimeout(this._scrollbarFadeTimeout);
1804
- }
1805
- this._scrollbarFadeTimeout = window.setTimeout(() => {
1806
- this.fadeScrollbars();
1807
- }, 1000);
1808
- }
1809
- fadeScrollbars() {
1810
- const duration = 300;
1811
- const startTime = Date.now();
1812
- const startAlphaV = this._scrollbarV?.alpha ?? 0;
1813
- const startAlphaH = this._scrollbarH?.alpha ?? 0;
1814
- const tick = () => {
1815
- const t = Math.min((Date.now() - startTime) / duration, 1);
1816
- if (this._scrollbarV)
1817
- this._scrollbarV.alpha = startAlphaV * (1 - t);
1818
- if (this._scrollbarH)
1819
- this._scrollbarH.alpha = startAlphaH * (1 - t);
1820
- if (t < 1)
1821
- requestAnimationFrame(tick);
1822
- };
1823
- requestAnimationFrame(tick);
1332
+ return { x: this.scrollX, y: this.scrollY };
1824
1333
  }
1825
1334
  }
1826
1335