@energy8platform/game-engine 0.10.11 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +185 -74
  2. package/dist/index.cjs.js +1280 -296
  3. package/dist/index.cjs.js.map +1 -1
  4. package/dist/index.d.ts +362 -46
  5. package/dist/index.esm.js +1281 -298
  6. package/dist/index.esm.js.map +1 -1
  7. package/dist/lua.cjs.js +8 -18
  8. package/dist/lua.cjs.js.map +1 -1
  9. package/dist/lua.d.ts +0 -2
  10. package/dist/lua.esm.js +8 -18
  11. package/dist/lua.esm.js.map +1 -1
  12. package/dist/react.cjs.js +2372 -11
  13. package/dist/react.cjs.js.map +1 -1
  14. package/dist/react.d.ts +17 -6
  15. package/dist/react.esm.js +2372 -12
  16. package/dist/react.esm.js.map +1 -1
  17. package/dist/ui.cjs.js +1553 -632
  18. package/dist/ui.cjs.js.map +1 -1
  19. package/dist/ui.d.ts +374 -46
  20. package/dist/ui.esm.js +1553 -634
  21. package/dist/ui.esm.js.map +1 -1
  22. package/dist/vite.cjs.js +1 -11
  23. package/dist/vite.cjs.js.map +1 -1
  24. package/dist/vite.d.ts +1 -1
  25. package/dist/vite.esm.js +1 -11
  26. package/dist/vite.esm.js.map +1 -1
  27. package/package.json +3 -18
  28. package/src/index.ts +3 -3
  29. package/src/lua/LuaEngine.ts +8 -18
  30. package/src/react/applyProps.ts +86 -0
  31. package/src/react/extendAll.ts +27 -6
  32. package/src/react/index.ts +1 -1
  33. package/src/react/jsx.d.ts +222 -0
  34. package/src/react/reconciler.ts +22 -5
  35. package/src/ui/BalanceDisplay.ts +31 -38
  36. package/src/ui/Button.ts +217 -53
  37. package/src/ui/FlexContainer.ts +479 -0
  38. package/src/ui/Label.ts +13 -0
  39. package/src/ui/Layout.ts +86 -87
  40. package/src/ui/Modal.ts +11 -1
  41. package/src/ui/Panel.ts +108 -36
  42. package/src/ui/ProgressBar.ts +85 -31
  43. package/src/ui/ScrollContainer.ts +397 -45
  44. package/src/ui/Toast.ts +47 -17
  45. package/src/ui/WinDisplay.ts +51 -39
  46. package/src/ui/index.ts +5 -11
  47. package/src/ui/view.ts +28 -0
  48. package/src/vite/index.ts +1 -11
package/dist/index.esm.js CHANGED
@@ -1,8 +1,6 @@
1
- import { Ticker, Assets, Container, Text, Application, AnimatedSprite, Texture, Graphics, NineSliceSprite } from 'pixi.js';
1
+ import { Ticker, Assets, Container, Text, Application, AnimatedSprite, Texture, Sprite, Graphics, NineSliceSprite } from 'pixi.js';
2
2
  import { CasinoGameSDK, Bridge } from '@energy8platform/game-sdk';
3
3
  export { BridgeDestroyedError, BridgeNotReadyError, SDKError, TimeoutError } from '@energy8platform/game-sdk';
4
- import { FancyButton, ProgressBar as ProgressBar$1, ScrollBox } from '@pixi/ui';
5
- import { LayoutContainer } from '@pixi/layout/components';
6
4
 
7
5
  // ─── Scale Modes ───────────────────────────────────────────
8
6
  var ScaleMode;
@@ -2909,6 +2907,407 @@ class SpriteAnimation {
2909
2907
  }
2910
2908
  }
2911
2909
 
