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