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