2910
+ // ─── Helpers ─────────────────────────────────────────────
2911
+ function normalizePadding(p) {
2912
+ return typeof p === 'number' ? [p, p, p, p] : p;
2913
+ }
2914
+ /** Measure a child's size and bounds offset for layout purposes */
2915
+ function measureChild(child) {
2916
+ const cfg = child._flexConfig;
2917
+ if (cfg?.layoutWidth !== undefined && cfg?.layoutHeight !== undefined) {
2918
+ return { w: cfg.layoutWidth, h: cfg.layoutHeight, ox: 0, oy: 0 };
2919
+ }
2920
+ // For FlexContainers, use their explicit size if set
2921
+ if (child instanceof FlexContainer) {
2922
+ const fc = child;
2923
+ if (fc._explicitWidth > 0 && fc._explicitHeight > 0) {
2924
+ return { w: fc._explicitWidth, h: fc._explicitHeight, ox: 0, oy: 0 };
2925
+ }
2926
+ }
2927
+ // Use localBounds to get the true visual extent and origin offset.
2928
+ // This handles children with non-zero anchors (e.g. Button, Label with centered text).
2929
+ const bounds = child.getLocalBounds();
2930
+ const w = cfg?.layoutWidth ?? bounds.width;
2931
+ const h = cfg?.layoutHeight ?? bounds.height;
2932
+ return { w, h, ox: bounds.x, oy: bounds.y };
2933
+ }
2934
+ function layoutLine(items, isRow, mainSize, justify, align, gap, crossOffset, crossSize) {
2935
+ if (items.length === 0)
2936
+ return;
2937
+ // Compute total fixed main size and flex grow total
2938
+ let totalFixed = 0;
2939
+ let totalGrow = 0;
2940
+ for (const item of items) {
2941
+ const grow = item.child._flexConfig?.flexGrow ?? 0;
2942
+ if (grow > 0) {
2943
+ totalGrow += grow;
2944
+ }
2945
+ else {
2946
+ totalFixed += isRow ? item.w : item.h;
2947
+ }
2948
+ }
2949
+ const totalGap = gap * (items.length - 1);
2950
+ const availableForFlex = Math.max(0, mainSize - totalFixed - totalGap);
2951
+ // Resolve flex sizes
2952
+ if (totalGrow > 0) {
2953
+ for (const item of items) {
2954
+ const grow = item.child._flexConfig?.flexGrow ?? 0;
2955
+ if (grow > 0) {
2956
+ const flexSize = (grow / totalGrow) * availableForFlex;
2957
+ if (isRow) {
2958
+ item.w = flexSize;
2959
+ item.child.width = flexSize;
2960
+ }
2961
+ else {
2962
+ item.h = flexSize;
2963
+ item.child.height = flexSize;
2964
+ }
2965
+ }
2966
+ }
2967
+ }
2968
+ // Calculate total main size after flex
2969
+ let totalMain = totalGap;
2970
+ for (const item of items) {
2971
+ totalMain += isRow ? item.w : item.h;
2972
+ }
2973
+ // Justify: compute starting offset and extra spacing
2974
+ let mainOffset = 0;
2975
+ let extraGap = 0;
2976
+ switch (justify) {
2977
+ case 'start':
2978
+ break;
2979
+ case 'center':
2980
+ mainOffset = Math.max(0, (mainSize - totalMain) / 2);
2981
+ break;
2982
+ case 'end':
2983
+ mainOffset = Math.max(0, mainSize - totalMain);
2984
+ break;
2985
+ case 'space-between':
2986
+ if (items.length > 1) {
2987
+ extraGap = Math.max(0, (mainSize - totalMain + totalGap) / (items.length - 1)) - gap;
2988
+ }
2989
+ break;
2990
+ case 'space-around':
2991
+ if (items.length > 0) {
2992
+ const totalSpace = Math.max(0, mainSize - totalMain + totalGap);
2993
+ const segment = totalSpace / items.length;
2994
+ mainOffset = segment / 2;
2995
+ extraGap = segment - gap;
2996
+ }
2997
+ break;
2998
+ }
2999
+ // Position each item
3000
+ let pos = mainOffset;
3001
+ for (const item of items) {
3002
+ const mainDim = isRow ? item.w : item.h;
3003
+ const crossDim = isRow ? item.h : item.w;
3004
+ // Cross-axis alignment
3005
+ let crossPos = crossOffset;
3006
+ switch (align) {
3007
+ case 'start':
3008
+ break;
3009
+ case 'center':
3010
+ crossPos += (crossSize - crossDim) / 2;
3011
+ break;
3012
+ case 'end':
3013
+ crossPos += crossSize - crossDim;
3014
+ break;
3015
+ case 'stretch':
3016
+ if (isRow) {
3017
+ item.child.height = crossSize;
3018
+ }
3019
+ else {
3020
+ item.child.width = crossSize;
3021
+ }
3022
+ break;
3023
+ }
3024
+ // Compensate for local bounds offset (e.g. centered anchors)
3025
+ if (isRow) {
3026
+ item.child.x = pos - item.ox;
3027
+ item.child.y = crossPos - item.oy;
3028
+ }
3029
+ else {
3030
+ item.child.x = crossPos - item.ox;
3031
+ item.child.y = pos - item.oy;
3032
+ }
3033
+ pos += mainDim + gap + extraGap;
3034
+ }
3035
+ }
3036
+ // ─── FlexContainer ───────────────────────────────────────
3037
+ /**
3038
+ * Lightweight flexbox-like layout container for PixiJS.
3039
+ *
3040
+ * Supports row/column direction, justify/align, gap, padding, wrapping,
3041
+ * and flex-grow distribution. Zero external dependencies.
3042
+ *
3043
+ * @example
3044
+ * ```ts
3045
+ * const toolbar = new FlexContainer({
3046
+ * direction: 'row',
3047
+ * justifyContent: 'space-between',
3048
+ * alignItems: 'center',
3049
+ * gap: 16,
3050
+ * padding: 12,
3051
+ * });
3052
+ *
3053
+ * toolbar.addFlexChild(button1);
3054
+ * toolbar.addFlexChild(button2);
3055
+ * toolbar.resize(800, 60);
3056
+ * ```
3057
+ */
3058
+ class FlexContainer extends Container {
3059
+ __uiComponent = true;
3060
+ _config;
3061
+ _padding;
3062
+ _maxWidth;
3063
+ _maxHeight;
3064
+ /** @internal */ _explicitWidth;
3065
+ /** @internal */ _explicitHeight;
3066
+ _layoutChildren = [];
3067
+ _layoutDirty = true;
3068
+ constructor(config = {}) {
3069
+ super();
3070
+ this._config = {
3071
+ direction: config.direction ?? 'row',
3072
+ justifyContent: config.justifyContent ?? 'start',
3073
+ alignItems: config.alignItems ?? 'start',
3074
+ gap: config.gap ?? 0,
3075
+ flexWrap: config.flexWrap ?? false,
3076
+ };
3077
+ this._padding = normalizePadding(config.padding ?? 0);
3078
+ this._maxWidth = config.maxWidth ?? Infinity;
3079
+ this._maxHeight = config.maxHeight ?? Infinity;
3080
+ this._explicitWidth = config.width ?? 0;
3081
+ this._explicitHeight = config.height ?? 0;
3082
+ }
3083
+ // ─── Public API ──────────────────────────────────────
3084
+ /** Add a child with optional flex config. Also registers in flex layout. */
3085
+ addFlexChild(child, flexConfig) {
3086
+ if (flexConfig)
3087
+ child._flexConfig = flexConfig;
3088
+ if (!this._layoutChildren.includes(child)) {
3089
+ this._layoutChildren.push(child);
3090
+ this._layoutDirty = true;
3091
+ }
3092
+ super.addChild(child);
3093
+ return this;
3094
+ }
3095
+ /** Remove a child from flex layout and display list */
3096
+ removeFlexChild(child) {
3097
+ const idx = this._layoutChildren.indexOf(child);
3098
+ if (idx !== -1) {
3099
+ this._layoutChildren.splice(idx, 1);
3100
+ this._layoutDirty = true;
3101
+ }
3102
+ super.removeChild(child);
3103
+ return this;
3104
+ }
3105
+ /** Remove all flex children */
3106
+ clearFlexChildren() {
3107
+ for (const child of this._layoutChildren) {
3108
+ super.removeChild(child);
3109
+ }
3110
+ this._layoutChildren.length = 0;
3111
+ this._layoutDirty = true;
3112
+ return this;
3113
+ }
3114
+ /**
3115
+ * Override addChild so children automatically participate in flex layout.
3116
+ * This enables declarative usage from React JSX.
3117
+ */
3118
+ addChild(...children) {
3119
+ for (const child of children) {
3120
+ if (!this._layoutChildren.includes(child)) {
3121
+ this._layoutChildren.push(child);
3122
+ this._layoutDirty = true;
3123
+ }
3124
+ }
3125
+ const result = super.addChild(...children);
3126
+ if (this._layoutDirty)
3127
+ this.updateLayout();
3128
+ return result;
3129
+ }
3130
+ removeChild(...children) {
3131
+ for (const child of children) {
3132
+ const idx = this._layoutChildren.indexOf(child);
3133
+ if (idx !== -1) {
3134
+ this._layoutChildren.splice(idx, 1);
3135
+ this._layoutDirty = true;
3136
+ }
3137
+ }
3138
+ return super.removeChild(...children);
3139
+ }
3140
+ /** Get all flex layout children (read-only) */
3141
+ get flexChildren() {
3142
+ return this._layoutChildren;
3143
+ }
3144
+ /** Update the container size and recalculate layout */
3145
+ resize(width, height) {
3146
+ this._explicitWidth = width;
3147
+ this._explicitHeight = height;
3148
+ this._layoutDirty = true;
3149
+ this.updateLayout();
3150
+ }
3151
+ /** Update layout direction */
3152
+ setDirection(direction) {
3153
+ this._config.direction = direction;
3154
+ this._layoutDirty = true;
3155
+ }
3156
+ /** Update justifyContent */
3157
+ setJustifyContent(justify) {
3158
+ this._config.justifyContent = justify;
3159
+ this._layoutDirty = true;
3160
+ }
3161
+ /** Update alignItems */
3162
+ setAlignItems(align) {
3163
+ this._config.alignItems = align;
3164
+ this._layoutDirty = true;
3165
+ }
3166
+ /** Update gap */
3167
+ setGap(gap) {
3168
+ this._config.gap = gap;
3169
+ this._layoutDirty = true;
3170
+ }
3171
+ /** Update padding */
3172
+ setPadding(padding) {
3173
+ this._padding = normalizePadding(padding);
3174
+ this._layoutDirty = true;
3175
+ }
3176
+ /**
3177
+ * Recalculate and apply layout positions for all children.
3178
+ * Called automatically by `resize()`. Call manually after
3179
+ * adding/removing children without resize.
3180
+ */
3181
+ updateLayout() {
3182
+ this._layoutDirty = false;
3183
+ const { direction, justifyContent, alignItems, gap, flexWrap } = this._config;
3184
+ const [pt, pr, pb, pl] = this._padding;
3185
+ const isRow = direction === 'row';
3186
+ const contentW = this._explicitWidth > 0 ? this._explicitWidth - pl - pr : Infinity;
3187
+ const contentH = this._explicitHeight > 0 ? this._explicitHeight - pt - pb : Infinity;
3188
+ const mainLimit = isRow ? contentW : contentH;
3189
+ const crossLimit = isRow ? contentH : contentW;
3190
+ // Measure children
3191
+ const measured = this._layoutChildren.map((child) => {
3192
+ const { w, h, ox, oy } = measureChild(child);
3193
+ return { child, w, h, ox, oy };
3194
+ });
3195
+ // Split into lines (if wrapping)
3196
+ const lines = [];
3197
+ if (flexWrap && mainLimit < Infinity) {
3198
+ let currentLine = [];
3199
+ let lineMain = 0;
3200
+ for (const item of measured) {
3201
+ const itemMain = isRow ? item.w : item.h;
3202
+ const wouldBe = lineMain + (currentLine.length > 0 ? gap : 0) + itemMain;
3203
+ if (currentLine.length > 0 && wouldBe > mainLimit) {
3204
+ lines.push(currentLine);
3205
+ currentLine = [item];
3206
+ lineMain = itemMain;
3207
+ }
3208
+ else {
3209
+ currentLine.push(item);
3210
+ lineMain = wouldBe;
3211
+ }
3212
+ }
3213
+ if (currentLine.length > 0)
3214
+ lines.push(currentLine);
3215
+ }
3216
+ else {
3217
+ lines.push(measured);
3218
+ }
3219
+ // Compute cross size per line
3220
+ const lineCrossSizes = lines.map((line) => {
3221
+ let maxCross = 0;
3222
+ for (const item of line) {
3223
+ const cross = isRow ? item.h : item.w;
3224
+ if (cross > maxCross)
3225
+ maxCross = cross;
3226
+ }
3227
+ return maxCross;
3228
+ });
3229
+ // Layout each line
3230
+ let crossOffset = isRow ? pt : pl;
3231
+ for (let i = 0; i < lines.length; i++) {
3232
+ const line = lines[i];
3233
+ const lineCross = lineCrossSizes[i];
3234
+ const mainStart = isRow ? pl : pt;
3235
+ // Offset items by padding
3236
+ const tempItems = line.map((item) => ({ ...item }));
3237
+ layoutLine(tempItems, isRow, mainLimit < Infinity ? mainLimit : 0, mainLimit < Infinity ? justifyContent : 'start', alignItems, gap, crossOffset, crossLimit < Infinity ? Math.min(lineCross, crossLimit) : lineCross);
3238
+ // Apply main-axis padding offset
3239
+ for (const item of tempItems) {
3240
+ const origChild = line.find((l) => l.child === item.child);
3241
+ origChild.child.x = item.child.x + (isRow ? mainStart : 0);
3242
+ origChild.child.y = item.child.y + (isRow ? 0 : mainStart);
3243
+ }
3244
+ crossOffset += lineCross + gap;
3245
+ }
3246
+ }
3247
+ /** Computed content size (after layout) */
3248
+ getContentSize() {
3249
+ if (this._layoutDirty)
3250
+ this.updateLayout();
3251
+ let maxX = 0;
3252
+ let maxY = 0;
3253
+ for (const child of this._layoutChildren) {
3254
+ const { w, h } = measureChild(child);
3255
+ maxX = Math.max(maxX, child.x + w);
3256
+ maxY = Math.max(maxY, child.y + h);
3257
+ }
3258
+ const [, pr, pb] = this._padding;
3259
+ return { width: maxX + pr, height: maxY + pb };
3260
+ }
3261
+ /** React reconciler update hook — applies changed config props */
3262
+ updateConfig(changed) {
3263
+ if ('direction' in changed)
3264
+ this.setDirection(changed.direction);
3265
+ if ('justifyContent' in changed)
3266
+ this.setJustifyContent(changed.justifyContent);
3267
+ if ('alignItems' in changed)
3268
+ this.setAlignItems(changed.alignItems);
3269
+ if ('gap' in changed)
3270
+ this.setGap(changed.gap);
3271
+ if ('padding' in changed)
3272
+ this.setPadding(changed.padding);
3273
+ if ('flexWrap' in changed) {
3274
+ this._config.flexWrap = changed.flexWrap;
3275
+ this._layoutDirty = true;
3276
+ }
3277
+ if ('width' in changed || 'height' in changed) {
3278
+ this.resize(changed.width ?? this._explicitWidth, changed.height ?? this._explicitHeight);
3279
+ return; // resize calls updateLayout
3280
+ }
3281
+ if (this._layoutDirty)
3282
+ this.updateLayout();
3283
+ }
3284
+ destroy(options) {
3285
+ this._layoutChildren.length = 0;
3286
+ super.destroy(options);
3287
+ }
3288
+ }
3289
+
3290
+ /**
3291
+ * Resolve a ViewInput to a Container instance.
3292
+ *
3293
+ * @example
3294
+ * ```ts
3295
+ * resolveView('btn-idle') // → Sprite.from('btn-idle')
3296
+ * resolveView(someTexture) // → new Sprite(someTexture)
3297
+ * resolveView(myCustomContainer) // → myCustomContainer (as-is)
3298
+ * resolveView(undefined) // → null
3299
+ * ```
3300
+ */
3301
+ function resolveView(input) {
3302
+ if (input == null)
3303
+ return null;
3304
+ if (typeof input === 'string')
3305
+ return Sprite.from(input);
3306
+ if (input instanceof Texture)
3307
+ return new Sprite(input);
3308
+ return input;
3309
+ }
3310
+
2912
3311
  const DEFAULT_COLORS = {
2913
3312
  default: 0xffd700,
2914
3313
  hover: 0xffe44d,
@@ -2917,33 +3316,55 @@ const DEFAULT_COLORS = {
2917
3316
  };
2918
3317
  function makeGraphicsView(w, h, radius, color) {
2919
3318
  const g = new Graphics();
2920
- g.roundRect(0, 0, w, h, radius).fill(color);
2921
- // Highlight overlay
2922
- g.roundRect(2, 2, w - 4, h * 0.45, radius).fill({ color: 0xffffff, alpha: 0.1 });
3319
+ g.roundRect(-w / 2, -h / 2, w, h, radius).fill(color);
2923
3320
  return g;
2924
3321
  }
2925
3322
  /**
2926
- * Interactive button component powered by `@pixi/ui` FancyButton.
3323
+ * Interactive button with per-state custom views and animations.
2927
3324
  *
2928
- * Supports both texture-based and Graphics-based rendering with
2929
- * per-state views, press animation, and text.
3325
+ * Each visual state accepts a `ViewInput`: texture name, Texture, or any Container
3326
+ * (Sprite, NineSliceSprite, AnimatedSprite, custom artwork, etc).
3327
+ * Falls back to colored Graphics when no custom view is provided.
2930
3328
  *
2931
3329
  * @example
2932
3330
  * ```ts
3331
+ * // Graphics-based (quick prototyping)
2933
3332
  * const btn = new Button({
2934
3333
  * width: 200, height: 60, borderRadius: 12,
2935
3334
  * colors: { default: 0x22aa22, hover: 0x33cc33 },
2936
3335
  * text: 'SPIN',
3336
+ * onPress: () => spin(),
3337
+ * });
3338
+ *
3339
+ * // Asset-based (production art)
3340
+ * const btn = new Button({
3341
+ * defaultView: 'btn-idle',
3342
+ * hoverView: 'btn-hover',
3343
+ * pressedView: 'btn-pressed',
3344
+ * disabledView: 'btn-disabled',
3345
+ * text: 'SPIN',
3346
+ * onPress: () => spin(),
2937
3347
  * });
2938
3348
  *
2939
- * btn.onPress.connect(() => console.log('Clicked!'));
2940
- * scene.container.addChild(btn);
3349
+ * // Custom Container view
3350
+ * const btn = new Button({
3351
+ * defaultView: myAnimatedSprite,
3352
+ * text: 'SPIN',
3353
+ * });
2941
3354
  * ```
2942
3355
  */
2943
- class Button extends FancyButton {
2944
- _buttonConfig;
3356
+ class Button extends Container {
3357
+ __uiComponent = true;
3358
+ _views = new Map();
3359
+ _state = 'default';
3360
+ _enabled = true;
3361
+ _config;
3362
+ _textObj = null;
3363
+ /** Press callback */
3364
+ onPress;
2945
3365
  constructor(config = {}) {
2946
- const resolvedConfig = {
3366
+ super();
3367
+ this._config = {
2947
3368
  width: config.width ?? 200,
2948
3369
  height: config.height ?? 60,
2949
3370
  borderRadius: config.borderRadius ?? 8,
@@ -2951,50 +3372,39 @@ class Button extends FancyButton {
2951
3372
  animationDuration: config.animationDuration ?? 100,
2952
3373
  ...config,
2953
3374
  };
2954
- const colorMap = { ...DEFAULT_COLORS, ...config.colors };
2955
- const { width, height, borderRadius } = resolvedConfig;
2956
- // Build FancyButton options
2957
- const options = {
2958
- anchor: 0.5,
2959
- animations: {
2960
- hover: {
2961
- props: { scale: { x: 1.03, y: 1.03 } },
2962
- duration: resolvedConfig.animationDuration,
2963
- },
2964
- pressed: {
2965
- props: { scale: { x: resolvedConfig.pressScale, y: resolvedConfig.pressScale } },
2966
- duration: resolvedConfig.animationDuration,
2967
- },
2968
- },
2969
- };
2970
- // Texture-based views
2971
- if (config.textures) {
2972
- if (config.textures.default)
2973
- options.defaultView = config.textures.default;
2974
- if (config.textures.hover)
2975
- options.hoverView = config.textures.hover;
2976
- if (config.textures.pressed)
2977
- options.pressedView = config.textures.pressed;
2978
- if (config.textures.disabled)
2979
- options.disabledView = config.textures.disabled;
2980
- }
2981
- else {
2982
- // Graphics-based views
2983
- options.defaultView = makeGraphicsView(width, height, borderRadius, colorMap.default);
2984
- options.hoverView = makeGraphicsView(width, height, borderRadius, colorMap.hover);
2985
- options.pressedView = makeGraphicsView(width, height, borderRadius, colorMap.pressed);
2986
- options.disabledView = makeGraphicsView(width, height, borderRadius, colorMap.disabled);
2987
- }
3375
+ this.onPress = config.onPress;
3376
+ this._buildViews(config);
2988
3377
  // Text
2989
3378
  if (config.text) {
2990
- options.text = config.text;
2991
- }
2992
- super(options);
2993
- this._buttonConfig = resolvedConfig;
3379
+ this._textObj = new Text({
3380
+ text: config.text,
3381
+ style: {
3382
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
3383
+ fontSize: 20,
3384
+ fill: 0xffffff,
3385
+ fontWeight: 'bold',
3386
+ ...config.textStyle,
3387
+ },
3388
+ });
3389
+ this._textObj.anchor.set(0.5);
3390
+ this.addChild(this._textObj);
3391
+ }
3392
+ // Interaction
3393
+ this.eventMode = 'static';
3394
+ this.cursor = 'pointer';
3395
+ this.on('pointerover', this._onPointerOver, this);
3396
+ this.on('pointerout', this._onPointerOut, this);
3397
+ this.on('pointerdown', this._onPointerDown, this);
3398
+ this.on('pointerup', this._onPointerUp, this);
3399
+ this.on('pointerupoutside', this._onPointerUpOutside, this);
2994
3400
  if (config.disabled) {
2995
3401
  this.enabled = false;
2996
3402
  }
2997
3403
  }
3404
+ /** Current button state */
3405
+ get state() {
3406
+ return this._state;
3407
+ }
2998
3408
  /** Enable the button */
2999
3409
  enable() {
3000
3410
  this.enabled = true;
@@ -3003,29 +3413,161 @@ class Button extends FancyButton {
3003
3413
  disable() {
3004
3414
  this.enabled = false;
3005
3415
  }
3416
+ /** Whether the button is enabled */
3417
+ get enabled() {
3418
+ return this._enabled;
3419
+ }
3420
+ set enabled(value) {
3421
+ this._enabled = value;
3422
+ this.cursor = value ? 'pointer' : 'default';
3423
+ this.eventMode = value ? 'static' : 'none';
3424
+ this._setState(value ? 'default' : 'disabled');
3425
+ }
3006
3426
  /** Whether the button is disabled */
3007
3427
  get disabled() {
3008
- return !this.enabled;
3428
+ return !this._enabled;
3429
+ }
3430
+ /** Update button text */
3431
+ set text(value) {
3432
+ if (this._textObj) {
3433
+ this._textObj.text = value;
3434
+ }
3435
+ }
3436
+ // ─── View building ──────────────────────────────────
3437
+ _buildViews(config) {
3438
+ const colorMap = { ...DEFAULT_COLORS, ...config.colors };
3439
+ const { width, height, borderRadius } = this._config;
3440
+ const stateViews = {
3441
+ default: config.defaultView,
3442
+ hover: config.hoverView,
3443
+ pressed: config.pressedView,
3444
+ disabled: config.disabledView,
3445
+ };
3446
+ const states = ['default', 'hover', 'pressed', 'disabled'];
3447
+ for (const state of states) {
3448
+ const customView = resolveView(stateViews[state]);
3449
+ const view = customView ?? makeGraphicsView(width, height, borderRadius, colorMap[state]);
3450
+ view.visible = state === 'default';
3451
+ this._views.set(state, view);
3452
+ this.addChild(view);
3453
+ }
3454
+ }
3455
+ _rebuildViews() {
3456
+ for (const [, view] of this._views) {
3457
+ this.removeChild(view);
3458
+ view.destroy();
3459
+ }
3460
+ this._views.clear();
3461
+ this._buildViews(this._config);
3462
+ // Re-insert views before text
3463
+ if (this._textObj && this._textObj.parent === this) {
3464
+ this.setChildIndex(this._textObj, this.children.length - 1);
3465
+ }
3466
+ }
3467
+ // ─── State management ───────────────────────────────
3468
+ _setState(state) {
3469
+ if (this._state === state)
3470
+ return;
3471
+ this._state = state;
3472
+ for (const [s, view] of this._views) {
3473
+ view.visible = s === state;
3474
+ }
3475
+ }
3476
+ _onPointerOver() {
3477
+ if (!this._enabled)
3478
+ return;
3479
+ this._setState('hover');
3480
+ Tween.killTweensOf(this);
3481
+ Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
3482
+ }
3483
+ _onPointerOut() {
3484
+ if (!this._enabled)
3485
+ return;
3486
+ this._setState('default');
3487
+ Tween.killTweensOf(this);
3488
+ Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
3489
+ }
3490
+ _onPointerDown() {
3491
+ if (!this._enabled)
3492
+ return;
3493
+ this._setState('pressed');
3494
+ Tween.killTweensOf(this);
3495
+ const s = this._config.pressScale;
3496
+ Tween.to(this, { 'scale.x': s, 'scale.y': s }, this._config.animationDuration, Easing.easeOutQuad);
3497
+ }
3498
+ _onPointerUp() {
3499
+ if (!this._enabled)
3500
+ return;
3501
+ this._setState('hover');
3502
+ Tween.killTweensOf(this);
3503
+ Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
3504
+ this.onPress?.();
3505
+ }
3506
+ _onPointerUpOutside() {
3507
+ if (!this._enabled)
3508
+ return;
3509
+ this._setState('default');
3510
+ Tween.killTweensOf(this);
3511
+ Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
3512
+ }
3513
+ /** React reconciler update hook */
3514
+ updateConfig(changed) {
3515
+ if ('text' in changed && this._textObj)
3516
+ this._textObj.text = changed.text;
3517
+ if ('disabled' in changed)
3518
+ this.enabled = !changed.disabled;
3519
+ if ('onPress' in changed)
3520
+ this.onPress = changed.onPress;
3521
+ const structural = [
3522
+ 'colors', 'width', 'height', 'borderRadius', 'textStyle',
3523
+ 'defaultView', 'hoverView', 'pressedView', 'disabledView',
3524
+ ];
3525
+ const needsRebuild = structural.some((k) => k in changed);
3526
+ if (needsRebuild) {
3527
+ Object.assign(this._config, changed);
3528
+ this._rebuildViews();
3529
+ }
3530
+ }
3531
+ destroy(options) {
3532
+ Tween.killTweensOf(this);
3533
+ this.off('pointerover', this._onPointerOver, this);
3534
+ this.off('pointerout', this._onPointerOut, this);
3535
+ this.off('pointerdown', this._onPointerDown, this);
3536
+ this.off('pointerup', this._onPointerUp, this);
3537
+ this.off('pointerupoutside', this._onPointerUpOutside, this);
3538
+ this._views.clear();
3539
+ this._textObj = null;
3540
+ super.destroy(options);
3009
3541
  }
3010
3542
  }
3011
3543
 
3012
- function makeBarGraphics(w, h, radius, color) {
3013
- return new Graphics().roundRect(0, 0, w, h, radius).fill(color);
3014
- }
3015
3544
  /**
3016
- * Horizontal progress bar powered by `@pixi/ui` ProgressBar.
3545
+ * Horizontal progress bar with optional custom track/fill views.
3017
3546
  *
3018
- * Provides optional smooth animated fill via per-frame `update()`.
3547
+ * Supports asset-based skinning: provide `trackView` and/or `fillView`
3548
+ * as texture names, Textures, or any Container (NineSliceSprite, custom artwork, etc).
3549
+ * Falls back to colored Graphics when no custom views are provided.
3019
3550
  *
3020
3551
  * @example
3021
3552
  * ```ts
3553
+ * // Graphics-based (quick prototyping)
3022
3554
  * const bar = new ProgressBar({ width: 300, height: 20, fillColor: 0x22cc22 });
3023
- * scene.container.addChild(bar);
3024
- * bar.progress = 0.5; // 50%
3555
+ * bar.progress = 0.5;
3556
+ *
3557
+ * // Asset-based (production art)
3558
+ * const bar = new ProgressBar({
3559
+ * width: 300, height: 20,
3560
+ * trackView: 'bar-track',
3561
+ * fillView: new NineSliceSprite({ texture: 'bar-fill', ... }),
3562
+ * });
3563
+ * bar.progress = 0.75;
3025
3564
  * ```
3026
3565
  */
3027
3566
  class ProgressBar extends Container {
3028
- _bar;
3567
+ __uiComponent = true;
3568
+ _track;
3569
+ _fill;
3570
+ _fillMask;
3029
3571
  _borderGfx;
3030
3572
  _config;
3031
3573
  _progress = 0;
@@ -3044,21 +3586,39 @@ class ProgressBar extends Container {
3044
3586
  animationSpeed: config.animationSpeed ?? 0.1,
3045
3587
  };
3046
3588
  const { width, height, borderRadius, fillColor, trackColor, borderColor, borderWidth } = this._config;
3047
- const bgGraphics = makeBarGraphics(width, height, borderRadius, trackColor);
3048
- const fillGraphics = makeBarGraphics(width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1), fillColor);
3049
- const options = {
3050
- bg: bgGraphics,
3051
- fill: fillGraphics,
3052
- fillPaddings: {
3053
- top: borderWidth,
3054
- right: borderWidth,
3055
- bottom: borderWidth,
3056
- left: borderWidth,
3057
- },
3058
- progress: 0,
3059
- };
3060
- this._bar = new ProgressBar$1(options);
3061
- this.addChild(this._bar);
3589
+ // Track background custom view or Graphics
3590
+ const customTrack = resolveView(config.trackView);
3591
+ if (customTrack) {
3592
+ customTrack.width = width;
3593
+ customTrack.height = height;
3594
+ this._track = customTrack;
3595
+ }
3596
+ else {
3597
+ const g = new Graphics();
3598
+ g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
3599
+ this._track = g;
3600
+ }
3601
+ this.addChild(this._track);
3602
+ // Fill bar — custom view or Graphics
3603
+ const customFill = resolveView(config.fillView);
3604
+ if (customFill) {
3605
+ customFill.x = borderWidth;
3606
+ customFill.y = borderWidth;
3607
+ customFill.width = width - borderWidth * 2;
3608
+ customFill.height = height - borderWidth * 2;
3609
+ this._fill = customFill;
3610
+ }
3611
+ else {
3612
+ const g = new Graphics();
3613
+ g.roundRect(borderWidth, borderWidth, width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1)).fill(fillColor);
3614
+ this._fill = g;
3615
+ }
3616
+ this.addChild(this._fill);
3617
+ // Mask for the fill (controls visible width)
3618
+ this._fillMask = new Graphics();
3619
+ this._fillMask.rect(0, 0, 0, height).fill(0xffffff);
3620
+ this.addChild(this._fillMask);
3621
+ this._fill.mask = this._fillMask;
3062
3622
  // Border overlay
3063
3623
  this._borderGfx = new Graphics();
3064
3624
  if (borderColor !== undefined && borderWidth > 0) {
@@ -3076,7 +3636,7 @@ class ProgressBar extends Container {
3076
3636
  this._progress = Math.max(0, Math.min(1, value));
3077
3637
  if (!this._config.animated) {
3078
3638
  this._displayedProgress = this._progress;
3079
- this._bar.progress = this._displayedProgress * 100;
3639
+ this.updateMask();
3080
3640
  }
3081
3641
  }
3082
3642
  /**
@@ -3087,11 +3647,26 @@ class ProgressBar extends Container {
3087
3647
  return;
3088
3648
  if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
3089
3649
  this._displayedProgress = this._progress;
3650
+ this.updateMask();
3090
3651
  return;
3091
3652
  }
3092
3653
  this._displayedProgress +=
3093
3654
  (this._progress - this._displayedProgress) * this._config.animationSpeed;
3094
- this._bar.progress = this._displayedProgress * 100;
3655
+ this.updateMask();
3656
+ }
3657
+ /** React reconciler update hook */
3658
+ updateConfig(changed) {
3659
+ if ('progress' in changed)
3660
+ this.progress = changed.progress;
3661
+ if ('animated' in changed)
3662
+ this._config.animated = changed.animated;
3663
+ if ('animationSpeed' in changed)
3664
+ this._config.animationSpeed = changed.animationSpeed;
3665
+ }
3666
+ updateMask() {
3667
+ const w = this._config.width * this._displayedProgress;
3668
+ this._fillMask.clear();
3669
+ this._fillMask.rect(0, 0, w, this._config.height).fill(0xffffff);
3095
3670
  }
3096
3671
  }
3097
3672
 
@@ -3109,6 +3684,7 @@ class ProgressBar extends Container {
3109
3684
  * ```
3110
3685
  */
3111
3686
  class Label extends Container {
3687
+ __uiComponent = true;
3112
3688
  _text;
3113
3689
  _maxWidth;
3114
3690
  _autoFit;
@@ -3175,6 +3751,21 @@ class Label extends Container {
3175
3751
  maximumFractionDigits: decimals,
3176
3752
  }).format(value);
3177
3753
  }
3754
+ /** React reconciler update hook */
3755
+ updateConfig(changed) {
3756
+ if ('text' in changed)
3757
+ this.text = changed.text;
3758
+ if ('maxWidth' in changed)
3759
+ this.maxWidth = changed.maxWidth;
3760
+ if ('autoFit' in changed) {
3761
+ this._autoFit = changed.autoFit;
3762
+ this.fitText();
3763
+ }
3764
+ if ('style' in changed && typeof changed.style === 'object') {
3765
+ Object.assign(this._text.style, changed.style);
3766
+ this.fitText();
3767
+ }
3768
+ }
3178
3769
  fitText() {
3179
3770
  if (!this._autoFit || this._maxWidth === Infinity)
3180
3771
  return;
@@ -3187,10 +3778,10 @@ class Label extends Container {
3187
3778
  }
3188
3779
 
3189
3780
  /**
3190
- * Background panel powered by `@pixi/layout` LayoutContainer.
3781
+ * Background panel with optional flexbox content layout.
3191
3782
  *
3192
3783
  * Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
3193
- * Children added to `content` participate in flexbox layout automatically.
3784
+ * Children added via `addContent()` participate in flex layout automatically.
3194
3785
  *
3195
3786
  * @example
3196
3787
  * ```ts
@@ -3205,9 +3796,14 @@ class Label extends Container {
3205
3796
  * });
3206
3797
  * ```
3207
3798
  */
3208
- class Panel extends LayoutContainer {
3799
+ class Panel extends Container {
3800
+ __uiComponent = true;
3801
+ _bg;
3802
+ _content;
3803
+ _internalSetup = true;
3209
3804
  _panelConfig;
3210
3805
  constructor(config = {}) {
3806
+ super();
3211
3807
  const resolvedConfig = {
3212
3808
  width: config.width ?? 400,
3213
3809
  height: config.height ?? 300,
@@ -3215,8 +3811,8 @@ class Panel extends LayoutContainer {
3215
3811
  backgroundAlpha: config.backgroundAlpha ?? 1,
3216
3812
  ...config,
3217
3813
  };
3218
- // If using a 9-slice texture, pass it as a custom background
3219
- let customBackground;
3814
+ this._panelConfig = resolvedConfig;
3815
+ // Create background
3220
3816
  if (config.nineSliceTexture) {
3221
3817
  const texture = typeof config.nineSliceTexture === 'string'
3222
3818
  ? Texture.from(config.nineSliceTexture)
@@ -3232,40 +3828,102 @@ class Panel extends LayoutContainer {
3232
3828
  nineSlice.width = resolvedConfig.width;
3233
3829
  nineSlice.height = resolvedConfig.height;
3234
3830
  nineSlice.alpha = resolvedConfig.backgroundAlpha;
3235
- customBackground = nineSlice;
3831
+ this._bg = nineSlice;
3236
3832
  }
3237
- super(customBackground ? { background: customBackground } : undefined);
3238
- this._panelConfig = resolvedConfig;
3239
- // Apply layout styles
3240
- const layoutStyles = {
3241
- width: resolvedConfig.width,
3242
- height: resolvedConfig.height,
3243
- padding: resolvedConfig.padding,
3244
- flexDirection: 'column',
3245
- };
3246
- // Graphics-based background via layout styles
3247
- if (!config.nineSliceTexture) {
3248
- layoutStyles.backgroundColor = config.backgroundColor ?? 0x1a1a2e;
3249
- layoutStyles.borderRadius = config.borderRadius ?? 0;
3833
+ else {
3834
+ const g = new Graphics();
3835
+ const bgColor = config.backgroundColor ?? 0x1a1a2e;
3836
+ const radius = config.borderRadius ?? 0;
3837
+ g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius).fill(bgColor);
3250
3838
  if (config.borderColor !== undefined && config.borderWidth) {
3251
- layoutStyles.borderColor = config.borderColor;
3252
- layoutStyles.borderWidth = config.borderWidth;
3839
+ g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius)
3840
+ .stroke({ color: config.borderColor, width: config.borderWidth });
3253
3841
  }
3842
+ g.alpha = resolvedConfig.backgroundAlpha;
3843
+ this._bg = g;
3254
3844
  }
3255
- this.layout = layoutStyles;
3256
- if (!config.nineSliceTexture) {
3257
- this.background.alpha = resolvedConfig.backgroundAlpha;
3258
- }
3845
+ this.addChild(this._bg);
3846
+ // Create content flex container
3847
+ this._content = new FlexContainer({
3848
+ ...config.layout,
3849
+ direction: config.layout?.direction ?? 'column',
3850
+ justifyContent: config.layout?.justifyContent ?? 'start',
3851
+ alignItems: config.layout?.alignItems ?? 'start',
3852
+ gap: config.layout?.gap ?? 0,
3853
+ padding: resolvedConfig.padding,
3854
+ width: resolvedConfig.width,
3855
+ height: resolvedConfig.height,
3856
+ });
3857
+ this.addChild(this._content);
3858
+ this._internalSetup = false;
3259
3859
  }
3260
- /** Access the content container (children added here participate in layout) */
3860
+ /** Access the content flex container — add children here for layout */
3261
3861
  get content() {
3262
- return this.overflowContainer;
3862
+ return this._content;
3863
+ }
3864
+ /** Convenience: add a child to the content layout */
3865
+ addContent(child) {
3866
+ this._content.addFlexChild(child);
3867
+ this._content.updateLayout();
3868
+ return this;
3263
3869
  }
3264
3870
  /** Resize the panel */
3265
3871
  setSize(width, height) {
3266
3872
  this._panelConfig.width = width;
3267
3873
  this._panelConfig.height = height;
3268
- this._layout?.setStyle({ width, height });
3874
+ // Resize background
3875
+ if (this._bg instanceof NineSliceSprite) {
3876
+ this._bg.width = width;
3877
+ this._bg.height = height;
3878
+ }
3879
+ else if (this._bg instanceof Graphics) {
3880
+ const radius = this._panelConfig.borderRadius ?? 0;
3881
+ const bgColor = this._panelConfig.backgroundColor ?? 0x1a1a2e;
3882
+ this._bg.clear();
3883
+ this._bg.roundRect(0, 0, width, height, radius).fill(bgColor);
3884
+ if (this._panelConfig.borderColor !== undefined && this._panelConfig.borderWidth) {
3885
+ this._bg.roundRect(0, 0, width, height, radius)
3886
+ .stroke({ color: this._panelConfig.borderColor, width: this._panelConfig.borderWidth });
3887
+ }
3888
+ this._bg.alpha = this._panelConfig.backgroundAlpha;
3889
+ }
3890
+ this._content.resize(width, height);
3891
+ }
3892
+ /**
3893
+ * Override addChild so external children are routed to content FlexContainer.
3894
+ * Enables `<panel><label /><button /></panel>` in React JSX.
3895
+ */
3896
+ addChild(...children) {
3897
+ if (this._internalSetup) {
3898
+ return super.addChild(...children);
3899
+ }
3900
+ for (const child of children) {
3901
+ this._content.addFlexChild(child);
3902
+ }
3903
+ this._content.updateLayout();
3904
+ return children[0];
3905
+ }
3906
+ removeChild(...children) {
3907
+ if (this._internalSetup) {
3908
+ return super.removeChild(...children);
3909
+ }
3910
+ for (const child of children) {
3911
+ this._content.removeFlexChild(child);
3912
+ }
3913
+ return children[0];
3914
+ }
3915
+ /** React reconciler update hook */
3916
+ updateConfig(changed) {
3917
+ if ('width' in changed || 'height' in changed) {
3918
+ this.setSize(changed.width ?? this._panelConfig.width, changed.height ?? this._panelConfig.height);
3919
+ }
3920
+ if ('backgroundAlpha' in changed) {
3921
+ this._panelConfig.backgroundAlpha = changed.backgroundAlpha;
3922
+ this._bg.alpha = changed.backgroundAlpha;
3923
+ }
3924
+ }
3925
+ destroy(options) {
3926
+ super.destroy(options);
3269
3927
  }
3270
3928
  }
3271
3929
 
@@ -3273,7 +3931,7 @@ class Panel extends LayoutContainer {
3273
3931
  * Reactive balance display component.
3274
3932
  *
3275
3933
  * Automatically formats currency and can animate value changes
3276
- * with a smooth countup/countdown effect.
3934
+ * with a smooth countup/countdown effect using engine Tween.
3277
3935
  *
3278
3936
  * @example
3279
3937
  * ```ts
@@ -3285,13 +3943,14 @@ class Panel extends LayoutContainer {
3285
3943
  * ```
3286
3944
  */
3287
3945
  class BalanceDisplay extends Container {
3946
+ __uiComponent = true;
3288
3947
  _prefixLabel = null;
3289
3948
  _valueLabel;
3290
3949
  _config;
3291
3950
  _currentValue = 0;
3292
3951
  _displayedValue = 0;
3293
- _animating = false;
3294
- _animationCancelled = false;
3952
+ /** Internal target for Tween animation */
3953
+ _tweenTarget = { value: 0 };
3295
3954
  constructor(config = {}) {
3296
3955
  super();
3297
3956
  this._config = {
@@ -3352,37 +4011,13 @@ class BalanceDisplay extends Container {
3352
4011
  this._config.currency = currency;
3353
4012
  this.updateDisplay();
3354
4013
  }
3355
- async animateValue(from, to) {
3356
- if (this._animating) {
3357
- this._animationCancelled = true;
3358
- }
3359
- this._animating = true;
3360
- this._animationCancelled = false;
3361
- const duration = this._config.animationDuration;
3362
- const startTime = Date.now();
3363
- return new Promise((resolve) => {
3364
- const tick = () => {
3365
- if (this._animationCancelled) {
3366
- this._animating = false;
3367
- resolve();
3368
- return;
3369
- }
3370
- const elapsed = Date.now() - startTime;
3371
- const t = Math.min(elapsed / duration, 1);
3372
- const eased = Easing.easeOutCubic(t);
3373
- this._displayedValue = from + (to - from) * eased;
3374
- this.updateDisplay();
3375
- if (t < 1) {
3376
- requestAnimationFrame(tick);
3377
- }
3378
- else {
3379
- this._displayedValue = to;
3380
- this.updateDisplay();
3381
- this._animating = false;
3382
- resolve();
3383
- }
3384
- };
3385
- requestAnimationFrame(tick);
4014
+ animateValue(from, to) {
4015
+ // Cancel any running animation
4016
+ Tween.killTweensOf(this._tweenTarget);
4017
+ this._tweenTarget.value = from;
4018
+ Tween.to(this._tweenTarget, { value: to }, this._config.animationDuration, Easing.easeOutCubic, () => {
4019
+ this._displayedValue = this._tweenTarget.value;
4020
+ this.updateDisplay();
3386
4021
  });
3387
4022
  }
3388
4023
  updateDisplay() {
@@ -3394,13 +4029,24 @@ class BalanceDisplay extends Container {
3394
4029
  this._valueLabel.y = 14;
3395
4030
  }
3396
4031
  }
4032
+ /** React reconciler update hook */
4033
+ updateConfig(changed) {
4034
+ if ('value' in changed)
4035
+ this.setValue(changed.value);
4036
+ if ('currency' in changed)
4037
+ this.setCurrency(changed.currency);
4038
+ }
4039
+ destroy(options) {
4040
+ Tween.killTweensOf(this._tweenTarget);
4041
+ super.destroy(options);
4042
+ }
3397
4043
  }
3398
4044
 
3399
4045
  /**
3400
4046
  * Win amount display with countup animation.
3401
4047
  *
3402
4048
  * Shows a dramatic countup from 0 to the win amount, with optional
3403
- * scale pop effect — typical of slot games.
4049
+ * scale pop effect — typical of slot games. Uses engine Tween system.
3404
4050
  *
3405
4051
  * @example
3406
4052
  * ```ts
@@ -3411,9 +4057,11 @@ class BalanceDisplay extends Container {
3411
4057
  * ```
3412
4058
  */
3413
4059
  class WinDisplay extends Container {
4060
+ __uiComponent = true;
3414
4061
  _label;
3415
4062
  _config;
3416
- _cancelCountup = false;
4063
+ /** Internal target for Tween countup */
4064
+ _tweenTarget = { value: 0 };
3417
4065
  constructor(config = {}) {
3418
4066
  super();
3419
4067
  this._config = {
@@ -3443,47 +4091,30 @@ class WinDisplay extends Container {
3443
4091
  */
3444
4092
  async showWin(amount) {
3445
4093
  this.visible = true;
3446
- this._cancelCountup = false;
3447
4094
  this.alpha = 1;
3448
- const duration = this._config.countupDuration;
3449
- const startTime = Date.now();
3450
- // Scale pop
4095
+ // Cancel any running animation
4096
+ Tween.killTweensOf(this._tweenTarget);
4097
+ Tween.killTweensOf(this);
4098
+ // Setup countup
4099
+ this._tweenTarget.value = 0;
3451
4100
  this.scale.set(0.5);
3452
- return new Promise((resolve) => {
3453
- const tick = () => {
3454
- if (this._cancelCountup) {
3455
- this.displayAmount(amount);
3456
- resolve();
3457
- return;
3458
- }
3459
- const elapsed = Date.now() - startTime;
3460
- const t = Math.min(elapsed / duration, 1);
3461
- const eased = Easing.easeOutCubic(t);
3462
- // Countup
3463
- const current = amount * eased;
3464
- this.displayAmount(current);
3465
- // Scale animation
3466
- const scaleT = Math.min(elapsed / 300, 1);
3467
- const scaleEased = Easing.easeOutBack(scaleT);
3468
- const targetScale = 1;
3469
- this.scale.set(0.5 + (targetScale - 0.5) * scaleEased);
3470
- if (t < 1) {
3471
- requestAnimationFrame(tick);
3472
- }
3473
- else {
3474
- this.displayAmount(amount);
3475
- this.scale.set(1);
3476
- resolve();
3477
- }
3478
- };
3479
- requestAnimationFrame(tick);
4101
+ // Scale pop animation
4102
+ const scalePromise = Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, 300, Easing.easeOutBack);
4103
+ // Countup animation
4104
+ const countupPromise = Tween.to(this._tweenTarget, { value: amount }, this._config.countupDuration, Easing.easeOutCubic, () => {
4105
+ this.displayAmount(this._tweenTarget.value);
3480
4106
  });
4107
+ await Promise.all([scalePromise, countupPromise]);
4108
+ // Ensure final value is exact
4109
+ this.displayAmount(amount);
4110
+ this.scale.set(1);
3481
4111
  }
3482
4112
  /**
3483
4113
  * Skip the countup animation and show the final amount immediately.
3484
4114
  */
3485
4115
  skipCountup(amount) {
3486
- this._cancelCountup = true;
4116
+ Tween.killTweensOf(this._tweenTarget);
4117
+ Tween.killTweensOf(this);
3487
4118
  this.displayAmount(amount);
3488
4119
  this.scale.set(1);
3489
4120
  }
@@ -3491,19 +4122,33 @@ class WinDisplay extends Container {
3491
4122
  * Hide the win display.
3492
4123
  */
3493
4124
  hide() {
4125
+ Tween.killTweensOf(this._tweenTarget);
4126
+ Tween.killTweensOf(this);
3494
4127
  this.visible = false;
3495
4128
  this._label.text = '';
3496
4129
  }
3497
4130
  displayAmount(amount) {
3498
4131
  this._label.setCurrency(amount, this._config.currency, this._config.locale);
3499
4132
  }
4133
+ /** React reconciler update hook */
4134
+ updateConfig(changed) {
4135
+ if ('currency' in changed)
4136
+ this._config.currency = changed.currency;
4137
+ if ('locale' in changed)
4138
+ this._config.locale = changed.locale;
4139
+ }
4140
+ destroy(options) {
4141
+ Tween.killTweensOf(this._tweenTarget);
4142
+ Tween.killTweensOf(this);
4143
+ super.destroy(options);
4144
+ }
3500
4145
  }
3501
4146
 
3502
4147
  /**
3503
4148
  * Modal overlay component.
3504
4149
  * Shows content on top of a dark overlay with enter/exit animations.
3505
4150
  *
3506
- * The content container uses `@pixi/layout` for automatic centering.
4151
+ * Content is automatically centered via position calculations.
3507
4152
  *
3508
4153
  * @example
3509
4154
  * ```ts
@@ -3514,6 +4159,7 @@ class WinDisplay extends Container {
3514
4159
  * ```
3515
4160
  */
3516
4161
  class Modal extends Container {
4162
+ __uiComponent = true;
3517
4163
  _overlay;
3518
4164
  _contentContainer;
3519
4165
  _config;
@@ -3583,6 +4229,17 @@ class Modal extends Container {
3583
4229
  this._showing = false;
3584
4230
  this.onClose?.();
3585
4231
  }
4232
+ /** React reconciler update hook */
4233
+ updateConfig(changed) {
4234
+ if ('overlayAlpha' in changed)
4235
+ this._config.overlayAlpha = changed.overlayAlpha;
4236
+ if ('closeOnOverlay' in changed)
4237
+ this._config.closeOnOverlay = changed.closeOnOverlay;
4238
+ if ('animationDuration' in changed)
4239
+ this._config.animationDuration = changed.animationDuration;
4240
+ if ('onClose' in changed)
4241
+ this.onClose = changed.onClose;
4242
+ }
3586
4243
  }
3587
4244
 
3588
4245
  const TOAST_COLORS = {
@@ -3602,17 +4259,21 @@ const TOAST_COLORS = {
3602
4259
  * ```
3603
4260
  */
3604
4261
  class Toast extends Container {
4262
+ __uiComponent = true;
3605
4263
  _bg;
4264
+ _customBg;
3606
4265
  _text;
3607
4266
  _config;
3608
- _dismissTimeout = null;
4267
+ _dismissPending = false;
3609
4268
  constructor(config = {}) {
3610
4269
  super();
3611
4270
  this._config = {
3612
4271
  duration: config.duration ?? 3000,
3613
4272
  bottomOffset: config.bottomOffset ?? 60,
3614
4273
  };
3615
- this._bg = new Graphics();
4274
+ const customBg = resolveView(config.backgroundView);
4275
+ this._customBg = !!customBg;
4276
+ this._bg = customBg ?? new Graphics();
3616
4277
  this.addChild(this._bg);
3617
4278
  this._text = new Text({
3618
4279
  text: '',
@@ -3630,18 +4291,27 @@ class Toast extends Container {
3630
4291
  * Show a toast message.
3631
4292
  */
3632
4293
  async show(message, type = 'info', viewWidth, viewHeight) {
3633
- if (this._dismissTimeout) {
3634
- clearTimeout(this._dismissTimeout);
3635
- }
4294
+ // Cancel any pending dismiss
4295
+ Tween.killTweensOf(this);
4296
+ this._dismissPending = false;
3636
4297
  this._text.text = message;
3637
4298
  const padding = 20;
3638
4299
  const width = Math.max(200, this._text.width + padding * 2);
3639
4300
  const height = 44;
3640
4301
  const radius = 8;
3641
4302
  // Draw the background
3642
- this._bg.clear();
3643
- this._bg.roundRect(-width / 2, -height / 2, width, height, radius);
3644
- this._bg.fill(TOAST_COLORS[type]);
4303
+ if (this._customBg) {
4304
+ this._bg.width = width;
4305
+ this._bg.height = height;
4306
+ this._bg.x = -width / 2;
4307
+ this._bg.y = -height / 2;
4308
+ }
4309
+ else {
4310
+ const g = this._bg;
4311
+ g.clear();
4312
+ g.roundRect(-width / 2, -height / 2, width, height, radius);
4313
+ g.fill(TOAST_COLORS[type]);
4314
+ }
3645
4315
  // Position
3646
4316
  if (viewWidth && viewHeight) {
3647
4317
  this.x = viewWidth / 2;
@@ -3652,9 +4322,12 @@ class Toast extends Container {
3652
4322
  this.y += 20;
3653
4323
  await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
3654
4324
  if (this._config.duration > 0) {
3655
- this._dismissTimeout = setTimeout(() => {
3656
- this.dismiss();
3657
- }, this._config.duration);
4325
+ this._dismissPending = true;
4326
+ await Tween.delay(this._config.duration);
4327
+ if (this._dismissPending) {
4328
+ this._dismissPending = false;
4329
+ await this.dismiss();
4330
+ }
3658
4331
  }
3659
4332
  }
3660
4333
  /**
@@ -3663,57 +4336,36 @@ class Toast extends Container {
3663
4336
  async dismiss() {
3664
4337
  if (!this.visible)
3665
4338
  return;
3666
- if (this._dismissTimeout) {
3667
- clearTimeout(this._dismissTimeout);
3668
- this._dismissTimeout = null;
3669
- }
4339
+ this._dismissPending = false;
4340
+ Tween.killTweensOf(this);
3670
4341
  await Tween.to(this, { alpha: 0, y: this.y + 20 }, 200, Easing.easeInCubic);
3671
4342
  this.visible = false;
3672
4343
  }
4344
+ /** React reconciler update hook */
4345
+ updateConfig(changed) {
4346
+ if ('duration' in changed)
4347
+ this._config.duration = changed.duration;
4348
+ if ('bottomOffset' in changed)
4349
+ this._config.bottomOffset = changed.bottomOffset;
4350
+ }
4351
+ destroy(options) {
4352
+ this._dismissPending = false;
4353
+ Tween.killTweensOf(this);
4354
+ super.destroy(options);
4355
+ }
3673
4356
  }
3674
4357
 
3675
4358
  // ─── Helpers ─────────────────────────────────────────────
3676
- const ALIGNMENT_MAP = {
3677
- start: 'flex-start',
3678
- center: 'center',
3679
- end: 'flex-end',
3680
- stretch: 'stretch',
3681
- };
3682
- function normalizePadding(padding) {
3683
- if (typeof padding === 'number')
3684
- return [padding, padding, padding, padding];
3685
- return padding;
3686
- }
3687
- function directionToFlexStyles(direction, maxWidth) {
4359
+ function directionToFlex(direction) {
3688
4360
  switch (direction) {
3689
- case 'horizontal':
3690
- return { flexDirection: 'row', flexWrap: 'nowrap' };
3691
- case 'vertical':
3692
- return { flexDirection: 'column', flexWrap: 'nowrap' };
3693
- case 'grid':
3694
- return { flexDirection: 'row', flexWrap: 'wrap' };
3695
- case 'wrap':
3696
- return {
3697
- flexDirection: 'row',
3698
- flexWrap: 'wrap',
3699
- ...(maxWidth < Infinity ? { maxWidth } : {}),
3700
- };
4361
+ case 'horizontal': return { direction: 'row', wrap: false };
4362
+ case 'vertical': return { direction: 'column', wrap: false };
4363
+ case 'grid': return { direction: 'row', wrap: true };
4364
+ case 'wrap': return { direction: 'row', wrap: true };
3701
4365
  }
3702
4366
  }
3703
- function buildLayoutStyles(config) {
3704
- const [pt, pr, pb, pl] = config.padding;
3705
- return {
3706
- ...directionToFlexStyles(config.direction, config.maxWidth),
3707
- gap: config.gap,
3708
- alignItems: ALIGNMENT_MAP[config.alignment],
3709
- paddingTop: pt,
3710
- paddingRight: pr,
3711
- paddingBottom: pb,
3712
- paddingLeft: pl,
3713
- };
3714
- }
3715
4367
  /**
3716
- * Responsive layout container powered by `@pixi/layout` (Yoga flexbox engine).
4368
+ * Responsive layout container powered by a lightweight built-in flex layout solver.
3717
4369
  *
3718
4370
  * Supports horizontal, vertical, grid, and wrap layout modes with
3719
4371
  * alignment, padding, gap, and viewport-anchor positioning.
@@ -3740,6 +4392,7 @@ function buildLayoutStyles(config) {
3740
4392
  * ```
3741
4393
  */
3742
4394
  class Layout extends Container {
4395
+ __uiComponent = true;
3743
4396
  _layoutConfig;
3744
4397
  _padding;
3745
4398
  _anchor;
@@ -3748,6 +4401,7 @@ class Layout extends Container {
3748
4401
  _items = [];
3749
4402
  _viewportWidth = 0;
3750
4403
  _viewportHeight = 0;
4404
+ _flex;
3751
4405
  constructor(config = {}) {
3752
4406
  super();
3753
4407
  this._layoutConfig = {
@@ -3757,7 +4411,7 @@ class Layout extends Container {
3757
4411
  autoLayout: config.autoLayout ?? true,
3758
4412
  columns: config.columns ?? 2,
3759
4413
  };
3760
- this._padding = normalizePadding(config.padding ?? 0);
4414
+ this._padding = config.padding ?? 0;
3761
4415
  this._anchor = config.anchor ?? 'top-left';
3762
4416
  this._maxWidth = config.maxWidth ?? Infinity;
3763
4417
  this._breakpoints = config.breakpoints
@@ -3765,14 +4419,18 @@ class Layout extends Container {
3765
4419
  .map(([w, cfg]) => [Number(w), cfg])
3766
4420
  .sort((a, b) => a[0] - b[0])
3767
4421
  : [];
4422
+ // Create internal FlexContainer
4423
+ this._flex = new FlexContainer();
4424
+ super.addChild(this._flex);
3768
4425
  this.applyLayoutStyles();
3769
4426
  }
3770
4427
  /** Add an item to the layout */
3771
4428
  addItem(child) {
3772
4429
  this._items.push(child);
3773
- this.addChild(child);
3774
- if (this._layoutConfig.direction === 'grid') {
3775
- this.applyGridChildWidth(child);
4430
+ const flexConfig = this.buildFlexItemConfig(child);
4431
+ this._flex.addFlexChild(child, flexConfig);
4432
+ if (this._layoutConfig.autoLayout) {
4433
+ this.applyLayoutStyles();
3776
4434
  }
3777
4435
  return this;
3778
4436
  }
@@ -3781,15 +4439,13 @@ class Layout extends Container {
3781
4439
  const idx = this._items.indexOf(child);
3782
4440
  if (idx !== -1) {
3783
4441
  this._items.splice(idx, 1);
3784
- this.removeChild(child);
4442
+ this._flex.removeFlexChild(child);
3785
4443
  }
3786
4444
  return this;
3787
4445
  }
3788
4446
  /** Remove all items */
3789
4447
  clearItems() {
3790
- for (const item of this._items) {
3791
- this.removeChild(item);
3792
- }
4448
+ this._flex.clearFlexChildren();
3793
4449
  this._items.length = 0;
3794
4450
  return this;
3795
4451
  }
@@ -3812,43 +4468,58 @@ class Layout extends Container {
3812
4468
  const direction = effective.direction ?? this._layoutConfig.direction;
3813
4469
  const gap = effective.gap ?? this._layoutConfig.gap;
3814
4470
  const alignment = effective.alignment ?? this._layoutConfig.alignment;
3815
- effective.columns ?? this._layoutConfig.columns;
3816
- const padding = effective.padding !== undefined
3817
- ? normalizePadding(effective.padding)
3818
- : this._padding;
4471
+ const padding = effective.padding ?? this._padding;
3819
4472
  const maxWidth = effective.maxWidth ?? this._maxWidth;
3820
- const styles = buildLayoutStyles({ direction, gap, alignment, padding, maxWidth });
3821
- this.layout = styles;
4473
+ const { direction: flexDir, wrap } = directionToFlex(direction);
4474
+ this._flex.setDirection(flexDir);
4475
+ this._flex.setJustifyContent('start');
4476
+ this._flex.setAlignItems(alignment);
4477
+ this._flex.setGap(gap);
4478
+ this._flex.setPadding(padding);
4479
+ // Wrap and maxWidth
4480
+ if (wrap) {
4481
+ this._flex._config.flexWrap = true;
4482
+ if (direction === 'grid' && maxWidth < Infinity) {
4483
+ this._flex._maxWidth = maxWidth;
4484
+ }
4485
+ if (maxWidth < Infinity) {
4486
+ this._flex._maxWidth = maxWidth;
4487
+ }
4488
+ }
4489
+ else {
4490
+ this._flex._config.flexWrap = false;
4491
+ }
4492
+ // Update grid child widths
3822
4493
  if (direction === 'grid') {
3823
4494
  for (const item of this._items) {
3824
- this.applyGridChildWidth(item);
4495
+ const flexConfig = this.buildFlexItemConfig(item);
4496
+ item._flexConfig = flexConfig;
3825
4497
  }
3826
4498
  }
4499
+ // Set explicit size if we have viewport dimensions
4500
+ if (this._viewportWidth > 0 && this._viewportHeight > 0) {
4501
+ this._flex.resize(this._viewportWidth, this._viewportHeight);
4502
+ }
4503
+ else {
4504
+ this._flex.updateLayout();
4505
+ }
3827
4506
  }
3828
- applyGridChildWidth(child) {
4507
+ buildFlexItemConfig(_child) {
3829
4508
  const effective = this.resolveConfig();
4509
+ const direction = effective.direction ?? this._layoutConfig.direction;
3830
4510
  const columns = effective.columns ?? this._layoutConfig.columns;
3831
- const gap = effective.gap ?? this._layoutConfig.gap;
3832
- // Account for gaps between columns: total gap space = gap * (columns - 1)
3833
- // Each column gets: (100% - total_gap) / columns
3834
- // We use flexBasis + flexGrow to let Yoga handle the math when gap > 0
3835
- const styles = gap > 0
3836
- ? { flexBasis: 0, flexGrow: 1, flexShrink: 1, maxWidth: `${(100 / columns).toFixed(2)}%` }
3837
- : { width: `${(100 / columns).toFixed(2)}%` };
3838
- if (child._layout) {
3839
- child._layout.setStyle(styles);
3840
- }
3841
- else {
3842
- child.layout = styles;
4511
+ if (direction === 'grid' && columns > 0) {
4512
+ // For grid, give each item a proportional width
4513
+ // The actual pixel width will be computed during layout
4514
+ return { flexGrow: 1 };
3843
4515
  }
4516
+ return undefined;
3844
4517
  }
3845
4518
  applyAnchor() {
3846
4519
  const anchor = this.resolveConfig().anchor ?? this._anchor;
3847
4520
  if (this._viewportWidth === 0 || this._viewportHeight === 0)
3848
4521
  return;
3849
- const bounds = this.getLocalBounds();
3850
- const contentW = bounds.width * this.scale.x;
3851
- const contentH = bounds.height * this.scale.y;
4522
+ const { width: contentW, height: contentH } = this._flex.getContentSize();
3852
4523
  const vw = this._viewportWidth;
3853
4524
  const vh = this._viewportHeight;
3854
4525
  let anchorX = 0;
@@ -3871,8 +4542,8 @@ class Layout extends Container {
3871
4542
  else {
3872
4543
  anchorY = (vh - contentH) / 2;
3873
4544
  }
3874
- this.x = anchorX - bounds.x * this.scale.x;
3875
- this.y = anchorY - bounds.y * this.scale.y;
4545
+ this.x = anchorX;
4546
+ this.y = anchorY;
3876
4547
  }
3877
4548
  resolveConfig() {
3878
4549
  if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
@@ -3885,18 +4556,34 @@ class Layout extends Container {
3885
4556
  }
3886
4557
  return {};
3887
4558
  }
4559
+ /** React reconciler update hook */
4560
+ updateConfig(changed) {
4561
+ if ('direction' in changed)
4562
+ this._layoutConfig.direction = changed.direction;
4563
+ if ('gap' in changed)
4564
+ this._layoutConfig.gap = changed.gap;
4565
+ if ('alignment' in changed)
4566
+ this._layoutConfig.alignment = changed.alignment;
4567
+ if ('anchor' in changed)
4568
+ this._anchor = changed.anchor;
4569
+ if ('padding' in changed)
4570
+ this._padding = changed.padding;
4571
+ if ('columns' in changed)
4572
+ this._layoutConfig.columns = changed.columns;
4573
+ this.applyLayoutStyles();
4574
+ if (this._viewportWidth > 0)
4575
+ this.applyAnchor();
4576
+ }
4577
+ destroy(options) {
4578
+ this._items.length = 0;
4579
+ super.destroy(options);
4580
+ }
3888
4581
  }
3889
4582
 
3890
- const DIRECTION_MAP = {
3891
- vertical: 'vertical',
3892
- horizontal: 'horizontal',
3893
- both: 'bidirectional',
3894
- };
4583
+ const DECELERATION = 0.95;
4584
+ const MIN_VELOCITY = 0.5;
3895
4585
  /**
3896
- * Scrollable container powered by `@pixi/ui` ScrollBox.
3897
- *
3898
- * Provides touch/drag scrolling, mouse wheel support, inertia, and
3899
- * dynamic rendering optimization for off-screen items.
4586
+ * Scrollable container with touch/drag, mouse wheel, and inertia.
3900
4587
  *
3901
4588
  * @example
3902
4589
  * ```ts
@@ -3914,53 +4601,349 @@ const DIRECTION_MAP = {
3914
4601
  * scene.container.addChild(scroll);
3915
4602
  * ```
3916
4603
  */
3917
- class ScrollContainer extends ScrollBox {
4604
+ class ScrollContainer extends Container {
4605
+ __uiComponent = true;
4606
+ _viewport;
4607
+ _internalSetup = true;
4608
+ _content;
4609
+ _maskGfx;
4610
+ _bg = null;
3918
4611
  _scrollConfig;
4612
+ _items = [];
4613
+ // Scrollbar
4614
+ _scrollbar = null;
4615
+ _scrollbarConfig;
4616
+ // Drag state
4617
+ _dragging = false;
4618
+ _dragStart = { x: 0, y: 0 };
4619
+ _contentStart = { x: 0, y: 0 };
4620
+ _velocity = { x: 0, y: 0 };
4621
+ _lastDragPos = { x: 0, y: 0 };
4622
+ _lastDragTime = 0;
4623
+ _inertiaActive = false;
4624
+ // Bound handlers for cleanup
4625
+ _onTickBound = null;
4626
+ _onWheelBound = null;
3919
4627
  constructor(config) {
3920
- const options = {
3921
- width: config.width,
3922
- height: config.height,
3923
- type: DIRECTION_MAP[config.direction ?? 'vertical'],
3924
- radius: config.borderRadius ?? 0,
4628
+ super();
4629
+ this._viewport = { width: config.width, height: config.height };
4630
+ this._scrollConfig = {
4631
+ direction: config.direction ?? 'vertical',
3925
4632
  elementsMargin: config.elementsMargin ?? 0,
3926
4633
  padding: config.padding ?? 0,
3927
- disableDynamicRendering: config.disableDynamicRendering ?? false,
4634
+ borderRadius: config.borderRadius ?? 0,
3928
4635
  disableEasing: config.disableEasing ?? false,
3929
- globalScroll: config.globalScroll ?? true,
3930
4636
  };
4637
+ // Background
3931
4638
  if (config.backgroundColor !== undefined) {
3932
- options.background = config.backgroundColor;
4639
+ this._bg = new Graphics();
4640
+ this._bg.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
4641
+ .fill(config.backgroundColor);
4642
+ this.addChild(this._bg);
4643
+ }
4644
+ // Mask
4645
+ this._maskGfx = new Graphics();
4646
+ this._maskGfx.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
4647
+ .fill(0xffffff);
4648
+ this.addChild(this._maskGfx);
4649
+ // Content container
4650
+ this._content = new Container();
4651
+ this._content.mask = this._maskGfx;
4652
+ this.addChild(this._content);
4653
+ // Interaction
4654
+ this.eventMode = 'static';
4655
+ this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
4656
+ this.on('pointerdown', this._onPointerDown, this);
4657
+ this.on('pointermove', this._onPointerMove, this);
4658
+ this.on('pointerup', this._onPointerUp, this);
4659
+ this.on('pointerupoutside', this._onPointerUp, this);
4660
+ // Mouse wheel
4661
+ this._onWheelBound = this._onWheel.bind(this);
4662
+ // Scrollbar
4663
+ const sbWidth = config.scrollbarWidth ?? 6;
4664
+ const sbPadding = config.scrollbarPadding ?? 4;
4665
+ this._scrollbarConfig = { width: sbWidth, padding: sbPadding };
4666
+ if (config.scrollbar) {
4667
+ const customThumb = resolveView(config.thumbView);
4668
+ if (customThumb) {
4669
+ this._scrollbar = customThumb;
4670
+ }
4671
+ else {
4672
+ const g = new Graphics();
4673
+ g.roundRect(0, 0, sbWidth, 40, sbWidth / 2).fill(config.scrollbarColor ?? 0xaaaaaa);
4674
+ g.alpha = config.scrollbarAlpha ?? 0.5;
4675
+ this._scrollbar = g;
4676
+ }
4677
+ this._scrollbar.visible = false;
4678
+ super.addChild(this._scrollbar);
3933
4679
  }
3934
- super(options);
3935
- this._scrollConfig = config;
4680
+ this._internalSetup = false;
3936
4681
  }
3937
- /** Set scrollable content. Replaces any existing content. */
3938
- setContent(content) {
3939
- // Remove existing items
3940
- const existing = this.items;
3941
- if (existing.length > 0) {
3942
- for (let i = existing.length - 1; i >= 0; i--) {
3943
- this.removeItem(i);
4682
+ /**
4683
+ * Override addChild so external children are routed to scroll content.
4684
+ * Enables `<scrollContainer><label /><panel /></scrollContainer>` in React JSX.
4685
+ */
4686
+ addChild(...children) {
4687
+ if (this._internalSetup) {
4688
+ return super.addChild(...children);
4689
+ }
4690
+ for (const child of children) {
4691
+ this.addItem(child);
4692
+ }
4693
+ return children[0];
4694
+ }
4695
+ removeChild(...children) {
4696
+ if (this._internalSetup) {
4697
+ return super.removeChild(...children);
4698
+ }
4699
+ for (const child of children) {
4700
+ const idx = this._items.indexOf(child);
4701
+ if (idx !== -1) {
4702
+ this._items.splice(idx, 1);
4703
+ this._content.removeChild(child);
3944
4704
  }
3945
4705
  }
3946
- // Add all children from the content container
4706
+ this.layoutItems();
4707
+ return children[0];
4708
+ }
4709
+ /** React reconciler update hook */
4710
+ updateConfig(changed) {
4711
+ if ('width' in changed || 'height' in changed) {
4712
+ this.setViewportSize(changed.width ?? this._viewport.width, changed.height ?? this._viewport.height);
4713
+ }
4714
+ }
4715
+ /** Enable mouse wheel scrolling (call after adding to stage) */
4716
+ enableWheel(canvas) {
4717
+ if (this._onWheelBound) {
4718
+ canvas.addEventListener('wheel', this._onWheelBound, { passive: false });
4719
+ }
4720
+ }
4721
+ /** Set scrollable content. Replaces any existing items. */
4722
+ setContent(content) {
4723
+ this.clearItems();
3947
4724
  const children = [...content.children];
3948
- if (children.length > 0) {
3949
- this.addItems(children);
4725
+ for (const child of children) {
4726
+ this.addItem(child);
3950
4727
  }
3951
4728
  }
3952
4729
  /** Add a single item */
3953
- addItem(...items) {
3954
- this.addItems(items);
3955
- return items[0];
4730
+ addItem(child) {
4731
+ this._items.push(child);
4732
+ this._content.addChild(child);
4733
+ this.layoutItems();
4734
+ return this;
4735
+ }
4736
+ /** Remove all items */
4737
+ clearItems() {
4738
+ for (const item of this._items) {
4739
+ this._content.removeChild(item);
4740
+ }
4741
+ this._items.length = 0;
3956
4742
  }
3957
- /** Scroll to make a specific item/child visible */
4743
+ /** Get items */
4744
+ get items() {
4745
+ return this._items;
4746
+ }
4747
+ /** Scroll to make a specific item index visible */
3958
4748
  scrollToItem(index) {
3959
- this.scrollTo(index);
4749
+ if (index < 0 || index >= this._items.length)
4750
+ return;
4751
+ const item = this._items[index];
4752
+ const isVert = this._scrollConfig.direction !== 'horizontal';
4753
+ if (isVert) {
4754
+ this._content.y = -item.y + this._scrollConfig.padding;
4755
+ }
4756
+ else {
4757
+ this._content.x = -item.x + this._scrollConfig.padding;
4758
+ }
4759
+ this.clampScroll();
3960
4760
  }
3961
4761
  /** Current scroll position */
3962
4762
  get scrollPosition() {
3963
- return { x: this.scrollX, y: this.scrollY };
4763
+ return { x: this._content.x, y: this._content.y };
4764
+ }
4765
+ /** Resize the scroll viewport */
4766
+ setViewportSize(width, height) {
4767
+ this._viewport.width = width;
4768
+ this._viewport.height = height;
4769
+ this._maskGfx.clear();
4770
+ this._maskGfx.roundRect(0, 0, width, height, this._scrollConfig.borderRadius).fill(0xffffff);
4771
+ if (this._bg) {
4772
+ this._bg.clear();
4773
+ this._bg.roundRect(0, 0, width, height, this._scrollConfig.borderRadius)
4774
+ .fill(0xffffff); // color will be overridden if needed
4775
+ }
4776
+ this.clampScroll();
4777
+ }
4778
+ // ─── Layout ──────────────────────────────────────────
4779
+ layoutItems() {
4780
+ const { direction, elementsMargin, padding } = this._scrollConfig;
4781
+ const isVert = direction !== 'horizontal';
4782
+ let pos = padding;
4783
+ for (const item of this._items) {
4784
+ if (isVert) {
4785
+ item.x = padding;
4786
+ item.y = pos;
4787
+ pos += item.height + elementsMargin;
4788
+ }
4789
+ else {
4790
+ item.x = pos;
4791
+ item.y = padding;
4792
+ pos += item.width + elementsMargin;
4793
+ }
4794
+ }
4795
+ }
4796
+ // ─── Drag handling ───────────────────────────────────
4797
+ _onPointerDown(e) {
4798
+ this._dragging = true;
4799
+ this._inertiaActive = false;
4800
+ this._dragStart.x = e.globalX;
4801
+ this._dragStart.y = e.globalY;
4802
+ this._contentStart.x = this._content.x;
4803
+ this._contentStart.y = this._content.y;
4804
+ this._lastDragPos.x = e.globalX;
4805
+ this._lastDragPos.y = e.globalY;
4806
+ this._lastDragTime = Date.now();
4807
+ this._velocity.x = 0;
4808
+ this._velocity.y = 0;
4809
+ this.stopInertia();
4810
+ }
4811
+ _onPointerMove(e) {
4812
+ if (!this._dragging)
4813
+ return;
4814
+ const dx = e.globalX - this._dragStart.x;
4815
+ const dy = e.globalY - this._dragStart.y;
4816
+ const { direction } = this._scrollConfig;
4817
+ if (direction !== 'horizontal') {
4818
+ this._content.y = this._contentStart.y + dy;
4819
+ }
4820
+ if (direction !== 'vertical') {
4821
+ this._content.x = this._contentStart.x + dx;
4822
+ }
4823
+ // Track velocity
4824
+ const now = Date.now();
4825
+ const dt = now - this._lastDragTime;
4826
+ if (dt > 0) {
4827
+ this._velocity.x = (e.globalX - this._lastDragPos.x) / dt * 16;
4828
+ this._velocity.y = (e.globalY - this._lastDragPos.y) / dt * 16;
4829
+ }
4830
+ this._lastDragPos.x = e.globalX;
4831
+ this._lastDragPos.y = e.globalY;
4832
+ this._lastDragTime = now;
4833
+ this.clampScroll();
4834
+ }
4835
+ _onPointerUp() {
4836
+ if (!this._dragging)
4837
+ return;
4838
+ this._dragging = false;
4839
+ if (!this._scrollConfig.disableEasing &&
4840
+ (Math.abs(this._velocity.x) > MIN_VELOCITY || Math.abs(this._velocity.y) > MIN_VELOCITY)) {
4841
+ this.startInertia();
4842
+ }
4843
+ }
4844
+ // ─── Inertia ─────────────────────────────────────────
4845
+ startInertia() {
4846
+ this._inertiaActive = true;
4847
+ this._onTickBound = this._inertiaTick.bind(this);
4848
+ Ticker.shared.add(this._onTickBound);
4849
+ }
4850
+ stopInertia() {
4851
+ if (this._onTickBound && this._inertiaActive) {
4852
+ Ticker.shared.remove(this._onTickBound);
4853
+ this._inertiaActive = false;
4854
+ }
4855
+ }
4856
+ _inertiaTick() {
4857
+ const { direction } = this._scrollConfig;
4858
+ if (direction !== 'horizontal') {
4859
+ this._content.y += this._velocity.y;
4860
+ this._velocity.y *= DECELERATION;
4861
+ }
4862
+ if (direction !== 'vertical') {
4863
+ this._content.x += this._velocity.x;
4864
+ this._velocity.x *= DECELERATION;
4865
+ }
4866
+ this.clampScroll();
4867
+ if (Math.abs(this._velocity.x) < MIN_VELOCITY && Math.abs(this._velocity.y) < MIN_VELOCITY) {
4868
+ this.stopInertia();
4869
+ }
4870
+ }
4871
+ // ─── Mouse wheel ─────────────────────────────────────
4872
+ _onWheel(e) {
4873
+ const { direction } = this._scrollConfig;
4874
+ e.preventDefault();
4875
+ if (direction !== 'horizontal') {
4876
+ this._content.y -= e.deltaY;
4877
+ }
4878
+ if (direction !== 'vertical') {
4879
+ this._content.x -= e.deltaX;
4880
+ }
4881
+ this.clampScroll();
4882
+ }
4883
+ // ─── Scroll bounds ───────────────────────────────────
4884
+ clampScroll() {
4885
+ const { direction } = this._scrollConfig;
4886
+ const bounds = this._content.getLocalBounds();
4887
+ if (direction !== 'horizontal') {
4888
+ const contentHeight = bounds.height + bounds.y;
4889
+ const maxScroll = Math.min(0, this._viewport.height - contentHeight);
4890
+ this._content.y = Math.max(maxScroll, Math.min(0, this._content.y));
4891
+ }
4892
+ if (direction !== 'vertical') {
4893
+ const contentWidth = bounds.width + bounds.x;
4894
+ const maxScroll = Math.min(0, this._viewport.width - contentWidth);
4895
+ this._content.x = Math.max(maxScroll, Math.min(0, this._content.x));
4896
+ }
4897
+ this.updateScrollbar();
4898
+ }
4899
+ updateScrollbar() {
4900
+ if (!this._scrollbar)
4901
+ return;
4902
+ const { direction } = this._scrollConfig;
4903
+ const { width: sbW, padding: sbPad } = this._scrollbarConfig;
4904
+ const bounds = this._content.getLocalBounds();
4905
+ const isVert = direction !== 'horizontal';
4906
+ if (isVert) {
4907
+ const contentH = bounds.height + bounds.y;
4908
+ if (contentH <= this._viewport.height) {
4909
+ this._scrollbar.visible = false;
4910
+ return;
4911
+ }
4912
+ this._scrollbar.visible = true;
4913
+ const ratio = this._viewport.height / contentH;
4914
+ const thumbH = Math.max(20, this._viewport.height * ratio);
4915
+ const scrollRange = this._viewport.height - thumbH;
4916
+ const scrollProgress = -this._content.y / (contentH - this._viewport.height);
4917
+ this._scrollbar.x = this._viewport.width - sbW - sbPad;
4918
+ this._scrollbar.y = scrollProgress * scrollRange;
4919
+ this._scrollbar.height = thumbH;
4920
+ this._scrollbar.width = sbW;
4921
+ }
4922
+ else {
4923
+ const contentW = bounds.width + bounds.x;
4924
+ if (contentW <= this._viewport.width) {
4925
+ this._scrollbar.visible = false;
4926
+ return;
4927
+ }
4928
+ this._scrollbar.visible = true;
4929
+ const ratio = this._viewport.width / contentW;
4930
+ const thumbW = Math.max(20, this._viewport.width * ratio);
4931
+ const scrollRange = this._viewport.width - thumbW;
4932
+ const scrollProgress = -this._content.x / (contentW - this._viewport.width);
4933
+ this._scrollbar.y = this._viewport.height - sbW - sbPad;
4934
+ this._scrollbar.x = scrollProgress * scrollRange;
4935
+ this._scrollbar.width = thumbW;
4936
+ this._scrollbar.height = sbW;
4937
+ }
4938
+ }
4939
+ destroy(options) {
4940
+ this.stopInertia();
4941
+ this.off('pointerdown', this._onPointerDown, this);
4942
+ this.off('pointermove', this._onPointerMove, this);
4943
+ this.off('pointerup', this._onPointerUp, this);
4944
+ this.off('pointerupoutside', this._onPointerUp, this);
4945
+ this._items.length = 0;
4946
+ super.destroy(options);
3964
4947
  }
3965
4948
  }
3966
4949
 
@@ -4195,5 +5178,5 @@ class DevBridge {
4195
5178
  }
4196
5179
  }
4197
5180
 
4198
- export { AssetManager, AudioManager, BalanceDisplay, Button, DevBridge, Easing, EventEmitter, FPSOverlay, GameApplication, InputManager, Label, Layout, LoadingScene, Modal, Orientation, Panel, ProgressBar, ScaleMode, Scene, SceneManager, ScrollContainer, SpineHelper, SpriteAnimation, StateMachine, Timeline, Toast, TransitionType, Tween, ViewportManager, WinDisplay };
5181
+ export { AssetManager, AudioManager, BalanceDisplay, Button, DevBridge, Easing, EventEmitter, FPSOverlay, FlexContainer, GameApplication, InputManager, Label, Layout, LoadingScene, Modal, Orientation, Panel, ProgressBar, ScaleMode, Scene, SceneManager, ScrollContainer, SpineHelper, SpriteAnimation, StateMachine, Timeline, Toast, TransitionType, Tween, ViewportManager, WinDisplay };
4199
5182
  //# sourceMappingURL=index.esm.js.map