@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/index.esm.js CHANGED
@@ -1,5 +1,7 @@
1
- import { Ticker, Assets, Container, Text, Application, AnimatedSprite, Texture, Graphics, Sprite, NineSliceSprite } from 'pixi.js';
1
+ import { Ticker, Assets, Container, Text, Application, AnimatedSprite, Texture, Graphics, NineSliceSprite } from 'pixi.js';
2
2
  import { CasinoGameSDK, Bridge } from '@energy8platform/game-sdk';
3
+ import { FancyButton, ProgressBar as ProgressBar$1, ScrollBox } from '@pixi/ui';
4
+ import { LayoutContainer } from '@pixi/layout/components';
3
5
 
4
6
  // ─── Scale Modes ───────────────────────────────────────────
5
7
  var ScaleMode;
@@ -2172,6 +2174,7 @@ class GameApplication extends EventEmitter {
2172
2174
  async initPixi() {
2173
2175
  this.app = new Application();
2174
2176
  const pixiOpts = {
2177
+ preference: 'webgl',
2175
2178
  background: typeof this.config.loading?.backgroundColor === 'number'
2176
2179
  ? this.config.loading.backgroundColor
2177
2180
  : 0x000000,
@@ -2879,161 +2882,112 @@ class SpriteAnimation {
2879
2882
  }
2880
2883
 
2881
2884
  const DEFAULT_COLORS = {
2882
- normal: 0xffd700,
2885
+ default: 0xffd700,
2883
2886
  hover: 0xffe44d,
2884
2887
  pressed: 0xccac00,
2885
2888
  disabled: 0x666666,
2886
2889
  };
2890
+ function makeGraphicsView(w, h, radius, color) {
2891
+ const g = new Graphics();
2892
+ g.roundRect(0, 0, w, h, radius).fill(color);
2893
+ // Highlight overlay
2894
+ g.roundRect(2, 2, w - 4, h * 0.45, radius).fill({ color: 0xffffff, alpha: 0.1 });
2895
+ return g;
2896
+ }
2887
2897
  /**
2888
- * Interactive button component with state management and animation.
2898
+ * Interactive button component powered by `@pixi/ui` FancyButton.
2889
2899
  *
2890
- * Supports both texture-based and Graphics-based rendering.
2900
+ * Supports both texture-based and Graphics-based rendering with
2901
+ * per-state views, press animation, and text.
2891
2902
  *
2892
2903
  * @example
2893
2904
  * ```ts
2894
2905
  * const btn = new Button({
2895
2906
  * width: 200, height: 60, borderRadius: 12,
2896
- * colors: { normal: 0x22aa22, hover: 0x33cc33 },
2907
+ * colors: { default: 0x22aa22, hover: 0x33cc33 },
2908
+ * text: 'SPIN',
2897
2909
  * });
2898
2910
  *
2899
- * btn.onTap = () => console.log('Clicked!');
2911
+ * btn.onPress.connect(() => console.log('Clicked!'));
2900
2912
  * scene.container.addChild(btn);
2901
2913
  * ```
2902
2914
  */
2903
- class Button extends Container {
2904
- _state = 'normal';
2905
- _bg;
2906
- _sprites = {};
2907
- _config;
2908
- /** Called when the button is tapped/clicked */
2909
- onTap;
2910
- /** Called when the button state changes */
2911
- onStateChange;
2915
+ class Button extends FancyButton {
2916
+ _buttonConfig;
2912
2917
  constructor(config = {}) {
2913
- super();
2914
- this._config = {
2915
- width: 200,
2916
- height: 60,
2917
- borderRadius: 8,
2918
- pressScale: 0.95,
2919
- animationDuration: 100,
2918
+ const resolvedConfig = {
2919
+ width: config.width ?? 200,
2920
+ height: config.height ?? 60,
2921
+ borderRadius: config.borderRadius ?? 8,
2922
+ pressScale: config.pressScale ?? 0.95,
2923
+ animationDuration: config.animationDuration ?? 100,
2920
2924
  ...config,
2921
2925
  };
2922
- // Create Graphics background
2923
- this._bg = new Graphics();
2924
- this.addChild(this._bg);
2925
- // Create texture sprites if provided
2926
+ const colorMap = { ...DEFAULT_COLORS, ...config.colors };
2927
+ const { width, height, borderRadius } = resolvedConfig;
2928
+ // Build FancyButton options
2929
+ const options = {
2930
+ anchor: 0.5,
2931
+ animations: {
2932
+ hover: {
2933
+ props: { scale: { x: 1.03, y: 1.03 } },
2934
+ duration: resolvedConfig.animationDuration,
2935
+ },
2936
+ pressed: {
2937
+ props: { scale: { x: resolvedConfig.pressScale, y: resolvedConfig.pressScale } },
2938
+ duration: resolvedConfig.animationDuration,
2939
+ },
2940
+ },
2941
+ };
2942
+ // Texture-based views
2926
2943
  if (config.textures) {
2927
- for (const [state, tex] of Object.entries(config.textures)) {
2928
- const texture = typeof tex === 'string' ? Texture.from(tex) : tex;
2929
- const sprite = new Sprite(texture);
2930
- sprite.anchor.set(0.5);
2931
- sprite.visible = state === 'normal';
2932
- this._sprites[state] = sprite;
2933
- this.addChild(sprite);
2934
- }
2944
+ if (config.textures.default)
2945
+ options.defaultView = config.textures.default;
2946
+ if (config.textures.hover)
2947
+ options.hoverView = config.textures.hover;
2948
+ if (config.textures.pressed)
2949
+ options.pressedView = config.textures.pressed;
2950
+ if (config.textures.disabled)
2951
+ options.disabledView = config.textures.disabled;
2935
2952
  }
2936
- // Make interactive
2937
- this.eventMode = 'static';
2938
- this.cursor = 'pointer';
2939
- // Set up hit area for Graphics-based
2940
- this.pivot.set(this._config.width / 2, this._config.height / 2);
2941
- // Bind events
2942
- this.on('pointerover', this.onPointerOver);
2943
- this.on('pointerout', this.onPointerOut);
2944
- this.on('pointerdown', this.onPointerDown);
2945
- this.on('pointerup', this.onPointerUp);
2946
- this.on('pointertap', this.onPointerTap);
2947
- // Initial render
2948
- this.setState('normal');
2953
+ else {
2954
+ // Graphics-based views
2955
+ options.defaultView = makeGraphicsView(width, height, borderRadius, colorMap.default);
2956
+ options.hoverView = makeGraphicsView(width, height, borderRadius, colorMap.hover);
2957
+ options.pressedView = makeGraphicsView(width, height, borderRadius, colorMap.pressed);
2958
+ options.disabledView = makeGraphicsView(width, height, borderRadius, colorMap.disabled);
2959
+ }
2960
+ // Text
2961
+ if (config.text) {
2962
+ options.text = config.text;
2963
+ }
2964
+ super(options);
2965
+ this._buttonConfig = resolvedConfig;
2949
2966
  if (config.disabled) {
2950
- this.disable();
2967
+ this.enabled = false;
2951
2968
  }
2952
2969
  }
2953
- /** Current button state */
2954
- get state() {
2955
- return this._state;
2956
- }
2957
2970
  /** Enable the button */
2958
2971
  enable() {
2959
- if (this._state === 'disabled') {
2960
- this.setState('normal');
2961
- this.eventMode = 'static';
2962
- this.cursor = 'pointer';
2963
- }
2972
+ this.enabled = true;
2964
2973
  }
2965
2974
  /** Disable the button */
2966
2975
  disable() {
2967
- this.setState('disabled');
2968
- this.eventMode = 'none';
2969
- this.cursor = 'default';
2976
+ this.enabled = false;
2970
2977
  }
2971
2978
  /** Whether the button is disabled */
2972
2979
  get disabled() {
2973
- return this._state === 'disabled';
2974
- }
2975
- setState(state) {
2976
- if (this._state === state)
2977
- return;
2978
- this._state = state;
2979
- this.render();
2980
- this.onStateChange?.(state);
2981
- }
2982
- render() {
2983
- const { width, height, borderRadius, colors } = this._config;
2984
- const colorMap = { ...DEFAULT_COLORS, ...colors };
2985
- // Update Graphics
2986
- this._bg.clear();
2987
- this._bg.roundRect(0, 0, width, height, borderRadius).fill(colorMap[this._state]);
2988
- // Add highlight for normal/hover
2989
- if (this._state === 'normal' || this._state === 'hover') {
2990
- this._bg
2991
- .roundRect(2, 2, width - 4, height * 0.45, borderRadius)
2992
- .fill({ color: 0xffffff, alpha: 0.1 });
2993
- }
2994
- // Update sprite visibility
2995
- for (const [state, sprite] of Object.entries(this._sprites)) {
2996
- if (sprite)
2997
- sprite.visible = state === this._state;
2998
- }
2999
- // Fall back to normal sprite if state sprite doesn't exist
3000
- if (!this._sprites[this._state] && this._sprites.normal) {
3001
- this._sprites.normal.visible = true;
3002
- }
2980
+ return !this.enabled;
3003
2981
  }
3004
- onPointerOver = () => {
3005
- if (this._state === 'disabled')
3006
- return;
3007
- this.setState('hover');
3008
- };
3009
- onPointerOut = () => {
3010
- if (this._state === 'disabled')
3011
- return;
3012
- this.setState('normal');
3013
- Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration);
3014
- };
3015
- onPointerDown = () => {
3016
- if (this._state === 'disabled')
3017
- return;
3018
- this.setState('pressed');
3019
- const s = this._config.pressScale;
3020
- Tween.to(this.scale, { x: s, y: s }, this._config.animationDuration, Easing.easeOutQuad);
3021
- };
3022
- onPointerUp = () => {
3023
- if (this._state === 'disabled')
3024
- return;
3025
- this.setState('hover');
3026
- Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration, Easing.easeOutBack);
3027
- };
3028
- onPointerTap = () => {
3029
- if (this._state === 'disabled')
3030
- return;
3031
- this.onTap?.();
3032
- };
3033
2982
  }
3034
2983
 
2984
+ function makeBarGraphics(w, h, radius, color) {
2985
+ return new Graphics().roundRect(0, 0, w, h, radius).fill(color);
2986
+ }
3035
2987
  /**
3036
- * Horizontal progress bar with optional smooth fill animation.
2988
+ * Horizontal progress bar powered by `@pixi/ui` ProgressBar.
2989
+ *
2990
+ * Provides optional smooth animated fill via per-frame `update()`.
3037
2991
  *
3038
2992
  * @example
3039
2993
  * ```ts
@@ -3043,33 +2997,48 @@ class Button extends Container {
3043
2997
  * ```
3044
2998
  */
3045
2999
  class ProgressBar extends Container {
3046
- _track;
3047
- _fill;
3048
- _border;
3000
+ _bar;
3001
+ _borderGfx;
3049
3002
  _config;
3050
3003
  _progress = 0;
3051
3004
  _displayedProgress = 0;
3052
3005
  constructor(config = {}) {
3053
3006
  super();
3054
3007
  this._config = {
3055
- width: 300,
3056
- height: 16,
3057
- borderRadius: 8,
3058
- fillColor: 0xffd700,
3059
- trackColor: 0x333333,
3060
- borderColor: 0x555555,
3061
- borderWidth: 1,
3062
- animated: true,
3063
- animationSpeed: 0.1,
3064
- ...config,
3008
+ width: config.width ?? 300,
3009
+ height: config.height ?? 16,
3010
+ borderRadius: config.borderRadius ?? 8,
3011
+ fillColor: config.fillColor ?? 0xffd700,
3012
+ trackColor: config.trackColor ?? 0x333333,
3013
+ borderColor: config.borderColor ?? 0x555555,
3014
+ borderWidth: config.borderWidth ?? 1,
3015
+ animated: config.animated ?? true,
3016
+ animationSpeed: config.animationSpeed ?? 0.1,
3017
+ };
3018
+ const { width, height, borderRadius, fillColor, trackColor, borderColor, borderWidth } = this._config;
3019
+ const bgGraphics = makeBarGraphics(width, height, borderRadius, trackColor);
3020
+ const fillGraphics = makeBarGraphics(width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1), fillColor);
3021
+ const options = {
3022
+ bg: bgGraphics,
3023
+ fill: fillGraphics,
3024
+ fillPaddings: {
3025
+ top: borderWidth,
3026
+ right: borderWidth,
3027
+ bottom: borderWidth,
3028
+ left: borderWidth,
3029
+ },
3030
+ progress: 0,
3065
3031
  };
3066
- this._track = new Graphics();
3067
- this._fill = new Graphics();
3068
- this._border = new Graphics();
3069
- this.addChild(this._track, this._fill, this._border);
3070
- this.drawTrack();
3071
- this.drawBorder();
3072
- this.drawFill(0);
3032
+ this._bar = new ProgressBar$1(options);
3033
+ this.addChild(this._bar);
3034
+ // Border overlay
3035
+ this._borderGfx = new Graphics();
3036
+ if (borderColor !== undefined && borderWidth > 0) {
3037
+ this._borderGfx
3038
+ .roundRect(0, 0, width, height, borderRadius)
3039
+ .stroke({ color: borderColor, width: borderWidth });
3040
+ }
3041
+ this.addChild(this._borderGfx);
3073
3042
  }
3074
3043
  /** Get/set progress (0..1) */
3075
3044
  get progress() {
@@ -3079,13 +3048,13 @@ class ProgressBar extends Container {
3079
3048
  this._progress = Math.max(0, Math.min(1, value));
3080
3049
  if (!this._config.animated) {
3081
3050
  this._displayedProgress = this._progress;
3082
- this.drawFill(this._displayedProgress);
3051
+ this._bar.progress = this._displayedProgress * 100;
3083
3052
  }
3084
3053
  }
3085
3054
  /**
3086
3055
  * Call each frame if animated is true.
3087
3056
  */
3088
- update(dt) {
3057
+ update(_dt) {
3089
3058
  if (!this._config.animated)
3090
3059
  return;
3091
3060
  if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
@@ -3094,35 +3063,7 @@ class ProgressBar extends Container {
3094
3063
  }
3095
3064
  this._displayedProgress +=
3096
3065
  (this._progress - this._displayedProgress) * this._config.animationSpeed;
3097
- this.drawFill(this._displayedProgress);
3098
- }
3099
- drawTrack() {
3100
- const { width, height, borderRadius, trackColor } = this._config;
3101
- this._track.clear();
3102
- this._track.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
3103
- }
3104
- drawBorder() {
3105
- const { width, height, borderRadius, borderColor, borderWidth } = this._config;
3106
- this._border.clear();
3107
- this._border
3108
- .roundRect(0, 0, width, height, borderRadius)
3109
- .stroke({ color: borderColor, width: borderWidth });
3110
- }
3111
- drawFill(progress) {
3112
- const { width, height, borderRadius, fillColor, borderWidth } = this._config;
3113
- const innerWidth = width - borderWidth * 2;
3114
- const innerHeight = height - borderWidth * 2;
3115
- const fillWidth = Math.max(0, innerWidth * progress);
3116
- this._fill.clear();
3117
- if (fillWidth > 0) {
3118
- this._fill.x = borderWidth;
3119
- this._fill.y = borderWidth;
3120
- this._fill.roundRect(0, 0, fillWidth, innerHeight, borderRadius - 1).fill(fillColor);
3121
- // Highlight
3122
- this._fill
3123
- .roundRect(0, 0, fillWidth, innerHeight * 0.4, borderRadius - 1)
3124
- .fill({ color: 0xffffff, alpha: 0.15 });
3125
- }
3066
+ this._bar.progress = this._displayedProgress * 100;
3126
3067
  }
3127
3068
  }
3128
3069
 
@@ -3218,7 +3159,10 @@ class Label extends Container {
3218
3159
  }
3219
3160
 
3220
3161
  /**
3221
- * Background panel that can use either Graphics or 9-slice sprite.
3162
+ * Background panel powered by `@pixi/layout` LayoutContainer.
3163
+ *
3164
+ * Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
3165
+ * Children added to `content` participate in flexbox layout automatically.
3222
3166
  *
3223
3167
  * @example
3224
3168
  * ```ts
@@ -3233,72 +3177,67 @@ class Label extends Container {
3233
3177
  * });
3234
3178
  * ```
3235
3179
  */
3236
- class Panel extends Container {
3237
- _bg;
3238
- _content;
3239
- _config;
3180
+ class Panel extends LayoutContainer {
3181
+ _panelConfig;
3240
3182
  constructor(config = {}) {
3241
- super();
3242
- this._config = {
3243
- width: 400,
3244
- height: 300,
3245
- padding: 16,
3246
- backgroundAlpha: 1,
3183
+ const resolvedConfig = {
3184
+ width: config.width ?? 400,
3185
+ height: config.height ?? 300,
3186
+ padding: config.padding ?? 16,
3187
+ backgroundAlpha: config.backgroundAlpha ?? 1,
3247
3188
  ...config,
3248
3189
  };
3249
- // Create background
3190
+ // If using a 9-slice texture, pass it as a custom background
3191
+ let customBackground;
3250
3192
  if (config.nineSliceTexture) {
3251
3193
  const texture = typeof config.nineSliceTexture === 'string'
3252
3194
  ? Texture.from(config.nineSliceTexture)
3253
3195
  : config.nineSliceTexture;
3254
3196
  const [left, top, right, bottom] = config.nineSliceBorders ?? [10, 10, 10, 10];
3255
- this._bg = new NineSliceSprite({
3197
+ const nineSlice = new NineSliceSprite({
3256
3198
  texture,
3257
3199
  leftWidth: left,
3258
3200
  topHeight: top,
3259
3201
  rightWidth: right,
3260
3202
  bottomHeight: bottom,
3261
3203
  });
3262
- this._bg.width = this._config.width;
3263
- this._bg.height = this._config.height;
3204
+ nineSlice.width = resolvedConfig.width;
3205
+ nineSlice.height = resolvedConfig.height;
3206
+ nineSlice.alpha = resolvedConfig.backgroundAlpha;
3207
+ customBackground = nineSlice;
3208
+ }
3209
+ super(customBackground ? { background: customBackground } : undefined);
3210
+ this._panelConfig = resolvedConfig;
3211
+ // Apply layout styles
3212
+ const layoutStyles = {
3213
+ width: resolvedConfig.width,
3214
+ height: resolvedConfig.height,
3215
+ padding: resolvedConfig.padding,
3216
+ flexDirection: 'column',
3217
+ };
3218
+ // Graphics-based background via layout styles
3219
+ if (!config.nineSliceTexture) {
3220
+ layoutStyles.backgroundColor = config.backgroundColor ?? 0x1a1a2e;
3221
+ layoutStyles.borderRadius = config.borderRadius ?? 0;
3222
+ if (config.borderColor !== undefined && config.borderWidth) {
3223
+ layoutStyles.borderColor = config.borderColor;
3224
+ layoutStyles.borderWidth = config.borderWidth;
3225
+ }
3264
3226
  }
3265
- else {
3266
- this._bg = new Graphics();
3267
- this.drawGraphicsBg();
3227
+ this.layout = layoutStyles;
3228
+ if (!config.nineSliceTexture) {
3229
+ this.background.alpha = resolvedConfig.backgroundAlpha;
3268
3230
  }
3269
- this._bg.alpha = this._config.backgroundAlpha;
3270
- this.addChild(this._bg);
3271
- // Content container with padding
3272
- this._content = new Container();
3273
- this._content.x = this._config.padding;
3274
- this._content.y = this._config.padding;
3275
- this.addChild(this._content);
3276
3231
  }
3277
- /** Content container add children here */
3232
+ /** Access the content container (children added here participate in layout) */
3278
3233
  get content() {
3279
- return this._content;
3234
+ return this.overflowContainer;
3280
3235
  }
3281
3236
  /** Resize the panel */
3282
3237
  setSize(width, height) {
3283
- this._config.width = width;
3284
- this._config.height = height;
3285
- if (this._bg instanceof Graphics) {
3286
- this.drawGraphicsBg();
3287
- }
3288
- else {
3289
- this._bg.width = width;
3290
- this._bg.height = height;
3291
- }
3292
- }
3293
- drawGraphicsBg() {
3294
- const bg = this._bg;
3295
- const { width, height, backgroundColor, borderRadius, borderColor, borderWidth, } = this._config;
3296
- bg.clear();
3297
- bg.roundRect(0, 0, width, height, borderRadius ?? 0).fill(backgroundColor ?? 0x1a1a2e);
3298
- if (borderColor !== undefined && borderWidth) {
3299
- bg.roundRect(0, 0, width, height, borderRadius ?? 0)
3300
- .stroke({ color: borderColor, width: borderWidth });
3301
- }
3238
+ this._panelConfig.width = width;
3239
+ this._panelConfig.height = height;
3240
+ this._layout?.setStyle({ width, height });
3302
3241
  }
3303
3242
  }
3304
3243
 
@@ -3386,7 +3325,6 @@ class BalanceDisplay extends Container {
3386
3325
  this.updateDisplay();
3387
3326
  }
3388
3327
  async animateValue(from, to) {
3389
- // Cancel any ongoing animation
3390
3328
  if (this._animating) {
3391
3329
  this._animationCancelled = true;
3392
3330
  }
@@ -3396,7 +3334,6 @@ class BalanceDisplay extends Container {
3396
3334
  const startTime = Date.now();
3397
3335
  return new Promise((resolve) => {
3398
3336
  const tick = () => {
3399
- // If cancelled by a newer animation, stop immediately
3400
3337
  if (this._animationCancelled) {
3401
3338
  this._animating = false;
3402
3339
  resolve();
@@ -3538,6 +3475,8 @@ class WinDisplay extends Container {
3538
3475
  * Modal overlay component.
3539
3476
  * Shows content on top of a dark overlay with enter/exit animations.
3540
3477
  *
3478
+ * The content container uses `@pixi/layout` for automatic centering.
3479
+ *
3541
3480
  * @example
3542
3481
  * ```ts
3543
3482
  * const modal = new Modal({ closeOnOverlay: true });
@@ -3556,11 +3495,10 @@ class Modal extends Container {
3556
3495
  constructor(config = {}) {
3557
3496
  super();
3558
3497
  this._config = {
3559
- overlayColor: 0x000000,
3560
- overlayAlpha: 0.7,
3561
- closeOnOverlay: true,
3562
- animationDuration: 300,
3563
- ...config,
3498
+ overlayColor: config.overlayColor ?? 0x000000,
3499
+ overlayAlpha: config.overlayAlpha ?? 0.7,
3500
+ closeOnOverlay: config.closeOnOverlay ?? true,
3501
+ animationDuration: config.animationDuration ?? 300,
3564
3502
  };
3565
3503
  // Overlay
3566
3504
  this._overlay = new Graphics();
@@ -3643,9 +3581,8 @@ class Toast extends Container {
3643
3581
  constructor(config = {}) {
3644
3582
  super();
3645
3583
  this._config = {
3646
- duration: 3000,
3647
- bottomOffset: 60,
3648
- ...config,
3584
+ duration: config.duration ?? 3000,
3585
+ bottomOffset: config.bottomOffset ?? 60,
3649
3586
  };
3650
3587
  this._bg = new Graphics();
3651
3588
  this.addChild(this._bg);
@@ -3665,7 +3602,6 @@ class Toast extends Container {
3665
3602
  * Show a toast message.
3666
3603
  */
3667
3604
  async show(message, type = 'info', viewWidth, viewHeight) {
3668
- // Clear previous dismiss
3669
3605
  if (this._dismissTimeout) {
3670
3606
  clearTimeout(this._dismissTimeout);
3671
3607
  }
@@ -3674,10 +3610,10 @@ class Toast extends Container {
3674
3610
  const width = Math.max(200, this._text.width + padding * 2);
3675
3611
  const height = 44;
3676
3612
  const radius = 8;
3613
+ // Draw the background
3677
3614
  this._bg.clear();
3678
- this._bg.roundRect(-width / 2, -height / 2, width, height, radius).fill(TOAST_COLORS[type]);
3679
- this._bg.roundRect(-width / 2, -height / 2, width, height, radius)
3680
- .fill({ color: 0x000000, alpha: 0.2 });
3615
+ this._bg.roundRect(-width / 2, -height / 2, width, height, radius);
3616
+ this._bg.fill(TOAST_COLORS[type]);
3681
3617
  // Position
3682
3618
  if (viewWidth && viewHeight) {
3683
3619
  this.x = viewWidth / 2;
@@ -3686,9 +3622,7 @@ class Toast extends Container {
3686
3622
  this.visible = true;
3687
3623
  this.alpha = 0;
3688
3624
  this.y += 20;
3689
- // Animate in
3690
3625
  await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
3691
- // Auto-dismiss
3692
3626
  if (this._config.duration > 0) {
3693
3627
  this._dismissTimeout = setTimeout(() => {
3694
3628
  this.dismiss();
@@ -3710,8 +3644,48 @@ class Toast extends Container {
3710
3644
  }
3711
3645
  }
3712
3646
 
3647
+ // ─── Helpers ─────────────────────────────────────────────
3648
+ const ALIGNMENT_MAP = {
3649
+ start: 'flex-start',
3650
+ center: 'center',
3651
+ end: 'flex-end',
3652
+ stretch: 'stretch',
3653
+ };
3654
+ function normalizePadding(padding) {
3655
+ if (typeof padding === 'number')
3656
+ return [padding, padding, padding, padding];
3657
+ return padding;
3658
+ }
3659
+ function directionToFlexStyles(direction, maxWidth) {
3660
+ switch (direction) {
3661
+ case 'horizontal':
3662
+ return { flexDirection: 'row', flexWrap: 'nowrap' };
3663
+ case 'vertical':
3664
+ return { flexDirection: 'column', flexWrap: 'nowrap' };
3665
+ case 'grid':
3666
+ return { flexDirection: 'row', flexWrap: 'wrap' };
3667
+ case 'wrap':
3668
+ return {
3669
+ flexDirection: 'row',
3670
+ flexWrap: 'wrap',
3671
+ ...(maxWidth < Infinity ? { maxWidth } : {}),
3672
+ };
3673
+ }
3674
+ }
3675
+ function buildLayoutStyles(config) {
3676
+ const [pt, pr, pb, pl] = config.padding;
3677
+ return {
3678
+ ...directionToFlexStyles(config.direction, config.maxWidth),
3679
+ gap: config.gap,
3680
+ alignItems: ALIGNMENT_MAP[config.alignment],
3681
+ paddingTop: pt,
3682
+ paddingRight: pr,
3683
+ paddingBottom: pb,
3684
+ paddingLeft: pl,
3685
+ };
3686
+ }
3713
3687
  /**
3714
- * Responsive layout container that automatically arranges its children.
3688
+ * Responsive layout container powered by `@pixi/layout` (Yoga flexbox engine).
3715
3689
  *
3716
3690
  * Supports horizontal, vertical, grid, and wrap layout modes with
3717
3691
  * alignment, padding, gap, and viewport-anchor positioning.
@@ -3734,47 +3708,44 @@ class Toast extends Container {
3734
3708
  * toolbar.addItem(betLabel);
3735
3709
  * scene.container.addChild(toolbar);
3736
3710
  *
3737
- * // On resize, update layout position relative to viewport
3738
3711
  * toolbar.updateViewport(width, height);
3739
3712
  * ```
3740
3713
  */
3741
3714
  class Layout extends Container {
3742
- _config;
3715
+ _layoutConfig;
3743
3716
  _padding;
3744
3717
  _anchor;
3745
3718
  _maxWidth;
3746
3719
  _breakpoints;
3747
- _content;
3748
3720
  _items = [];
3749
3721
  _viewportWidth = 0;
3750
3722
  _viewportHeight = 0;
3751
3723
  constructor(config = {}) {
3752
3724
  super();
3753
- this._config = {
3725
+ this._layoutConfig = {
3754
3726
  direction: config.direction ?? 'vertical',
3755
3727
  gap: config.gap ?? 0,
3756
3728
  alignment: config.alignment ?? 'start',
3757
3729
  autoLayout: config.autoLayout ?? true,
3758
3730
  columns: config.columns ?? 2,
3759
3731
  };
3760
- this._padding = Layout.normalizePadding(config.padding ?? 0);
3732
+ this._padding = normalizePadding(config.padding ?? 0);
3761
3733
  this._anchor = config.anchor ?? 'top-left';
3762
3734
  this._maxWidth = config.maxWidth ?? Infinity;
3763
- // Sort breakpoints by width ascending for correct resolution
3764
3735
  this._breakpoints = config.breakpoints
3765
3736
  ? Object.entries(config.breakpoints)
3766
3737
  .map(([w, cfg]) => [Number(w), cfg])
3767
3738
  .sort((a, b) => a[0] - b[0])
3768
3739
  : [];
3769
- this._content = new Container();
3770
- this.addChild(this._content);
3740
+ this.applyLayoutStyles();
3771
3741
  }
3772
3742
  /** Add an item to the layout */
3773
3743
  addItem(child) {
3774
3744
  this._items.push(child);
3775
- this._content.addChild(child);
3776
- if (this._config.autoLayout)
3777
- this.layout();
3745
+ this.addChild(child);
3746
+ if (this._layoutConfig.direction === 'grid') {
3747
+ this.applyGridChildWidth(child);
3748
+ }
3778
3749
  return this;
3779
3750
  }
3780
3751
  /** Remove an item from the layout */
@@ -3782,20 +3753,16 @@ class Layout extends Container {
3782
3753
  const idx = this._items.indexOf(child);
3783
3754
  if (idx !== -1) {
3784
3755
  this._items.splice(idx, 1);
3785
- this._content.removeChild(child);
3786
- if (this._config.autoLayout)
3787
- this.layout();
3756
+ this.removeChild(child);
3788
3757
  }
3789
3758
  return this;
3790
3759
  }
3791
3760
  /** Remove all items */
3792
3761
  clearItems() {
3793
3762
  for (const item of this._items) {
3794
- this._content.removeChild(item);
3763
+ this.removeChild(item);
3795
3764
  }
3796
3765
  this._items.length = 0;
3797
- if (this._config.autoLayout)
3798
- this.layout();
3799
3766
  return this;
3800
3767
  }
3801
3768
  /** Get all layout items */
@@ -3809,129 +3776,55 @@ class Layout extends Container {
3809
3776
  updateViewport(width, height) {
3810
3777
  this._viewportWidth = width;
3811
3778
  this._viewportHeight = height;
3812
- this.layout();
3779
+ this.applyLayoutStyles();
3780
+ this.applyAnchor();
3813
3781
  }
3814
- /**
3815
- * Recalculate layout positions of all children.
3816
- */
3817
- layout() {
3818
- if (this._items.length === 0)
3819
- return;
3820
- // Resolve effective config (apply breakpoint overrides)
3782
+ applyLayoutStyles() {
3821
3783
  const effective = this.resolveConfig();
3822
- const gap = effective.gap ?? this._config.gap;
3823
- const direction = effective.direction ?? this._config.direction;
3824
- const alignment = effective.alignment ?? this._config.alignment;
3825
- const columns = effective.columns ?? this._config.columns;
3784
+ const direction = effective.direction ?? this._layoutConfig.direction;
3785
+ const gap = effective.gap ?? this._layoutConfig.gap;
3786
+ const alignment = effective.alignment ?? this._layoutConfig.alignment;
3787
+ effective.columns ?? this._layoutConfig.columns;
3826
3788
  const padding = effective.padding !== undefined
3827
- ? Layout.normalizePadding(effective.padding)
3789
+ ? normalizePadding(effective.padding)
3828
3790
  : this._padding;
3829
3791
  const maxWidth = effective.maxWidth ?? this._maxWidth;
3830
- const [pt, pr, pb, pl] = padding;
3831
- switch (direction) {
3832
- case 'horizontal':
3833
- this.layoutLinear('x', 'y', gap, alignment, pl, pt);
3834
- break;
3835
- case 'vertical':
3836
- this.layoutLinear('y', 'x', gap, alignment, pt, pl);
3837
- break;
3838
- case 'grid':
3839
- this.layoutGrid(columns, gap, alignment, pl, pt);
3840
- break;
3841
- case 'wrap':
3842
- this.layoutWrap(maxWidth - pl - pr, gap, alignment, pl, pt);
3843
- break;
3844
- }
3845
- // Apply anchor positioning relative to viewport
3846
- this.applyAnchor(effective.anchor ?? this._anchor);
3847
- }
3848
- // ─── Private layout helpers ────────────────────────────
3849
- layoutLinear(mainAxis, crossAxis, gap, alignment, mainOffset, crossOffset) {
3850
- let pos = mainOffset;
3851
- const sizes = this._items.map(item => this.getItemSize(item));
3852
- const maxCross = Math.max(...sizes.map(s => (crossAxis === 'x' ? s.width : s.height)));
3853
- for (let i = 0; i < this._items.length; i++) {
3854
- const item = this._items[i];
3855
- const size = sizes[i];
3856
- item[mainAxis] = pos;
3857
- // Cross-axis alignment
3858
- const itemCross = crossAxis === 'x' ? size.width : size.height;
3859
- switch (alignment) {
3860
- case 'start':
3861
- item[crossAxis] = crossOffset;
3862
- break;
3863
- case 'center':
3864
- item[crossAxis] = crossOffset + (maxCross - itemCross) / 2;
3865
- break;
3866
- case 'end':
3867
- item[crossAxis] = crossOffset + maxCross - itemCross;
3868
- break;
3869
- case 'stretch':
3870
- item[crossAxis] = crossOffset;
3871
- // Note: stretch doesn't resize children — that's up to the item
3872
- break;
3873
- }
3874
- const mainSize = mainAxis === 'x' ? size.width : size.height;
3875
- pos += mainSize + gap;
3876
- }
3877
- }
3878
- layoutGrid(columns, gap, alignment, offsetX, offsetY) {
3879
- const sizes = this._items.map(item => this.getItemSize(item));
3880
- const maxItemWidth = Math.max(...sizes.map(s => s.width));
3881
- const maxItemHeight = Math.max(...sizes.map(s => s.height));
3882
- const cellW = maxItemWidth + gap;
3883
- const cellH = maxItemHeight + gap;
3884
- for (let i = 0; i < this._items.length; i++) {
3885
- const item = this._items[i];
3886
- const col = i % columns;
3887
- const row = Math.floor(i / columns);
3888
- const size = sizes[i];
3889
- // X alignment within cell
3890
- switch (alignment) {
3891
- case 'center':
3892
- item.x = offsetX + col * cellW + (maxItemWidth - size.width) / 2;
3893
- break;
3894
- case 'end':
3895
- item.x = offsetX + col * cellW + maxItemWidth - size.width;
3896
- break;
3897
- default:
3898
- item.x = offsetX + col * cellW;
3899
- }
3900
- item.y = offsetY + row * cellH;
3901
- }
3902
- }
3903
- layoutWrap(maxWidth, gap, alignment, offsetX, offsetY) {
3904
- let x = offsetX;
3905
- let y = offsetY;
3906
- let rowHeight = 0;
3907
- const sizes = this._items.map(item => this.getItemSize(item));
3908
- for (let i = 0; i < this._items.length; i++) {
3909
- const item = this._items[i];
3910
- const size = sizes[i];
3911
- // Check if item fits in current row
3912
- if (x + size.width > maxWidth + offsetX && x > offsetX) {
3913
- // Wrap to next row
3914
- x = offsetX;
3915
- y += rowHeight + gap;
3916
- rowHeight = 0;
3792
+ const styles = buildLayoutStyles({ direction, gap, alignment, padding, maxWidth });
3793
+ this.layout = styles;
3794
+ if (direction === 'grid') {
3795
+ for (const item of this._items) {
3796
+ this.applyGridChildWidth(item);
3917
3797
  }
3918
- item.x = x;
3919
- item.y = y;
3920
- x += size.width + gap;
3921
- rowHeight = Math.max(rowHeight, size.height);
3922
3798
  }
3923
3799
  }
3924
- applyAnchor(anchor) {
3800
+ applyGridChildWidth(child) {
3801
+ const effective = this.resolveConfig();
3802
+ const columns = effective.columns ?? this._layoutConfig.columns;
3803
+ const gap = effective.gap ?? this._layoutConfig.gap;
3804
+ // Account for gaps between columns: total gap space = gap * (columns - 1)
3805
+ // Each column gets: (100% - total_gap) / columns
3806
+ // We use flexBasis + flexGrow to let Yoga handle the math when gap > 0
3807
+ const styles = gap > 0
3808
+ ? { flexBasis: 0, flexGrow: 1, flexShrink: 1, maxWidth: `${(100 / columns).toFixed(2)}%` }
3809
+ : { width: `${(100 / columns).toFixed(2)}%` };
3810
+ if (child._layout) {
3811
+ child._layout.setStyle(styles);
3812
+ }
3813
+ else {
3814
+ child.layout = styles;
3815
+ }
3816
+ }
3817
+ applyAnchor() {
3818
+ const anchor = this.resolveConfig().anchor ?? this._anchor;
3925
3819
  if (this._viewportWidth === 0 || this._viewportHeight === 0)
3926
3820
  return;
3927
- const bounds = this._content.getBounds();
3928
- const contentW = bounds.width;
3929
- const contentH = bounds.height;
3821
+ const bounds = this.getLocalBounds();
3822
+ const contentW = bounds.width * this.scale.x;
3823
+ const contentH = bounds.height * this.scale.y;
3930
3824
  const vw = this._viewportWidth;
3931
3825
  const vh = this._viewportHeight;
3932
3826
  let anchorX = 0;
3933
3827
  let anchorY = 0;
3934
- // Horizontal
3935
3828
  if (anchor.includes('left')) {
3936
3829
  anchorX = 0;
3937
3830
  }
@@ -3939,10 +3832,8 @@ class Layout extends Container {
3939
3832
  anchorX = vw - contentW;
3940
3833
  }
3941
3834
  else {
3942
- // center
3943
3835
  anchorX = (vw - contentW) / 2;
3944
3836
  }
3945
- // Vertical
3946
3837
  if (anchor.startsWith('top')) {
3947
3838
  anchorY = 0;
3948
3839
  }
@@ -3950,44 +3841,34 @@ class Layout extends Container {
3950
3841
  anchorY = vh - contentH;
3951
3842
  }
3952
3843
  else {
3953
- // center
3954
3844
  anchorY = (vh - contentH) / 2;
3955
3845
  }
3956
- // Compensate for content's local bounds offset
3957
- this.x = anchorX - bounds.x;
3958
- this.y = anchorY - bounds.y;
3846
+ this.x = anchorX - bounds.x * this.scale.x;
3847
+ this.y = anchorY - bounds.y * this.scale.y;
3959
3848
  }
3960
3849
  resolveConfig() {
3961
3850
  if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
3962
3851
  return {};
3963
3852
  }
3964
- // Find the largest breakpoint that's ≤ current viewport width
3965
- let resolved = {};
3966
3853
  for (const [maxWidth, overrides] of this._breakpoints) {
3967
3854
  if (this._viewportWidth <= maxWidth) {
3968
- resolved = overrides;
3969
- break;
3855
+ return overrides;
3970
3856
  }
3971
3857
  }
3972
- return resolved;
3973
- }
3974
- getItemSize(item) {
3975
- const bounds = item.getBounds();
3976
- return { width: bounds.width, height: bounds.height };
3977
- }
3978
- static normalizePadding(padding) {
3979
- if (typeof padding === 'number') {
3980
- return [padding, padding, padding, padding];
3981
- }
3982
- return padding;
3858
+ return {};
3983
3859
  }
3984
3860
  }
3985
3861
 
3862
+ const DIRECTION_MAP = {
3863
+ vertical: 'vertical',
3864
+ horizontal: 'horizontal',
3865
+ both: 'bidirectional',
3866
+ };
3986
3867
  /**
3987
- * Scrollable container with touch/drag, mouse wheel, inertia, and optional scrollbar.
3868
+ * Scrollable container powered by `@pixi/ui` ScrollBox.
3988
3869
  *
3989
- * Perfect for paytables, settings panels, bet history, and any scrollable content
3990
- * that doesn't fit on screen.
3870
+ * Provides touch/drag scrolling, mouse wheel support, inertia, and
3871
+ * dynamic rendering optimization for off-screen items.
3991
3872
  *
3992
3873
  * @example
3993
3874
  * ```ts
@@ -3995,435 +3876,63 @@ class Layout extends Container {
3995
3876
  * width: 600,
3996
3877
  * height: 400,
3997
3878
  * direction: 'vertical',
3998
- * showScrollbar: true,
3999
- * elasticity: 0.3,
3879
+ * elementsMargin: 8,
4000
3880
  * });
4001
3881
  *
4002
- * // Add content taller than 400px
4003
- * const list = new Container();
4004
3882
  * for (let i = 0; i < 50; i++) {
4005
- * const row = createRow(i);
4006
- * row.y = i * 40;
4007
- * list.addChild(row);
3883
+ * scroll.addItem(createRow(i));
4008
3884
  * }
4009
- * scroll.setContent(list);
4010
3885
  *
4011
3886
  * scene.container.addChild(scroll);
4012
3887
  * ```
4013
3888
  */
4014
- class ScrollContainer extends Container {
4015
- _config;
4016
- _viewport;
4017
- _content = null;
4018
- _mask;
4019
- _bg;
4020
- _scrollbarV = null;
4021
- _scrollbarH = null;
4022
- _scrollbarFadeTimeout = null;
4023
- // Scroll state
4024
- _scrollX = 0;
4025
- _scrollY = 0;
4026
- _velocityX = 0;
4027
- _velocityY = 0;
4028
- _isDragging = false;
4029
- _dragStart = { x: 0, y: 0 };
4030
- _scrollStart = { x: 0, y: 0 };
4031
- _lastDragPos = { x: 0, y: 0 };
4032
- _lastDragTime = 0;
4033
- _isAnimating = false;
4034
- _animationFrame = null;
3889
+ class ScrollContainer extends ScrollBox {
3890
+ _scrollConfig;
4035
3891
  constructor(config) {
4036
- super();
4037
- this._config = {
3892
+ const options = {
4038
3893
  width: config.width,
4039
3894
  height: config.height,
4040
- direction: config.direction ?? 'vertical',
4041
- showScrollbar: config.showScrollbar ?? true,
4042
- scrollbarWidth: config.scrollbarWidth ?? 6,
4043
- scrollbarColor: config.scrollbarColor ?? 0xffffff,
4044
- scrollbarAlpha: config.scrollbarAlpha ?? 0.4,
4045
- elasticity: config.elasticity ?? 0.3,
4046
- inertia: config.inertia ?? 0.92,
4047
- snapSize: config.snapSize ?? 0,
4048
- borderRadius: config.borderRadius ?? 0,
3895
+ type: DIRECTION_MAP[config.direction ?? 'vertical'],
3896
+ radius: config.borderRadius ?? 0,
3897
+ elementsMargin: config.elementsMargin ?? 0,
3898
+ padding: config.padding ?? 0,
3899
+ disableDynamicRendering: config.disableDynamicRendering ?? false,
3900
+ disableEasing: config.disableEasing ?? false,
3901
+ globalScroll: config.globalScroll ?? true,
4049
3902
  };
4050
- // Background
4051
- this._bg = new Graphics();
4052
3903
  if (config.backgroundColor !== undefined) {
4053
- this._bg.roundRect(0, 0, config.width, config.height, this._config.borderRadius)
4054
- .fill({ color: config.backgroundColor, alpha: config.backgroundAlpha ?? 1 });
4055
- }
4056
- this.addChild(this._bg);
4057
- // Viewport (masked area)
4058
- this._viewport = new Container();
4059
- this.addChild(this._viewport);
4060
- // Mask
4061
- this._mask = new Graphics();
4062
- this._mask.roundRect(0, 0, config.width, config.height, this._config.borderRadius)
4063
- .fill(0xffffff);
4064
- this.addChild(this._mask);
4065
- this._viewport.mask = this._mask;
4066
- // Scrollbars
4067
- if (this._config.showScrollbar) {
4068
- if (this._config.direction !== 'horizontal') {
4069
- this._scrollbarV = new Graphics();
4070
- this._scrollbarV.alpha = 0;
4071
- this.addChild(this._scrollbarV);
4072
- }
4073
- if (this._config.direction !== 'vertical') {
4074
- this._scrollbarH = new Graphics();
4075
- this._scrollbarH.alpha = 0;
4076
- this.addChild(this._scrollbarH);
4077
- }
3904
+ options.background = config.backgroundColor;
4078
3905
  }
4079
- // Interaction
4080
- this.eventMode = 'static';
4081
- this.cursor = 'grab';
4082
- this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
4083
- this.on('pointerdown', this.onPointerDown);
4084
- this.on('pointermove', this.onPointerMove);
4085
- this.on('pointerup', this.onPointerUp);
4086
- this.on('pointerupoutside', this.onPointerUp);
4087
- this.on('wheel', this.onWheel);
3906
+ super(options);
3907
+ this._scrollConfig = config;
4088
3908
  }
4089
3909
  /** Set scrollable content. Replaces any existing content. */
4090
3910
  setContent(content) {
4091
- if (this._content) {
4092
- this._viewport.removeChild(this._content);
3911
+ // Remove existing items
3912
+ const existing = this.items;
3913
+ if (existing.length > 0) {
3914
+ for (let i = existing.length - 1; i >= 0; i--) {
3915
+ this.removeItem(i);
3916
+ }
4093
3917
  }
4094
- this._content = content;
4095
- this._viewport.addChild(content);
4096
- this._scrollX = 0;
4097
- this._scrollY = 0;
4098
- this.applyScroll();
4099
- }
4100
- /** Get the content container */
4101
- get content() {
4102
- return this._content;
4103
- }
4104
- /** Scroll to a specific position (in content coordinates) */
4105
- scrollTo(x, y, animate = true) {
4106
- if (!animate) {
4107
- this._scrollX = x;
4108
- this._scrollY = y;
4109
- this.clampScroll();
4110
- this.applyScroll();
4111
- return;
3918
+ // Add all children from the content container
3919
+ const children = [...content.children];
3920
+ if (children.length > 0) {
3921
+ this.addItems(children);
4112
3922
  }
4113
- this.animateScrollTo(x, y);
3923
+ }
3924
+ /** Add a single item */
3925
+ addItem(...items) {
3926
+ this.addItems(items);
3927
+ return items[0];
4114
3928
  }
4115
3929
  /** Scroll to make a specific item/child visible */
4116
3930
  scrollToItem(index) {
4117
- if (this._config.snapSize > 0) {
4118
- const pos = index * this._config.snapSize;
4119
- if (this._config.direction === 'horizontal') {
4120
- this.scrollTo(pos, this._scrollY);
4121
- }
4122
- else {
4123
- this.scrollTo(this._scrollX, pos);
4124
- }
4125
- }
3931
+ this.scrollTo(index);
4126
3932
  }
4127
3933
  /** Current scroll position */
4128
3934
  get scrollPosition() {
4129
- return { x: this._scrollX, y: this._scrollY };
4130
- }
4131
- /** Resize the scroll viewport */
4132
- resize(width, height) {
4133
- this._config.width = width;
4134
- this._config.height = height;
4135
- // Redraw mask and background
4136
- this._mask.clear();
4137
- this._mask.roundRect(0, 0, width, height, this._config.borderRadius).fill(0xffffff);
4138
- this._bg.clear();
4139
- this.hitArea = { contains: (x, y) => x >= 0 && x <= width && y >= 0 && y <= height };
4140
- this.clampScroll();
4141
- this.applyScroll();
4142
- }
4143
- /** Destroy and clean up */
4144
- destroy(options) {
4145
- this.stopAnimation();
4146
- if (this._scrollbarFadeTimeout !== null) {
4147
- clearTimeout(this._scrollbarFadeTimeout);
4148
- }
4149
- this.off('pointerdown', this.onPointerDown);
4150
- this.off('pointermove', this.onPointerMove);
4151
- this.off('pointerup', this.onPointerUp);
4152
- this.off('pointerupoutside', this.onPointerUp);
4153
- this.off('wheel', this.onWheel);
4154
- super.destroy(options);
4155
- }
4156
- // ─── Scroll mechanics ─────────────────────────────────
4157
- get contentWidth() {
4158
- if (!this._content)
4159
- return 0;
4160
- const bounds = this._content.getBounds();
4161
- return bounds.width;
4162
- }
4163
- get contentHeight() {
4164
- if (!this._content)
4165
- return 0;
4166
- const bounds = this._content.getBounds();
4167
- return bounds.height;
4168
- }
4169
- get maxScrollX() {
4170
- return Math.max(0, this.contentWidth - this._config.width);
4171
- }
4172
- get maxScrollY() {
4173
- return Math.max(0, this.contentHeight - this._config.height);
4174
- }
4175
- canScrollX() {
4176
- return this._config.direction === 'horizontal' || this._config.direction === 'both';
4177
- }
4178
- canScrollY() {
4179
- return this._config.direction === 'vertical' || this._config.direction === 'both';
4180
- }
4181
- clampScroll() {
4182
- if (this.canScrollX()) {
4183
- this._scrollX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
4184
- }
4185
- else {
4186
- this._scrollX = 0;
4187
- }
4188
- if (this.canScrollY()) {
4189
- this._scrollY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
4190
- }
4191
- else {
4192
- this._scrollY = 0;
4193
- }
4194
- }
4195
- applyScroll() {
4196
- if (!this._content)
4197
- return;
4198
- this._content.x = -this._scrollX;
4199
- this._content.y = -this._scrollY;
4200
- this.updateScrollbars();
4201
- }
4202
- // ─── Input handlers ────────────────────────────────────
4203
- onPointerDown = (e) => {
4204
- this._isDragging = true;
4205
- this._isAnimating = false;
4206
- this.stopAnimation();
4207
- this.cursor = 'grabbing';
4208
- const local = e.getLocalPosition(this);
4209
- this._dragStart = { x: local.x, y: local.y };
4210
- this._scrollStart = { x: this._scrollX, y: this._scrollY };
4211
- this._lastDragPos = { x: local.x, y: local.y };
4212
- this._lastDragTime = Date.now();
4213
- this._velocityX = 0;
4214
- this._velocityY = 0;
4215
- this.showScrollbars();
4216
- };
4217
- onPointerMove = (e) => {
4218
- if (!this._isDragging)
4219
- return;
4220
- const local = e.getLocalPosition(this);
4221
- const dx = local.x - this._dragStart.x;
4222
- const dy = local.y - this._dragStart.y;
4223
- const now = Date.now();
4224
- const dt = Math.max(1, now - this._lastDragTime);
4225
- // Calculate velocity for inertia
4226
- this._velocityX = (local.x - this._lastDragPos.x) / dt * 16; // normalize to ~60fps
4227
- this._velocityY = (local.y - this._lastDragPos.y) / dt * 16;
4228
- this._lastDragPos = { x: local.x, y: local.y };
4229
- this._lastDragTime = now;
4230
- // Apply scroll with elasticity for overscroll
4231
- let newX = this._scrollStart.x - dx;
4232
- let newY = this._scrollStart.y - dy;
4233
- const elasticity = this._config.elasticity;
4234
- if (this.canScrollX()) {
4235
- if (newX < 0)
4236
- newX *= elasticity;
4237
- else if (newX > this.maxScrollX)
4238
- newX = this.maxScrollX + (newX - this.maxScrollX) * elasticity;
4239
- this._scrollX = newX;
4240
- }
4241
- if (this.canScrollY()) {
4242
- if (newY < 0)
4243
- newY *= elasticity;
4244
- else if (newY > this.maxScrollY)
4245
- newY = this.maxScrollY + (newY - this.maxScrollY) * elasticity;
4246
- this._scrollY = newY;
4247
- }
4248
- this.applyScroll();
4249
- };
4250
- onPointerUp = () => {
4251
- if (!this._isDragging)
4252
- return;
4253
- this._isDragging = false;
4254
- this.cursor = 'grab';
4255
- // Start inertia
4256
- if (Math.abs(this._velocityX) > 0.5 || Math.abs(this._velocityY) > 0.5) {
4257
- this.startInertia();
4258
- }
4259
- else {
4260
- this.snapAndBounce();
4261
- }
4262
- };
4263
- onWheel = (e) => {
4264
- e.preventDefault?.();
4265
- const delta = e.deltaY ?? 0;
4266
- const deltaX = e.deltaX ?? 0;
4267
- if (this.canScrollY()) {
4268
- this._scrollY += delta * 0.5;
4269
- }
4270
- if (this.canScrollX()) {
4271
- this._scrollX += deltaX * 0.5;
4272
- }
4273
- this.clampScroll();
4274
- this.applyScroll();
4275
- this.showScrollbars();
4276
- this.scheduleScrollbarFade();
4277
- };
4278
- // ─── Inertia & snap ───────────────────────────────────
4279
- startInertia() {
4280
- this._isAnimating = true;
4281
- const tick = () => {
4282
- if (!this._isAnimating)
4283
- return;
4284
- this._velocityX *= this._config.inertia;
4285
- this._velocityY *= this._config.inertia;
4286
- if (this.canScrollX())
4287
- this._scrollX -= this._velocityX;
4288
- if (this.canScrollY())
4289
- this._scrollY -= this._velocityY;
4290
- // Bounce back if overscrolled
4291
- let bounced = false;
4292
- if (this.canScrollX()) {
4293
- if (this._scrollX < 0) {
4294
- this._scrollX *= 0.8;
4295
- bounced = true;
4296
- }
4297
- else if (this._scrollX > this.maxScrollX) {
4298
- this._scrollX = this.maxScrollX + (this._scrollX - this.maxScrollX) * 0.8;
4299
- bounced = true;
4300
- }
4301
- }
4302
- if (this.canScrollY()) {
4303
- if (this._scrollY < 0) {
4304
- this._scrollY *= 0.8;
4305
- bounced = true;
4306
- }
4307
- else if (this._scrollY > this.maxScrollY) {
4308
- this._scrollY = this.maxScrollY + (this._scrollY - this.maxScrollY) * 0.8;
4309
- bounced = true;
4310
- }
4311
- }
4312
- this.applyScroll();
4313
- const speed = Math.abs(this._velocityX) + Math.abs(this._velocityY);
4314
- if (speed < 0.1 && !bounced) {
4315
- this._isAnimating = false;
4316
- this.snapAndBounce();
4317
- return;
4318
- }
4319
- this._animationFrame = requestAnimationFrame(tick);
4320
- };
4321
- this._animationFrame = requestAnimationFrame(tick);
4322
- }
4323
- snapAndBounce() {
4324
- // Clamp first
4325
- let targetX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
4326
- let targetY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
4327
- // Snap
4328
- if (this._config.snapSize > 0) {
4329
- if (this.canScrollY()) {
4330
- targetY = Math.round(targetY / this._config.snapSize) * this._config.snapSize;
4331
- targetY = Math.max(0, Math.min(targetY, this.maxScrollY));
4332
- }
4333
- if (this.canScrollX()) {
4334
- targetX = Math.round(targetX / this._config.snapSize) * this._config.snapSize;
4335
- targetX = Math.max(0, Math.min(targetX, this.maxScrollX));
4336
- }
4337
- }
4338
- if (Math.abs(targetX - this._scrollX) < 0.5 && Math.abs(targetY - this._scrollY) < 0.5) {
4339
- this._scrollX = targetX;
4340
- this._scrollY = targetY;
4341
- this.applyScroll();
4342
- this.scheduleScrollbarFade();
4343
- return;
4344
- }
4345
- this.animateScrollTo(targetX, targetY);
4346
- }
4347
- animateScrollTo(targetX, targetY) {
4348
- this._isAnimating = true;
4349
- const startX = this._scrollX;
4350
- const startY = this._scrollY;
4351
- const startTime = Date.now();
4352
- const duration = 300;
4353
- const tick = () => {
4354
- if (!this._isAnimating)
4355
- return;
4356
- const elapsed = Date.now() - startTime;
4357
- const t = Math.min(elapsed / duration, 1);
4358
- // easeOutCubic
4359
- const eased = 1 - Math.pow(1 - t, 3);
4360
- this._scrollX = startX + (targetX - startX) * eased;
4361
- this._scrollY = startY + (targetY - startY) * eased;
4362
- this.applyScroll();
4363
- if (t < 1) {
4364
- this._animationFrame = requestAnimationFrame(tick);
4365
- }
4366
- else {
4367
- this._isAnimating = false;
4368
- this.scheduleScrollbarFade();
4369
- }
4370
- };
4371
- this._animationFrame = requestAnimationFrame(tick);
4372
- }
4373
- stopAnimation() {
4374
- this._isAnimating = false;
4375
- if (this._animationFrame !== null) {
4376
- cancelAnimationFrame(this._animationFrame);
4377
- this._animationFrame = null;
4378
- }
4379
- }
4380
- // ─── Scrollbars ────────────────────────────────────────
4381
- updateScrollbars() {
4382
- const { width, height, scrollbarWidth, scrollbarColor, scrollbarAlpha } = this._config;
4383
- if (this._scrollbarV && this.canScrollY() && this.contentHeight > height) {
4384
- const ratio = height / this.contentHeight;
4385
- const barH = Math.max(20, height * ratio);
4386
- const barY = (this._scrollY / this.maxScrollY) * (height - barH);
4387
- this._scrollbarV.clear();
4388
- this._scrollbarV.roundRect(width - scrollbarWidth - 2, Math.max(0, barY), scrollbarWidth, barH, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
4389
- }
4390
- if (this._scrollbarH && this.canScrollX() && this.contentWidth > width) {
4391
- const ratio = width / this.contentWidth;
4392
- const barW = Math.max(20, width * ratio);
4393
- const barX = (this._scrollX / this.maxScrollX) * (width - barW);
4394
- this._scrollbarH.clear();
4395
- this._scrollbarH.roundRect(Math.max(0, barX), height - scrollbarWidth - 2, barW, scrollbarWidth, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
4396
- }
4397
- }
4398
- showScrollbars() {
4399
- if (this._scrollbarV)
4400
- this._scrollbarV.alpha = 1;
4401
- if (this._scrollbarH)
4402
- this._scrollbarH.alpha = 1;
4403
- }
4404
- scheduleScrollbarFade() {
4405
- if (this._scrollbarFadeTimeout !== null) {
4406
- clearTimeout(this._scrollbarFadeTimeout);
4407
- }
4408
- this._scrollbarFadeTimeout = window.setTimeout(() => {
4409
- this.fadeScrollbars();
4410
- }, 1000);
4411
- }
4412
- fadeScrollbars() {
4413
- const duration = 300;
4414
- const startTime = Date.now();
4415
- const startAlphaV = this._scrollbarV?.alpha ?? 0;
4416
- const startAlphaH = this._scrollbarH?.alpha ?? 0;
4417
- const tick = () => {
4418
- const t = Math.min((Date.now() - startTime) / duration, 1);
4419
- if (this._scrollbarV)
4420
- this._scrollbarV.alpha = startAlphaV * (1 - t);
4421
- if (this._scrollbarH)
4422
- this._scrollbarH.alpha = startAlphaH * (1 - t);
4423
- if (t < 1)
4424
- requestAnimationFrame(tick);
4425
- };
4426
- requestAnimationFrame(tick);
3935
+ return { x: this.scrollX, y: this.scrollY };
4427
3936
  }
4428
3937
  }
4429
3938