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