@energy8platform/game-engine 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -0
- package/dist/animation.cjs.js +18 -3
- package/dist/animation.cjs.js.map +1 -1
- package/dist/animation.esm.js +18 -3
- package/dist/animation.esm.js.map +1 -1
- package/dist/core.cjs.js +18 -3
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.esm.js +18 -3
- package/dist/core.esm.js.map +1 -1
- package/dist/host.cjs.js +18 -3
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +3 -5
- package/dist/host.esm.js +18 -3
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +18 -2535
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -1020
- package/dist/index.esm.js +20 -2525
- package/dist/index.esm.js.map +1 -1
- package/dist/slot.cjs.js +1872 -61
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +555 -28
- package/dist/slot.esm.js +1857 -62
- package/dist/slot.esm.js.map +1 -1
- package/dist/vite.cjs.js +0 -5
- package/dist/vite.cjs.js.map +1 -1
- package/dist/vite.esm.js +0 -5
- package/dist/vite.esm.js.map +1 -1
- package/package.json +2 -35
- package/src/animation/Tween.ts +14 -3
- package/src/host/index.ts +0 -1
- package/src/host/types.ts +0 -3
- package/src/index.ts +0 -28
- package/src/slot/anim/easing-map.ts +16 -2
- package/src/slot/cascade/TumbleController.ts +230 -0
- package/src/slot/config/ReelSystemConfig.ts +559 -0
- package/src/slot/config/presets.ts +222 -0
- package/src/slot/features/extra.ts +189 -0
- package/src/slot/features/index.ts +44 -0
- package/src/slot/features/symbols.ts +183 -0
- package/src/slot/features/types.ts +136 -0
- package/src/slot/features/wilds.ts +151 -0
- package/src/slot/grid/ReelGrid.ts +93 -24
- package/src/slot/grid/SymbolCell.ts +45 -11
- package/src/slot/index.ts +61 -2
- package/src/slot/motion/AnticipationController.ts +96 -0
- package/src/slot/motion/SpinEngine.ts +384 -0
- package/src/slot/system/ReelSystem.ts +295 -0
- package/src/vite/index.ts +0 -5
- package/dist/react-jsx.cjs.js +0 -3
- package/dist/react-jsx.cjs.js.map +0 -1
- package/dist/react-jsx.d.ts +0 -602
- package/dist/react-jsx.esm.js +0 -2
- package/dist/react-jsx.esm.js.map +0 -1
- package/dist/react.cjs.js +0 -3787
- package/dist/react.cjs.js.map +0 -1
- package/dist/react.d.ts +0 -1467
- package/dist/react.esm.js +0 -3771
- package/dist/react.esm.js.map +0 -1
- package/dist/ui.cjs.js +0 -2999
- package/dist/ui.cjs.js.map +0 -1
- package/dist/ui.d.ts +0 -1116
- package/dist/ui.esm.js +0 -2983
- package/dist/ui.esm.js.map +0 -1
- package/src/react/EngineContext.ts +0 -26
- package/src/react/ReactScene.ts +0 -88
- package/src/react/applyProps.ts +0 -271
- package/src/react/catalogue.ts +0 -17
- package/src/react/createPixiRoot.ts +0 -31
- package/src/react/extendAll.ts +0 -74
- package/src/react/hooks.ts +0 -46
- package/src/react/index.ts +0 -29
- package/src/react/jsx-runtime.ts +0 -346
- package/src/react/reconciler.ts +0 -338
- package/src/slot/freeSpins/FreeSpinsSession.ts +0 -40
- package/src/state/StateMachine.ts +0 -231
- package/src/state/index.ts +0 -1
- package/src/ui/BalanceDisplay.ts +0 -159
- package/src/ui/Button.ts +0 -304
- package/src/ui/FlexContainer.ts +0 -775
- package/src/ui/Label.ts +0 -124
- package/src/ui/LabelValue.ts +0 -122
- package/src/ui/Layout.ts +0 -291
- package/src/ui/Modal.ts +0 -170
- package/src/ui/Panel.ts +0 -204
- package/src/ui/ProgressBar.ts +0 -170
- package/src/ui/ScrollContainer.ts +0 -478
- package/src/ui/Slider.ts +0 -241
- package/src/ui/Toast.ts +0 -150
- package/src/ui/Toggle.ts +0 -201
- package/src/ui/WinDisplay.ts +0 -145
- package/src/ui/index.ts +0 -33
- package/src/ui/view.ts +0 -28
package/dist/ui.esm.js
DELETED
|
@@ -1,2983 +0,0 @@
|
|
|
1
|
-
import { Sprite, Texture, Container, Ticker, Text, Graphics, NineSliceSprite } from 'pixi.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Resolve a ViewInput to a Container instance.
|
|
5
|
-
*
|
|
6
|
-
* @example
|
|
7
|
-
* ```ts
|
|
8
|
-
* resolveView('btn-idle') // → Sprite.from('btn-idle')
|
|
9
|
-
* resolveView(someTexture) // → new Sprite(someTexture)
|
|
10
|
-
* resolveView(myCustomContainer) // → myCustomContainer (as-is)
|
|
11
|
-
* resolveView(undefined) // → null
|
|
12
|
-
* ```
|
|
13
|
-
*/
|
|
14
|
-
function resolveView(input) {
|
|
15
|
-
if (input == null)
|
|
16
|
-
return null;
|
|
17
|
-
if (typeof input === 'string')
|
|
18
|
-
return Sprite.from(input);
|
|
19
|
-
if (input instanceof Texture)
|
|
20
|
-
return new Sprite(input);
|
|
21
|
-
return input;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// ─── Helpers ─────────────────────────────────────────────
|
|
25
|
-
function normalizePadding(p) {
|
|
26
|
-
return typeof p === 'number' ? [p, p, p, p] : p;
|
|
27
|
-
}
|
|
28
|
-
/** Resolve padding from config: individual props override the base `padding` value */
|
|
29
|
-
function resolvePadding(config) {
|
|
30
|
-
const base = normalizePadding(config.padding ?? 0);
|
|
31
|
-
return [
|
|
32
|
-
config.paddingTop ?? base[0],
|
|
33
|
-
config.paddingRight ?? base[1],
|
|
34
|
-
config.paddingBottom ?? base[2],
|
|
35
|
-
config.paddingLeft ?? base[3],
|
|
36
|
-
];
|
|
37
|
-
}
|
|
38
|
-
/** Resolve a dimension value — number passes through, "50%" resolves against reference */
|
|
39
|
-
function resolveDimension(value, reference) {
|
|
40
|
-
if (value === undefined)
|
|
41
|
-
return undefined;
|
|
42
|
-
if (typeof value === 'number')
|
|
43
|
-
return value;
|
|
44
|
-
if (typeof value === 'string' && value.endsWith('%')) {
|
|
45
|
-
const pct = parseFloat(value);
|
|
46
|
-
if (!isNaN(pct) && reference > 0 && isFinite(reference))
|
|
47
|
-
return (pct / 100) * reference;
|
|
48
|
-
}
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
/** Measure a child's size and bounds offset for layout purposes */
|
|
52
|
-
function measureChild(child, parentContentW = 0, parentContentH = 0) {
|
|
53
|
-
const cfg = child._flexConfig;
|
|
54
|
-
const resolvedLW = resolveDimension(cfg?.layoutWidth, parentContentW);
|
|
55
|
-
const resolvedLH = resolveDimension(cfg?.layoutHeight, parentContentH);
|
|
56
|
-
// FlexContainer children are top-left origin by construction, so `ox/oy = 0`
|
|
57
|
-
// is correct and we can skip the `getLocalBounds()` call entirely.
|
|
58
|
-
if (child instanceof FlexContainer) {
|
|
59
|
-
const fc = child;
|
|
60
|
-
const w = fc._explicitWidth > 0 ? fc._explicitWidth : (fc._computedWidth > 0 ? fc._computedWidth : undefined);
|
|
61
|
-
const h = fc._explicitHeight > 0 ? fc._explicitHeight : (fc._computedHeight > 0 ? fc._computedHeight : undefined);
|
|
62
|
-
if (w !== undefined && h !== undefined) {
|
|
63
|
-
return { w: resolvedLW ?? w, h: resolvedLH ?? h, ox: 0, oy: 0 };
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
// Use localBounds to get the true visual origin offset.
|
|
67
|
-
// We take `ox/oy` from bounds unconditionally — a user-supplied `layoutWidth/Height`
|
|
68
|
-
// overrides the SIZE used by flex (protection against pre-paint bounds of 0), but
|
|
69
|
-
// it must NOT erase the center-anchor compensation: Button/Label views are drawn at
|
|
70
|
-
// `(-w/2..w/2)` and their bounds.x = -w/2 is what lets `layoutLine` place the
|
|
71
|
-
// visible rectangle where the layout intends.
|
|
72
|
-
const bounds = child.getLocalBounds();
|
|
73
|
-
return {
|
|
74
|
-
w: resolvedLW ?? bounds.width,
|
|
75
|
-
h: resolvedLH ?? bounds.height,
|
|
76
|
-
ox: bounds.x,
|
|
77
|
-
oy: bounds.y,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Set a child's main or cross dimension.
|
|
82
|
-
* For FlexContainer children, calls resize() to trigger internal relayout
|
|
83
|
-
* instead of the PixiJS scale setter.
|
|
84
|
-
*/
|
|
85
|
-
function setChildMainSize(child, isRow, mainSize, item) {
|
|
86
|
-
if (child instanceof FlexContainer) {
|
|
87
|
-
const fc = child;
|
|
88
|
-
fc.resize(isRow ? mainSize : fc._explicitWidth || fc._computedWidth, isRow ? fc._explicitHeight || fc._computedHeight : mainSize);
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
if (isRow) {
|
|
92
|
-
child.width = mainSize;
|
|
93
|
-
}
|
|
94
|
-
else {
|
|
95
|
-
child.height = mainSize;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
if (isRow)
|
|
99
|
-
item.w = mainSize;
|
|
100
|
-
else
|
|
101
|
-
item.h = mainSize;
|
|
102
|
-
}
|
|
103
|
-
function setChildCrossSize(child, isRow, crossSize) {
|
|
104
|
-
if (child instanceof FlexContainer) {
|
|
105
|
-
const fc = child;
|
|
106
|
-
fc.resize(isRow ? fc._explicitWidth || fc._computedWidth : crossSize, isRow ? crossSize : fc._explicitHeight || fc._computedHeight);
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
if (isRow) {
|
|
110
|
-
child.height = crossSize;
|
|
111
|
-
}
|
|
112
|
-
else {
|
|
113
|
-
child.width = crossSize;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
function layoutLine(items, isRow, mainSize, justify, align, gap, crossOffset, crossSize) {
|
|
118
|
-
if (items.length === 0)
|
|
119
|
-
return;
|
|
120
|
-
// Compute total fixed main size and flex grow total
|
|
121
|
-
let totalFixed = 0;
|
|
122
|
-
let totalGrow = 0;
|
|
123
|
-
for (const item of items) {
|
|
124
|
-
const grow = item.child._flexConfig?.flexGrow ?? 0;
|
|
125
|
-
if (grow > 0) {
|
|
126
|
-
totalGrow += grow;
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
totalFixed += isRow ? item.w : item.h;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
const totalGap = gap * (items.length - 1);
|
|
133
|
-
const availableForFlex = Math.max(0, mainSize - totalFixed - totalGap);
|
|
134
|
-
// Resolve flex sizes
|
|
135
|
-
if (totalGrow > 0) {
|
|
136
|
-
for (const item of items) {
|
|
137
|
-
const grow = item.child._flexConfig?.flexGrow ?? 0;
|
|
138
|
-
if (grow > 0) {
|
|
139
|
-
const flexSize = (grow / totalGrow) * availableForFlex;
|
|
140
|
-
setChildMainSize(item.child, isRow, flexSize, item);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
// Shrink: if content overflows and mainSize is finite, shrink eligible items
|
|
145
|
-
if (totalGrow === 0 && mainSize > 0) {
|
|
146
|
-
const overflow = totalFixed + totalGap - mainSize;
|
|
147
|
-
if (overflow > 0) {
|
|
148
|
-
let totalShrinkable = 0;
|
|
149
|
-
for (const item of items) {
|
|
150
|
-
const shrink = item.child._flexConfig?.flexShrink ?? 1;
|
|
151
|
-
if (shrink > 0) {
|
|
152
|
-
totalShrinkable += isRow ? item.w : item.h;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
if (totalShrinkable > 0) {
|
|
156
|
-
for (const item of items) {
|
|
157
|
-
const shrink = item.child._flexConfig?.flexShrink ?? 1;
|
|
158
|
-
if (shrink > 0) {
|
|
159
|
-
const itemMain = isRow ? item.w : item.h;
|
|
160
|
-
const reduction = overflow * (itemMain / totalShrinkable);
|
|
161
|
-
const newSize = Math.max(0, itemMain - reduction);
|
|
162
|
-
setChildMainSize(item.child, isRow, newSize, item);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
// Calculate total main size after flex
|
|
169
|
-
let totalMain = totalGap;
|
|
170
|
-
for (const item of items) {
|
|
171
|
-
totalMain += isRow ? item.w : item.h;
|
|
172
|
-
}
|
|
173
|
-
// Justify: compute starting offset and extra spacing
|
|
174
|
-
let mainOffset = 0;
|
|
175
|
-
let extraGap = 0;
|
|
176
|
-
switch (justify) {
|
|
177
|
-
case 'start':
|
|
178
|
-
break;
|
|
179
|
-
case 'center':
|
|
180
|
-
mainOffset = Math.max(0, (mainSize - totalMain) / 2);
|
|
181
|
-
break;
|
|
182
|
-
case 'end':
|
|
183
|
-
mainOffset = Math.max(0, mainSize - totalMain);
|
|
184
|
-
break;
|
|
185
|
-
case 'space-between':
|
|
186
|
-
if (items.length > 1) {
|
|
187
|
-
extraGap = Math.max(0, (mainSize - totalMain + totalGap) / (items.length - 1)) - gap;
|
|
188
|
-
}
|
|
189
|
-
break;
|
|
190
|
-
case 'space-around':
|
|
191
|
-
if (items.length > 0) {
|
|
192
|
-
const totalSpace = Math.max(0, mainSize - totalMain + totalGap);
|
|
193
|
-
const segment = totalSpace / items.length;
|
|
194
|
-
mainOffset = segment / 2;
|
|
195
|
-
extraGap = segment - gap;
|
|
196
|
-
}
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
// Position each item
|
|
200
|
-
let pos = mainOffset;
|
|
201
|
-
for (const item of items) {
|
|
202
|
-
const mainDim = isRow ? item.w : item.h;
|
|
203
|
-
const crossDim = isRow ? item.h : item.w;
|
|
204
|
-
// Cross-axis alignment (alignSelf overrides align)
|
|
205
|
-
const effectiveAlign = (item.child._flexConfig?.alignSelf && item.child._flexConfig.alignSelf !== 'auto')
|
|
206
|
-
? item.child._flexConfig.alignSelf
|
|
207
|
-
: align;
|
|
208
|
-
let crossPos = crossOffset;
|
|
209
|
-
switch (effectiveAlign) {
|
|
210
|
-
case 'start':
|
|
211
|
-
break;
|
|
212
|
-
case 'center':
|
|
213
|
-
crossPos += (crossSize - crossDim) / 2;
|
|
214
|
-
break;
|
|
215
|
-
case 'end':
|
|
216
|
-
crossPos += crossSize - crossDim;
|
|
217
|
-
break;
|
|
218
|
-
case 'stretch':
|
|
219
|
-
setChildCrossSize(item.child, isRow, crossSize);
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
// Compensate for local bounds offset (e.g. centered anchors)
|
|
223
|
-
if (isRow) {
|
|
224
|
-
item.child.x = pos - item.ox;
|
|
225
|
-
item.child.y = crossPos - item.oy;
|
|
226
|
-
}
|
|
227
|
-
else {
|
|
228
|
-
item.child.x = crossPos - item.ox;
|
|
229
|
-
item.child.y = pos - item.oy;
|
|
230
|
-
}
|
|
231
|
-
pos += mainDim + gap + extraGap;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
// ─── FlexContainer ───────────────────────────────────────
|
|
235
|
-
/**
|
|
236
|
-
* Lightweight flexbox-like layout container for PixiJS.
|
|
237
|
-
*
|
|
238
|
-
* Supports row/column direction, justify/align, gap, padding, wrapping,
|
|
239
|
-
* and flex-grow distribution. Zero external dependencies.
|
|
240
|
-
*
|
|
241
|
-
* @example
|
|
242
|
-
* ```ts
|
|
243
|
-
* const toolbar = new FlexContainer({
|
|
244
|
-
* direction: 'row',
|
|
245
|
-
* justifyContent: 'space-between',
|
|
246
|
-
* alignItems: 'center',
|
|
247
|
-
* gap: 16,
|
|
248
|
-
* padding: 12,
|
|
249
|
-
* });
|
|
250
|
-
*
|
|
251
|
-
* toolbar.addFlexChild(button1);
|
|
252
|
-
* toolbar.addFlexChild(button2);
|
|
253
|
-
* toolbar.resize(800, 60);
|
|
254
|
-
* ```
|
|
255
|
-
*/
|
|
256
|
-
class FlexContainer extends Container {
|
|
257
|
-
__uiComponent = true;
|
|
258
|
-
_config;
|
|
259
|
-
_padding;
|
|
260
|
-
_maxWidth;
|
|
261
|
-
_maxHeight;
|
|
262
|
-
/** @internal */ _explicitWidth;
|
|
263
|
-
/** @internal */ _explicitHeight;
|
|
264
|
-
/** @internal */ _computedWidth = 0;
|
|
265
|
-
/** @internal */ _computedHeight = 0;
|
|
266
|
-
/** @internal */ _availableWidth = 0;
|
|
267
|
-
/** @internal */ _availableHeight = 0;
|
|
268
|
-
/** @internal */ _rawWidth;
|
|
269
|
-
/** @internal */ _rawHeight;
|
|
270
|
-
_layoutChildren = [];
|
|
271
|
-
_layoutDirty = true;
|
|
272
|
-
_layoutSuspended = false;
|
|
273
|
-
constructor(config = {}) {
|
|
274
|
-
super();
|
|
275
|
-
this._config = {
|
|
276
|
-
direction: config.direction ?? 'row',
|
|
277
|
-
justifyContent: config.justifyContent ?? 'start',
|
|
278
|
-
alignItems: config.alignItems ?? 'start',
|
|
279
|
-
gap: config.gap ?? 0,
|
|
280
|
-
flexWrap: config.flexWrap ?? false,
|
|
281
|
-
alignContent: config.alignContent ?? 'start',
|
|
282
|
-
};
|
|
283
|
-
this._padding = resolvePadding(config);
|
|
284
|
-
this._maxWidth = config.maxWidth ?? Infinity;
|
|
285
|
-
this._maxHeight = config.maxHeight ?? Infinity;
|
|
286
|
-
this._rawWidth = config.width ?? 0;
|
|
287
|
-
this._rawHeight = config.height ?? 0;
|
|
288
|
-
this._explicitWidth = typeof this._rawWidth === 'number' ? this._rawWidth : 0;
|
|
289
|
-
this._explicitHeight = typeof this._rawHeight === 'number' ? this._rawHeight : 0;
|
|
290
|
-
}
|
|
291
|
-
// ─── Public API ──────────────────────────────────────
|
|
292
|
-
/** Add a child with optional flex config. Also registers in flex layout. */
|
|
293
|
-
addFlexChild(child, flexConfig) {
|
|
294
|
-
if (flexConfig)
|
|
295
|
-
child._flexConfig = flexConfig;
|
|
296
|
-
if (!this._layoutChildren.includes(child)) {
|
|
297
|
-
this._layoutChildren.push(child);
|
|
298
|
-
this._layoutDirty = true;
|
|
299
|
-
}
|
|
300
|
-
super.addChild(child);
|
|
301
|
-
return this;
|
|
302
|
-
}
|
|
303
|
-
/** Remove a child from flex layout and display list */
|
|
304
|
-
removeFlexChild(child) {
|
|
305
|
-
const idx = this._layoutChildren.indexOf(child);
|
|
306
|
-
if (idx !== -1) {
|
|
307
|
-
this._layoutChildren.splice(idx, 1);
|
|
308
|
-
this._layoutDirty = true;
|
|
309
|
-
}
|
|
310
|
-
super.removeChild(child);
|
|
311
|
-
return this;
|
|
312
|
-
}
|
|
313
|
-
/** Remove all flex children */
|
|
314
|
-
clearFlexChildren() {
|
|
315
|
-
for (const child of this._layoutChildren) {
|
|
316
|
-
super.removeChild(child);
|
|
317
|
-
}
|
|
318
|
-
this._layoutChildren.length = 0;
|
|
319
|
-
this._layoutDirty = true;
|
|
320
|
-
return this;
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* Override addChild so children automatically participate in flex layout.
|
|
324
|
-
* This enables declarative usage from React JSX.
|
|
325
|
-
*/
|
|
326
|
-
addChild(...children) {
|
|
327
|
-
for (const child of children) {
|
|
328
|
-
if (!this._layoutChildren.includes(child)) {
|
|
329
|
-
this._layoutChildren.push(child);
|
|
330
|
-
this._layoutDirty = true;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
const result = super.addChild(...children);
|
|
334
|
-
if (this._layoutDirty && !this._layoutSuspended)
|
|
335
|
-
this.updateLayout();
|
|
336
|
-
return result;
|
|
337
|
-
}
|
|
338
|
-
addChildAt(child, index) {
|
|
339
|
-
if (!this._layoutChildren.includes(child)) {
|
|
340
|
-
// Insert into layout children at matching position
|
|
341
|
-
const layoutIndex = Math.min(index, this._layoutChildren.length);
|
|
342
|
-
this._layoutChildren.splice(layoutIndex, 0, child);
|
|
343
|
-
this._layoutDirty = true;
|
|
344
|
-
}
|
|
345
|
-
const result = super.addChildAt(child, index);
|
|
346
|
-
if (this._layoutDirty && !this._layoutSuspended)
|
|
347
|
-
this.updateLayout();
|
|
348
|
-
return result;
|
|
349
|
-
}
|
|
350
|
-
removeChild(...children) {
|
|
351
|
-
for (const child of children) {
|
|
352
|
-
const idx = this._layoutChildren.indexOf(child);
|
|
353
|
-
if (idx !== -1) {
|
|
354
|
-
this._layoutChildren.splice(idx, 1);
|
|
355
|
-
this._layoutDirty = true;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
return super.removeChild(...children);
|
|
359
|
-
}
|
|
360
|
-
/** Get all flex layout children (read-only) */
|
|
361
|
-
get flexChildren() {
|
|
362
|
-
return this._layoutChildren;
|
|
363
|
-
}
|
|
364
|
-
/** Suspend automatic layout recalculation. Call resumeLayout() to flush. */
|
|
365
|
-
suspendLayout() {
|
|
366
|
-
this._layoutSuspended = true;
|
|
367
|
-
}
|
|
368
|
-
/** Resume automatic layout and flush if dirty. */
|
|
369
|
-
resumeLayout() {
|
|
370
|
-
this._layoutSuspended = false;
|
|
371
|
-
if (this._layoutDirty)
|
|
372
|
-
this.updateLayout();
|
|
373
|
-
}
|
|
374
|
-
/** Update the container size and recalculate layout */
|
|
375
|
-
resize(width, height) {
|
|
376
|
-
this._explicitWidth = width;
|
|
377
|
-
this._explicitHeight = height;
|
|
378
|
-
this._layoutDirty = true;
|
|
379
|
-
if (!this._layoutSuspended)
|
|
380
|
-
this.updateLayout();
|
|
381
|
-
}
|
|
382
|
-
/** Update layout direction */
|
|
383
|
-
setDirection(direction) {
|
|
384
|
-
this._config.direction = direction;
|
|
385
|
-
this._layoutDirty = true;
|
|
386
|
-
}
|
|
387
|
-
/** Update justifyContent */
|
|
388
|
-
setJustifyContent(justify) {
|
|
389
|
-
this._config.justifyContent = justify;
|
|
390
|
-
this._layoutDirty = true;
|
|
391
|
-
}
|
|
392
|
-
/** Update alignItems */
|
|
393
|
-
setAlignItems(align) {
|
|
394
|
-
this._config.alignItems = align;
|
|
395
|
-
this._layoutDirty = true;
|
|
396
|
-
}
|
|
397
|
-
/** Update gap */
|
|
398
|
-
setGap(gap) {
|
|
399
|
-
this._config.gap = gap;
|
|
400
|
-
this._layoutDirty = true;
|
|
401
|
-
}
|
|
402
|
-
/** Update padding */
|
|
403
|
-
setPadding(padding) {
|
|
404
|
-
this._padding = normalizePadding(padding);
|
|
405
|
-
this._layoutDirty = true;
|
|
406
|
-
}
|
|
407
|
-
/**
|
|
408
|
-
* Recalculate and apply layout positions for all children.
|
|
409
|
-
* Called automatically by `resize()`. Call manually after
|
|
410
|
-
* adding/removing children without resize.
|
|
411
|
-
*/
|
|
412
|
-
updateLayout() {
|
|
413
|
-
if (this._layoutSuspended) {
|
|
414
|
-
this._layoutDirty = true;
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
this._layoutDirty = false;
|
|
418
|
-
const { direction, justifyContent, alignItems, gap, flexWrap, alignContent } = this._config;
|
|
419
|
-
const [pt, pr, pb, pl] = this._padding;
|
|
420
|
-
const isRow = direction === 'row';
|
|
421
|
-
// Resolve percentage width/height against parent's available space
|
|
422
|
-
if (typeof this._rawWidth === 'string') {
|
|
423
|
-
this._explicitWidth = resolveDimension(this._rawWidth, this._availableWidth) ?? 0;
|
|
424
|
-
}
|
|
425
|
-
if (typeof this._rawHeight === 'string') {
|
|
426
|
-
this._explicitHeight = resolveDimension(this._rawHeight, this._availableHeight) ?? 0;
|
|
427
|
-
}
|
|
428
|
-
const contentW = this._explicitWidth > 0 ? this._explicitWidth - pl - pr : Infinity;
|
|
429
|
-
const contentH = this._explicitHeight > 0 ? this._explicitHeight - pt - pb : Infinity;
|
|
430
|
-
const mainLimit = isRow ? contentW : contentH;
|
|
431
|
-
const crossLimit = isRow ? contentH : contentW;
|
|
432
|
-
// Pass content area to measureChild for percentage resolution
|
|
433
|
-
const pctRefW = contentW < Infinity ? contentW : 0;
|
|
434
|
-
const pctRefH = contentH < Infinity ? contentH : 0;
|
|
435
|
-
// Propagate available size to child FlexContainers and resolve their percentages
|
|
436
|
-
for (const child of this._layoutChildren) {
|
|
437
|
-
if (child instanceof FlexContainer) {
|
|
438
|
-
const fc = child;
|
|
439
|
-
fc._availableWidth = pctRefW;
|
|
440
|
-
fc._availableHeight = pctRefH;
|
|
441
|
-
// If child has percentage dimensions, trigger its layout to resolve them
|
|
442
|
-
if (typeof fc._rawWidth === 'string' || typeof fc._rawHeight === 'string') {
|
|
443
|
-
fc.updateLayout();
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
// Measure children (skip flexExclude — they position themselves)
|
|
448
|
-
const measured = [];
|
|
449
|
-
for (const child of this._layoutChildren) {
|
|
450
|
-
if (child._flexConfig?.flexExclude)
|
|
451
|
-
continue;
|
|
452
|
-
const { w, h, ox, oy } = measureChild(child, pctRefW, pctRefH);
|
|
453
|
-
measured.push({ child, w, h, ox, oy });
|
|
454
|
-
}
|
|
455
|
-
// Split into lines (if wrapping)
|
|
456
|
-
const lines = [];
|
|
457
|
-
if (flexWrap && mainLimit < Infinity) {
|
|
458
|
-
let currentLine = [];
|
|
459
|
-
let lineMain = 0;
|
|
460
|
-
for (const item of measured) {
|
|
461
|
-
const itemMain = isRow ? item.w : item.h;
|
|
462
|
-
const wouldBe = lineMain + (currentLine.length > 0 ? gap : 0) + itemMain;
|
|
463
|
-
if (currentLine.length > 0 && wouldBe > mainLimit) {
|
|
464
|
-
lines.push(currentLine);
|
|
465
|
-
currentLine = [item];
|
|
466
|
-
lineMain = itemMain;
|
|
467
|
-
}
|
|
468
|
-
else {
|
|
469
|
-
currentLine.push(item);
|
|
470
|
-
lineMain = wouldBe;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
if (currentLine.length > 0)
|
|
474
|
-
lines.push(currentLine);
|
|
475
|
-
}
|
|
476
|
-
else {
|
|
477
|
-
lines.push(measured);
|
|
478
|
-
}
|
|
479
|
-
// Compute cross size per line
|
|
480
|
-
const lineCrossSizes = lines.map((line) => {
|
|
481
|
-
let maxCross = 0;
|
|
482
|
-
for (const item of line) {
|
|
483
|
-
const cross = isRow ? item.h : item.w;
|
|
484
|
-
if (cross > maxCross)
|
|
485
|
-
maxCross = cross;
|
|
486
|
-
}
|
|
487
|
-
return maxCross;
|
|
488
|
-
});
|
|
489
|
-
// Compute natural main size (for auto-sizing when no explicit size given)
|
|
490
|
-
let naturalMainSize = 0;
|
|
491
|
-
if (mainLimit === Infinity) {
|
|
492
|
-
for (const line of lines) {
|
|
493
|
-
let lineMain = 0;
|
|
494
|
-
for (const item of line) {
|
|
495
|
-
lineMain += isRow ? item.w : item.h;
|
|
496
|
-
}
|
|
497
|
-
lineMain += gap * Math.max(0, line.length - 1);
|
|
498
|
-
naturalMainSize = Math.max(naturalMainSize, lineMain);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
// Effective main size: explicit if set, otherwise natural content size
|
|
502
|
-
const effectiveMainSize = mainLimit < Infinity ? mainLimit : naturalMainSize;
|
|
503
|
-
// Compute alignContent offsets for multi-line layouts
|
|
504
|
-
const totalLinesCross = lineCrossSizes.reduce((s, v) => s + v, 0) + gap * Math.max(0, lines.length - 1);
|
|
505
|
-
let acOffset = 0;
|
|
506
|
-
let acExtraGap = 0;
|
|
507
|
-
if (lines.length > 1 && crossLimit < Infinity) {
|
|
508
|
-
const freeSpace = Math.max(0, crossLimit - totalLinesCross);
|
|
509
|
-
switch (alignContent) {
|
|
510
|
-
case 'center':
|
|
511
|
-
acOffset = freeSpace / 2;
|
|
512
|
-
break;
|
|
513
|
-
case 'end':
|
|
514
|
-
acOffset = freeSpace;
|
|
515
|
-
break;
|
|
516
|
-
case 'space-between':
|
|
517
|
-
if (lines.length > 1) {
|
|
518
|
-
acExtraGap = freeSpace / (lines.length - 1);
|
|
519
|
-
}
|
|
520
|
-
break;
|
|
521
|
-
case 'stretch':
|
|
522
|
-
if (lines.length > 0) {
|
|
523
|
-
const extra = freeSpace / lines.length;
|
|
524
|
-
for (let i = 0; i < lineCrossSizes.length; i++) {
|
|
525
|
-
lineCrossSizes[i] += extra;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
break;
|
|
529
|
-
// 'start' — no adjustment
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
// Layout each line
|
|
533
|
-
let crossOffset = (isRow ? pt : pl) + acOffset;
|
|
534
|
-
for (let i = 0; i < lines.length; i++) {
|
|
535
|
-
const line = lines[i];
|
|
536
|
-
const lineCross = lineCrossSizes[i];
|
|
537
|
-
const mainStart = isRow ? pl : pt;
|
|
538
|
-
// Offset items by padding
|
|
539
|
-
const tempItems = line.map((item) => ({ ...item }));
|
|
540
|
-
// Cross size for alignment: use container cross size for single-line, line cross for multi-line
|
|
541
|
-
const effectiveCross = lines.length === 1 && crossLimit < Infinity ? crossLimit : lineCross;
|
|
542
|
-
layoutLine(tempItems, isRow, effectiveMainSize, mainLimit < Infinity ? justifyContent : 'start', alignItems, gap, crossOffset, effectiveCross);
|
|
543
|
-
// Apply main-axis padding offset
|
|
544
|
-
for (const item of tempItems) {
|
|
545
|
-
const origChild = line.find((l) => l.child === item.child);
|
|
546
|
-
origChild.child.x = item.child.x + (isRow ? mainStart : 0);
|
|
547
|
-
origChild.child.y = item.child.y + (isRow ? 0 : mainStart);
|
|
548
|
-
}
|
|
549
|
-
crossOffset += lineCross + gap + acExtraGap;
|
|
550
|
-
}
|
|
551
|
-
// Compute and store actual dimensions for measureChild() and getContentSize()
|
|
552
|
-
let totalCrossNatural = 0;
|
|
553
|
-
for (let i = 0; i < lineCrossSizes.length; i++) {
|
|
554
|
-
totalCrossNatural += lineCrossSizes[i];
|
|
555
|
-
if (i < lineCrossSizes.length - 1)
|
|
556
|
-
totalCrossNatural += gap;
|
|
557
|
-
}
|
|
558
|
-
if (isRow) {
|
|
559
|
-
this._computedWidth = this._explicitWidth > 0 ? this._explicitWidth : (pl + naturalMainSize + pr);
|
|
560
|
-
this._computedHeight = this._explicitHeight > 0 ? this._explicitHeight : (pt + totalCrossNatural + pb);
|
|
561
|
-
}
|
|
562
|
-
else {
|
|
563
|
-
this._computedWidth = this._explicitWidth > 0 ? this._explicitWidth : (pl + totalCrossNatural + pr);
|
|
564
|
-
this._computedHeight = this._explicitHeight > 0 ? this._explicitHeight : (pt + naturalMainSize + pb);
|
|
565
|
-
}
|
|
566
|
-
// Position flexExclude children (absolute positioning).
|
|
567
|
-
// All position props describe the visual (bounds) rectangle. We derive the
|
|
568
|
-
// visual rectangle directly from `getLocalBounds()` rather than the
|
|
569
|
-
// layout-config `w`/`h` returned by measureChild — those may differ from
|
|
570
|
-
// actual bounds when the user pins `layoutWidth`/`layoutHeight` to a value
|
|
571
|
-
// that doesn't match the child's real visual extent (e.g. a Button whose
|
|
572
|
-
// text overflows its configured `width`). Mixing layout size with real
|
|
573
|
-
// bounds-origin would shift the visual center off the requested point.
|
|
574
|
-
// `centerX/centerY` take precedence over `left/right` / `top/bottom` on each axis.
|
|
575
|
-
for (const child of this._layoutChildren) {
|
|
576
|
-
if (!child._flexConfig?.flexExclude)
|
|
577
|
-
continue;
|
|
578
|
-
const cfg = child._flexConfig;
|
|
579
|
-
const bounds = child.getLocalBounds();
|
|
580
|
-
const cw = this._computedWidth;
|
|
581
|
-
const ch = this._computedHeight;
|
|
582
|
-
const centerX = resolveDimension(cfg.centerX, pctRefW);
|
|
583
|
-
if (centerX !== undefined)
|
|
584
|
-
child.x = centerX - bounds.x - bounds.width / 2;
|
|
585
|
-
else if (cfg.left !== undefined)
|
|
586
|
-
child.x = cfg.left - bounds.x;
|
|
587
|
-
else if (cfg.right !== undefined)
|
|
588
|
-
child.x = cw - cfg.right - bounds.x - bounds.width;
|
|
589
|
-
const centerY = resolveDimension(cfg.centerY, pctRefH);
|
|
590
|
-
if (centerY !== undefined)
|
|
591
|
-
child.y = centerY - bounds.y - bounds.height / 2;
|
|
592
|
-
else if (cfg.top !== undefined)
|
|
593
|
-
child.y = cfg.top - bounds.y;
|
|
594
|
-
else if (cfg.bottom !== undefined)
|
|
595
|
-
child.y = ch - cfg.bottom - bounds.y - bounds.height;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
/** Computed content size (after layout) */
|
|
599
|
-
getContentSize() {
|
|
600
|
-
if (this._layoutDirty)
|
|
601
|
-
this.updateLayout();
|
|
602
|
-
return { width: this._computedWidth, height: this._computedHeight };
|
|
603
|
-
}
|
|
604
|
-
/** React reconciler update hook — applies changed config props */
|
|
605
|
-
updateConfig(changed) {
|
|
606
|
-
if ('direction' in changed)
|
|
607
|
-
this.setDirection(changed.direction);
|
|
608
|
-
if ('justifyContent' in changed)
|
|
609
|
-
this.setJustifyContent(changed.justifyContent);
|
|
610
|
-
if ('alignItems' in changed)
|
|
611
|
-
this.setAlignItems(changed.alignItems);
|
|
612
|
-
if ('gap' in changed)
|
|
613
|
-
this.setGap(changed.gap);
|
|
614
|
-
if ('padding' in changed || 'paddingTop' in changed || 'paddingRight' in changed || 'paddingBottom' in changed || 'paddingLeft' in changed) {
|
|
615
|
-
this._padding = resolvePadding(changed);
|
|
616
|
-
this._layoutDirty = true;
|
|
617
|
-
}
|
|
618
|
-
if ('flexWrap' in changed) {
|
|
619
|
-
this._config.flexWrap = changed.flexWrap;
|
|
620
|
-
this._layoutDirty = true;
|
|
621
|
-
}
|
|
622
|
-
if ('alignContent' in changed) {
|
|
623
|
-
this._config.alignContent = changed.alignContent;
|
|
624
|
-
this._layoutDirty = true;
|
|
625
|
-
}
|
|
626
|
-
if ('width' in changed || 'height' in changed) {
|
|
627
|
-
const w = changed.width ?? this._rawWidth;
|
|
628
|
-
const h = changed.height ?? this._rawHeight;
|
|
629
|
-
this._rawWidth = w;
|
|
630
|
-
this._rawHeight = h;
|
|
631
|
-
if (typeof w === 'number' && typeof h === 'number') {
|
|
632
|
-
this.resize(w, h);
|
|
633
|
-
}
|
|
634
|
-
else {
|
|
635
|
-
// Percentage — will resolve in updateLayout
|
|
636
|
-
this._explicitWidth = typeof w === 'number' ? w : 0;
|
|
637
|
-
this._explicitHeight = typeof h === 'number' ? h : 0;
|
|
638
|
-
this._layoutDirty = true;
|
|
639
|
-
if (!this._layoutSuspended)
|
|
640
|
-
this.updateLayout();
|
|
641
|
-
}
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
if (this._layoutDirty && !this._layoutSuspended)
|
|
645
|
-
this.updateLayout();
|
|
646
|
-
}
|
|
647
|
-
destroy(options) {
|
|
648
|
-
this._layoutChildren.length = 0;
|
|
649
|
-
super.destroy(options);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Collection of easing functions for use with Tween and Timeline.
|
|
655
|
-
*
|
|
656
|
-
* All functions take a progress value t (0..1) and return the eased value.
|
|
657
|
-
*/
|
|
658
|
-
const Easing = {
|
|
659
|
-
easeOutQuad: (t) => t * (2 - t),
|
|
660
|
-
easeInCubic: (t) => t * t * t,
|
|
661
|
-
easeOutCubic: (t) => --t * t * t + 1,
|
|
662
|
-
easeOutBack: (t) => {
|
|
663
|
-
const c1 = 1.70158;
|
|
664
|
-
const c3 = c1 + 1;
|
|
665
|
-
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
666
|
-
}};
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Lightweight tween system integrated with PixiJS Ticker.
|
|
670
|
-
* Zero external dependencies — no GSAP required.
|
|
671
|
-
*
|
|
672
|
-
* All tweens return a Promise that resolves on completion.
|
|
673
|
-
*
|
|
674
|
-
* @example
|
|
675
|
-
* ```ts
|
|
676
|
-
* // Fade in a sprite
|
|
677
|
-
* await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
|
|
678
|
-
*
|
|
679
|
-
* // Move and wait
|
|
680
|
-
* await Tween.to(sprite, { x: 500 }, 300);
|
|
681
|
-
*
|
|
682
|
-
* // From a starting value
|
|
683
|
-
* await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
|
|
684
|
-
* ```
|
|
685
|
-
*/
|
|
686
|
-
class Tween {
|
|
687
|
-
static _tweens = [];
|
|
688
|
-
static _tickerAdded = false;
|
|
689
|
-
/**
|
|
690
|
-
* Animate properties from current values to target values.
|
|
691
|
-
*
|
|
692
|
-
* @param target - Object to animate (Sprite, Container, etc.)
|
|
693
|
-
* @param props - Target property values
|
|
694
|
-
* @param duration - Duration in milliseconds
|
|
695
|
-
* @param easing - Easing function (default: easeOutQuad)
|
|
696
|
-
* @param onUpdate - Progress callback (0..1)
|
|
697
|
-
*/
|
|
698
|
-
static to(target, props, duration, easing, onUpdate) {
|
|
699
|
-
return new Promise((resolve) => {
|
|
700
|
-
// Capture starting values
|
|
701
|
-
const from = {};
|
|
702
|
-
for (const key of Object.keys(props)) {
|
|
703
|
-
from[key] = Tween.getProperty(target, key);
|
|
704
|
-
}
|
|
705
|
-
const tween = {
|
|
706
|
-
target,
|
|
707
|
-
from,
|
|
708
|
-
to: { ...props },
|
|
709
|
-
duration: Math.max(1, duration),
|
|
710
|
-
easing: easing ?? Easing.easeOutQuad,
|
|
711
|
-
elapsed: 0,
|
|
712
|
-
delay: 0,
|
|
713
|
-
resolve,
|
|
714
|
-
onUpdate,
|
|
715
|
-
};
|
|
716
|
-
Tween._tweens.push(tween);
|
|
717
|
-
Tween.ensureTicker();
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
/**
|
|
721
|
-
* Animate properties from given values to current values.
|
|
722
|
-
*/
|
|
723
|
-
static from(target, props, duration, easing, onUpdate) {
|
|
724
|
-
// Capture current values as "to"
|
|
725
|
-
const to = {};
|
|
726
|
-
for (const key of Object.keys(props)) {
|
|
727
|
-
to[key] = Tween.getProperty(target, key);
|
|
728
|
-
Tween.setProperty(target, key, props[key]);
|
|
729
|
-
}
|
|
730
|
-
return Tween.to(target, to, duration, easing, onUpdate);
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* Animate from one set of values to another.
|
|
734
|
-
*/
|
|
735
|
-
static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
|
|
736
|
-
// Set starting values
|
|
737
|
-
for (const key of Object.keys(fromProps)) {
|
|
738
|
-
Tween.setProperty(target, key, fromProps[key]);
|
|
739
|
-
}
|
|
740
|
-
return Tween.to(target, toProps, duration, easing, onUpdate);
|
|
741
|
-
}
|
|
742
|
-
/**
|
|
743
|
-
* Wait for a given duration (useful in timelines).
|
|
744
|
-
* Uses PixiJS Ticker for consistent timing with other tweens.
|
|
745
|
-
*/
|
|
746
|
-
static delay(ms) {
|
|
747
|
-
return new Promise((resolve) => {
|
|
748
|
-
let elapsed = 0;
|
|
749
|
-
const onTick = (ticker) => {
|
|
750
|
-
elapsed += ticker.deltaMS;
|
|
751
|
-
if (elapsed >= ms) {
|
|
752
|
-
Ticker.shared.remove(onTick);
|
|
753
|
-
resolve();
|
|
754
|
-
}
|
|
755
|
-
};
|
|
756
|
-
Ticker.shared.add(onTick);
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
/**
|
|
760
|
-
* Kill all tweens on a target.
|
|
761
|
-
*/
|
|
762
|
-
static killTweensOf(target) {
|
|
763
|
-
Tween._tweens = Tween._tweens.filter((tw) => {
|
|
764
|
-
if (tw.target === target) {
|
|
765
|
-
tw.resolve();
|
|
766
|
-
return false;
|
|
767
|
-
}
|
|
768
|
-
return true;
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
/**
|
|
772
|
-
* Kill all active tweens.
|
|
773
|
-
*/
|
|
774
|
-
static killAll() {
|
|
775
|
-
for (const tw of Tween._tweens) {
|
|
776
|
-
tw.resolve();
|
|
777
|
-
}
|
|
778
|
-
Tween._tweens.length = 0;
|
|
779
|
-
}
|
|
780
|
-
/** Number of active tweens */
|
|
781
|
-
static get activeTweens() {
|
|
782
|
-
return Tween._tweens.length;
|
|
783
|
-
}
|
|
784
|
-
/**
|
|
785
|
-
* Reset the tween system — kill all tweens and remove the ticker.
|
|
786
|
-
* Useful for cleanup between game instances, tests, or hot-reload.
|
|
787
|
-
*/
|
|
788
|
-
static reset() {
|
|
789
|
-
for (const tw of Tween._tweens) {
|
|
790
|
-
tw.resolve();
|
|
791
|
-
}
|
|
792
|
-
Tween._tweens.length = 0;
|
|
793
|
-
if (Tween._tickerAdded) {
|
|
794
|
-
Ticker.shared.remove(Tween.tick);
|
|
795
|
-
Tween._tickerAdded = false;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
// ─── Internal ──────────────────────────────────────────
|
|
799
|
-
static ensureTicker() {
|
|
800
|
-
if (Tween._tickerAdded)
|
|
801
|
-
return;
|
|
802
|
-
Tween._tickerAdded = true;
|
|
803
|
-
Ticker.shared.add(Tween.tick);
|
|
804
|
-
}
|
|
805
|
-
static tick = (ticker) => {
|
|
806
|
-
const dt = ticker.deltaMS;
|
|
807
|
-
const completed = [];
|
|
808
|
-
for (const tw of Tween._tweens) {
|
|
809
|
-
tw.elapsed += dt;
|
|
810
|
-
if (tw.elapsed < tw.delay)
|
|
811
|
-
continue;
|
|
812
|
-
const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
|
|
813
|
-
const t = tw.easing(raw);
|
|
814
|
-
// Interpolate each property
|
|
815
|
-
for (const key of Object.keys(tw.to)) {
|
|
816
|
-
const start = tw.from[key];
|
|
817
|
-
const end = tw.to[key];
|
|
818
|
-
const value = start + (end - start) * t;
|
|
819
|
-
Tween.setProperty(tw.target, key, value);
|
|
820
|
-
}
|
|
821
|
-
tw.onUpdate?.(raw);
|
|
822
|
-
if (raw >= 1) {
|
|
823
|
-
completed.push(tw);
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
// Remove completed tweens
|
|
827
|
-
for (const tw of completed) {
|
|
828
|
-
const idx = Tween._tweens.indexOf(tw);
|
|
829
|
-
if (idx !== -1)
|
|
830
|
-
Tween._tweens.splice(idx, 1);
|
|
831
|
-
tw.resolve();
|
|
832
|
-
}
|
|
833
|
-
// Remove ticker when no active tweens
|
|
834
|
-
if (Tween._tweens.length === 0 && Tween._tickerAdded) {
|
|
835
|
-
Ticker.shared.remove(Tween.tick);
|
|
836
|
-
Tween._tickerAdded = false;
|
|
837
|
-
}
|
|
838
|
-
};
|
|
839
|
-
/**
|
|
840
|
-
* Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
|
|
841
|
-
*/
|
|
842
|
-
static getProperty(target, key) {
|
|
843
|
-
const parts = key.split('.');
|
|
844
|
-
let obj = target;
|
|
845
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
846
|
-
obj = obj[parts[i]];
|
|
847
|
-
}
|
|
848
|
-
return obj[parts[parts.length - 1]] ?? 0;
|
|
849
|
-
}
|
|
850
|
-
/**
|
|
851
|
-
* Set a potentially nested property.
|
|
852
|
-
*/
|
|
853
|
-
static setProperty(target, key, value) {
|
|
854
|
-
const parts = key.split('.');
|
|
855
|
-
let obj = target;
|
|
856
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
857
|
-
obj = obj[parts[i]];
|
|
858
|
-
}
|
|
859
|
-
obj[parts[parts.length - 1]] = value;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
const DEFAULT_COLORS = {
|
|
864
|
-
default: 0xffd700,
|
|
865
|
-
hover: 0xffe44d,
|
|
866
|
-
pressed: 0xccac00,
|
|
867
|
-
disabled: 0x666666,
|
|
868
|
-
};
|
|
869
|
-
function makeGraphicsView(w, h, radius, color) {
|
|
870
|
-
const g = new Graphics();
|
|
871
|
-
g.roundRect(-w / 2, -h / 2, w, h, radius).fill(color);
|
|
872
|
-
return g;
|
|
873
|
-
}
|
|
874
|
-
/**
|
|
875
|
-
* Interactive button with per-state custom views and animations.
|
|
876
|
-
*
|
|
877
|
-
* Each visual state accepts a `ViewInput`: texture name, Texture, or any Container
|
|
878
|
-
* (Sprite, NineSliceSprite, AnimatedSprite, custom artwork, etc).
|
|
879
|
-
* Falls back to colored Graphics when no custom view is provided.
|
|
880
|
-
*
|
|
881
|
-
* @example
|
|
882
|
-
* ```ts
|
|
883
|
-
* // Graphics-based (quick prototyping)
|
|
884
|
-
* const btn = new Button({
|
|
885
|
-
* width: 200, height: 60, borderRadius: 12,
|
|
886
|
-
* colors: { default: 0x22aa22, hover: 0x33cc33 },
|
|
887
|
-
* text: 'SPIN',
|
|
888
|
-
* onPress: () => spin(),
|
|
889
|
-
* });
|
|
890
|
-
*
|
|
891
|
-
* // Asset-based (production art)
|
|
892
|
-
* const btn = new Button({
|
|
893
|
-
* defaultView: 'btn-idle',
|
|
894
|
-
* hoverView: 'btn-hover',
|
|
895
|
-
* pressedView: 'btn-pressed',
|
|
896
|
-
* disabledView: 'btn-disabled',
|
|
897
|
-
* text: 'SPIN',
|
|
898
|
-
* onPress: () => spin(),
|
|
899
|
-
* });
|
|
900
|
-
*
|
|
901
|
-
* // Custom Container view
|
|
902
|
-
* const btn = new Button({
|
|
903
|
-
* defaultView: myAnimatedSprite,
|
|
904
|
-
* text: 'SPIN',
|
|
905
|
-
* });
|
|
906
|
-
* ```
|
|
907
|
-
*/
|
|
908
|
-
class Button extends Container {
|
|
909
|
-
__uiComponent = true;
|
|
910
|
-
_views = new Map();
|
|
911
|
-
_state = 'default';
|
|
912
|
-
_enabled = true;
|
|
913
|
-
_config;
|
|
914
|
-
_textObj = null;
|
|
915
|
-
/** Press callback */
|
|
916
|
-
onPress;
|
|
917
|
-
constructor(config = {}) {
|
|
918
|
-
super();
|
|
919
|
-
this._config = {
|
|
920
|
-
width: config.width ?? 200,
|
|
921
|
-
height: config.height ?? 60,
|
|
922
|
-
borderRadius: config.borderRadius ?? 8,
|
|
923
|
-
pressScale: config.pressScale ?? 0.95,
|
|
924
|
-
animationDuration: config.animationDuration ?? 100,
|
|
925
|
-
...config,
|
|
926
|
-
};
|
|
927
|
-
this.onPress = config.onPress;
|
|
928
|
-
this._buildViews(config);
|
|
929
|
-
// Text
|
|
930
|
-
if (config.text) {
|
|
931
|
-
this._textObj = new Text({
|
|
932
|
-
text: config.text,
|
|
933
|
-
style: {
|
|
934
|
-
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
935
|
-
fontSize: 20,
|
|
936
|
-
fill: 0xffffff,
|
|
937
|
-
fontWeight: 'bold',
|
|
938
|
-
...config.textStyle,
|
|
939
|
-
},
|
|
940
|
-
});
|
|
941
|
-
this._textObj.anchor.set(0.5);
|
|
942
|
-
this.addChild(this._textObj);
|
|
943
|
-
}
|
|
944
|
-
// Interaction
|
|
945
|
-
this.eventMode = 'static';
|
|
946
|
-
this.cursor = 'pointer';
|
|
947
|
-
this.on('pointerover', this._onPointerOver, this);
|
|
948
|
-
this.on('pointerout', this._onPointerOut, this);
|
|
949
|
-
this.on('pointerdown', this._onPointerDown, this);
|
|
950
|
-
this.on('pointerup', this._onPointerUp, this);
|
|
951
|
-
this.on('pointerupoutside', this._onPointerUpOutside, this);
|
|
952
|
-
if (config.disabled) {
|
|
953
|
-
this.enabled = false;
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
/** Current button state */
|
|
957
|
-
get state() {
|
|
958
|
-
return this._state;
|
|
959
|
-
}
|
|
960
|
-
/** Enable the button */
|
|
961
|
-
enable() {
|
|
962
|
-
this.enabled = true;
|
|
963
|
-
}
|
|
964
|
-
/** Disable the button */
|
|
965
|
-
disable() {
|
|
966
|
-
this.enabled = false;
|
|
967
|
-
}
|
|
968
|
-
/** Whether the button is enabled */
|
|
969
|
-
get enabled() {
|
|
970
|
-
return this._enabled;
|
|
971
|
-
}
|
|
972
|
-
set enabled(value) {
|
|
973
|
-
this._enabled = value;
|
|
974
|
-
this.cursor = value ? 'pointer' : 'default';
|
|
975
|
-
this.eventMode = value ? 'static' : 'none';
|
|
976
|
-
this._setState(value ? 'default' : 'disabled');
|
|
977
|
-
}
|
|
978
|
-
/** Whether the button is disabled */
|
|
979
|
-
get disabled() {
|
|
980
|
-
return !this._enabled;
|
|
981
|
-
}
|
|
982
|
-
/** Update button text */
|
|
983
|
-
set text(value) {
|
|
984
|
-
if (this._textObj) {
|
|
985
|
-
this._textObj.text = value;
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
// ─── View building ──────────────────────────────────
|
|
989
|
-
_buildViews(config) {
|
|
990
|
-
const colorMap = { ...DEFAULT_COLORS, ...config.colors };
|
|
991
|
-
const { width, height, borderRadius } = this._config;
|
|
992
|
-
const stateViews = {
|
|
993
|
-
default: config.defaultView,
|
|
994
|
-
hover: config.hoverView,
|
|
995
|
-
pressed: config.pressedView,
|
|
996
|
-
disabled: config.disabledView,
|
|
997
|
-
};
|
|
998
|
-
const states = ['default', 'hover', 'pressed', 'disabled'];
|
|
999
|
-
for (const state of states) {
|
|
1000
|
-
const customView = resolveView(stateViews[state]);
|
|
1001
|
-
const view = customView ?? makeGraphicsView(width, height, borderRadius, colorMap[state]);
|
|
1002
|
-
view.visible = state === 'default';
|
|
1003
|
-
this._views.set(state, view);
|
|
1004
|
-
this.addChild(view);
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
_rebuildViews() {
|
|
1008
|
-
for (const [, view] of this._views) {
|
|
1009
|
-
this.removeChild(view);
|
|
1010
|
-
view.destroy();
|
|
1011
|
-
}
|
|
1012
|
-
this._views.clear();
|
|
1013
|
-
this._buildViews(this._config);
|
|
1014
|
-
// Re-insert views before text
|
|
1015
|
-
if (this._textObj && this._textObj.parent === this) {
|
|
1016
|
-
this.setChildIndex(this._textObj, this.children.length - 1);
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
// ─── State management ───────────────────────────────
|
|
1020
|
-
_setState(state) {
|
|
1021
|
-
if (this._state === state)
|
|
1022
|
-
return;
|
|
1023
|
-
this._state = state;
|
|
1024
|
-
for (const [s, view] of this._views) {
|
|
1025
|
-
view.visible = s === state;
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
_onPointerOver() {
|
|
1029
|
-
if (!this._enabled)
|
|
1030
|
-
return;
|
|
1031
|
-
this._setState('hover');
|
|
1032
|
-
Tween.killTweensOf(this);
|
|
1033
|
-
Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
|
|
1034
|
-
}
|
|
1035
|
-
_onPointerOut() {
|
|
1036
|
-
if (!this._enabled)
|
|
1037
|
-
return;
|
|
1038
|
-
this._setState('default');
|
|
1039
|
-
Tween.killTweensOf(this);
|
|
1040
|
-
Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
|
|
1041
|
-
}
|
|
1042
|
-
_onPointerDown() {
|
|
1043
|
-
if (!this._enabled)
|
|
1044
|
-
return;
|
|
1045
|
-
this._setState('pressed');
|
|
1046
|
-
Tween.killTweensOf(this);
|
|
1047
|
-
const s = this._config.pressScale;
|
|
1048
|
-
Tween.to(this, { 'scale.x': s, 'scale.y': s }, this._config.animationDuration, Easing.easeOutQuad);
|
|
1049
|
-
}
|
|
1050
|
-
_onPointerUp() {
|
|
1051
|
-
if (!this._enabled)
|
|
1052
|
-
return;
|
|
1053
|
-
this._setState('hover');
|
|
1054
|
-
Tween.killTweensOf(this);
|
|
1055
|
-
Tween.to(this, { 'scale.x': 1.03, 'scale.y': 1.03 }, this._config.animationDuration, Easing.easeOutQuad);
|
|
1056
|
-
this.onPress?.();
|
|
1057
|
-
}
|
|
1058
|
-
_onPointerUpOutside() {
|
|
1059
|
-
if (!this._enabled)
|
|
1060
|
-
return;
|
|
1061
|
-
this._setState('default');
|
|
1062
|
-
Tween.killTweensOf(this);
|
|
1063
|
-
Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutQuad);
|
|
1064
|
-
}
|
|
1065
|
-
/** React reconciler update hook */
|
|
1066
|
-
updateConfig(changed) {
|
|
1067
|
-
if ('text' in changed && this._textObj)
|
|
1068
|
-
this._textObj.text = changed.text;
|
|
1069
|
-
if ('disabled' in changed)
|
|
1070
|
-
this.enabled = !changed.disabled;
|
|
1071
|
-
if ('onPress' in changed)
|
|
1072
|
-
this.onPress = changed.onPress;
|
|
1073
|
-
const structural = [
|
|
1074
|
-
'colors', 'width', 'height', 'borderRadius', 'textStyle',
|
|
1075
|
-
'defaultView', 'hoverView', 'pressedView', 'disabledView',
|
|
1076
|
-
];
|
|
1077
|
-
const needsRebuild = structural.some((k) => k in changed);
|
|
1078
|
-
if (needsRebuild) {
|
|
1079
|
-
Object.assign(this._config, changed);
|
|
1080
|
-
this._rebuildViews();
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
destroy(options) {
|
|
1084
|
-
Tween.killTweensOf(this);
|
|
1085
|
-
this.off('pointerover', this._onPointerOver, this);
|
|
1086
|
-
this.off('pointerout', this._onPointerOut, this);
|
|
1087
|
-
this.off('pointerdown', this._onPointerDown, this);
|
|
1088
|
-
this.off('pointerup', this._onPointerUp, this);
|
|
1089
|
-
this.off('pointerupoutside', this._onPointerUpOutside, this);
|
|
1090
|
-
this._views.clear();
|
|
1091
|
-
this._textObj = null;
|
|
1092
|
-
super.destroy(options);
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
/**
|
|
1097
|
-
* Horizontal progress bar with optional custom track/fill views.
|
|
1098
|
-
*
|
|
1099
|
-
* Supports asset-based skinning: provide `trackView` and/or `fillView`
|
|
1100
|
-
* as texture names, Textures, or any Container (NineSliceSprite, custom artwork, etc).
|
|
1101
|
-
* Falls back to colored Graphics when no custom views are provided.
|
|
1102
|
-
*
|
|
1103
|
-
* @example
|
|
1104
|
-
* ```ts
|
|
1105
|
-
* // Graphics-based (quick prototyping)
|
|
1106
|
-
* const bar = new ProgressBar({ width: 300, height: 20, fillColor: 0x22cc22 });
|
|
1107
|
-
* bar.progress = 0.5;
|
|
1108
|
-
*
|
|
1109
|
-
* // Asset-based (production art)
|
|
1110
|
-
* const bar = new ProgressBar({
|
|
1111
|
-
* width: 300, height: 20,
|
|
1112
|
-
* trackView: 'bar-track',
|
|
1113
|
-
* fillView: new NineSliceSprite({ texture: 'bar-fill', ... }),
|
|
1114
|
-
* });
|
|
1115
|
-
* bar.progress = 0.75;
|
|
1116
|
-
* ```
|
|
1117
|
-
*/
|
|
1118
|
-
class ProgressBar extends Container {
|
|
1119
|
-
__uiComponent = true;
|
|
1120
|
-
_track;
|
|
1121
|
-
_fill;
|
|
1122
|
-
_fillMask;
|
|
1123
|
-
_borderGfx;
|
|
1124
|
-
_config;
|
|
1125
|
-
_progress = 0;
|
|
1126
|
-
_displayedProgress = 0;
|
|
1127
|
-
constructor(config = {}) {
|
|
1128
|
-
super();
|
|
1129
|
-
this._config = {
|
|
1130
|
-
width: config.width ?? 300,
|
|
1131
|
-
height: config.height ?? 16,
|
|
1132
|
-
borderRadius: config.borderRadius ?? 8,
|
|
1133
|
-
fillColor: config.fillColor ?? 0xffd700,
|
|
1134
|
-
trackColor: config.trackColor ?? 0x333333,
|
|
1135
|
-
borderColor: config.borderColor ?? 0x555555,
|
|
1136
|
-
borderWidth: config.borderWidth ?? 1,
|
|
1137
|
-
animated: config.animated ?? true,
|
|
1138
|
-
animationSpeed: config.animationSpeed ?? 0.1,
|
|
1139
|
-
};
|
|
1140
|
-
const { width, height, borderRadius, fillColor, trackColor, borderColor, borderWidth } = this._config;
|
|
1141
|
-
// Track background — custom view or Graphics
|
|
1142
|
-
const customTrack = resolveView(config.trackView);
|
|
1143
|
-
if (customTrack) {
|
|
1144
|
-
customTrack.width = width;
|
|
1145
|
-
customTrack.height = height;
|
|
1146
|
-
this._track = customTrack;
|
|
1147
|
-
}
|
|
1148
|
-
else {
|
|
1149
|
-
const g = new Graphics();
|
|
1150
|
-
g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
|
|
1151
|
-
this._track = g;
|
|
1152
|
-
}
|
|
1153
|
-
this.addChild(this._track);
|
|
1154
|
-
// Fill bar — custom view or Graphics
|
|
1155
|
-
const customFill = resolveView(config.fillView);
|
|
1156
|
-
if (customFill) {
|
|
1157
|
-
customFill.x = borderWidth;
|
|
1158
|
-
customFill.y = borderWidth;
|
|
1159
|
-
customFill.width = width - borderWidth * 2;
|
|
1160
|
-
customFill.height = height - borderWidth * 2;
|
|
1161
|
-
this._fill = customFill;
|
|
1162
|
-
}
|
|
1163
|
-
else {
|
|
1164
|
-
const g = new Graphics();
|
|
1165
|
-
g.roundRect(borderWidth, borderWidth, width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1)).fill(fillColor);
|
|
1166
|
-
this._fill = g;
|
|
1167
|
-
}
|
|
1168
|
-
this.addChild(this._fill);
|
|
1169
|
-
// Mask for the fill (controls visible width)
|
|
1170
|
-
this._fillMask = new Graphics();
|
|
1171
|
-
this._fillMask.rect(0, 0, 0, height).fill(0xffffff);
|
|
1172
|
-
this.addChild(this._fillMask);
|
|
1173
|
-
this._fill.mask = this._fillMask;
|
|
1174
|
-
// Border overlay
|
|
1175
|
-
this._borderGfx = new Graphics();
|
|
1176
|
-
if (borderColor !== undefined && borderWidth > 0) {
|
|
1177
|
-
this._borderGfx
|
|
1178
|
-
.roundRect(0, 0, width, height, borderRadius)
|
|
1179
|
-
.stroke({ color: borderColor, width: borderWidth });
|
|
1180
|
-
}
|
|
1181
|
-
this.addChild(this._borderGfx);
|
|
1182
|
-
}
|
|
1183
|
-
/** Get/set progress (0..1) */
|
|
1184
|
-
get progress() {
|
|
1185
|
-
return this._progress;
|
|
1186
|
-
}
|
|
1187
|
-
set progress(value) {
|
|
1188
|
-
this._progress = Math.max(0, Math.min(1, value));
|
|
1189
|
-
if (!this._config.animated) {
|
|
1190
|
-
this._displayedProgress = this._progress;
|
|
1191
|
-
this.updateMask();
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
/**
|
|
1195
|
-
* Call each frame if animated is true.
|
|
1196
|
-
*/
|
|
1197
|
-
update(_dt) {
|
|
1198
|
-
if (!this._config.animated)
|
|
1199
|
-
return;
|
|
1200
|
-
if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
|
|
1201
|
-
this._displayedProgress = this._progress;
|
|
1202
|
-
this.updateMask();
|
|
1203
|
-
return;
|
|
1204
|
-
}
|
|
1205
|
-
this._displayedProgress +=
|
|
1206
|
-
(this._progress - this._displayedProgress) * this._config.animationSpeed;
|
|
1207
|
-
this.updateMask();
|
|
1208
|
-
}
|
|
1209
|
-
/** React reconciler update hook */
|
|
1210
|
-
updateConfig(changed) {
|
|
1211
|
-
if ('progress' in changed)
|
|
1212
|
-
this.progress = changed.progress;
|
|
1213
|
-
if ('animated' in changed)
|
|
1214
|
-
this._config.animated = changed.animated;
|
|
1215
|
-
if ('animationSpeed' in changed)
|
|
1216
|
-
this._config.animationSpeed = changed.animationSpeed;
|
|
1217
|
-
}
|
|
1218
|
-
updateMask() {
|
|
1219
|
-
const w = this._config.width * this._displayedProgress;
|
|
1220
|
-
this._fillMask.clear();
|
|
1221
|
-
this._fillMask.rect(0, 0, w, this._config.height).fill(0xffffff);
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
/**
|
|
1226
|
-
* Enhanced text label with auto-fit scaling and currency formatting.
|
|
1227
|
-
*
|
|
1228
|
-
* @example
|
|
1229
|
-
* ```ts
|
|
1230
|
-
* const label = new Label({
|
|
1231
|
-
* text: 'BALANCE',
|
|
1232
|
-
* style: { fontSize: 24, fill: 0xffd700 },
|
|
1233
|
-
* maxWidth: 200,
|
|
1234
|
-
* autoFit: true,
|
|
1235
|
-
* });
|
|
1236
|
-
* ```
|
|
1237
|
-
*/
|
|
1238
|
-
class Label extends Container {
|
|
1239
|
-
__uiComponent = true;
|
|
1240
|
-
_text;
|
|
1241
|
-
_maxWidth;
|
|
1242
|
-
_autoFit;
|
|
1243
|
-
constructor(config = {}) {
|
|
1244
|
-
super();
|
|
1245
|
-
this._maxWidth = config.maxWidth ?? Infinity;
|
|
1246
|
-
this._autoFit = config.autoFit ?? false;
|
|
1247
|
-
this._text = new Text({
|
|
1248
|
-
text: config.text ?? '',
|
|
1249
|
-
style: {
|
|
1250
|
-
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
1251
|
-
fontSize: 24,
|
|
1252
|
-
fill: 0xffffff,
|
|
1253
|
-
...config.style,
|
|
1254
|
-
},
|
|
1255
|
-
});
|
|
1256
|
-
this._text.anchor.set(0.5);
|
|
1257
|
-
this.addChild(this._text);
|
|
1258
|
-
this.fitText();
|
|
1259
|
-
}
|
|
1260
|
-
/** Get/set the displayed text */
|
|
1261
|
-
get text() {
|
|
1262
|
-
return this._text.text;
|
|
1263
|
-
}
|
|
1264
|
-
set text(value) {
|
|
1265
|
-
this._text.text = value;
|
|
1266
|
-
this.fitText();
|
|
1267
|
-
}
|
|
1268
|
-
/** Get/set the text style */
|
|
1269
|
-
get style() {
|
|
1270
|
-
return this._text.style;
|
|
1271
|
-
}
|
|
1272
|
-
/** Set max width constraint */
|
|
1273
|
-
set maxWidth(value) {
|
|
1274
|
-
this._maxWidth = value;
|
|
1275
|
-
this.fitText();
|
|
1276
|
-
}
|
|
1277
|
-
/**
|
|
1278
|
-
* Format and display a number as currency.
|
|
1279
|
-
*
|
|
1280
|
-
* @param amount - The numeric amount
|
|
1281
|
-
* @param currency - Currency code (e.g., 'USD', 'EUR')
|
|
1282
|
-
* @param locale - Locale string (default: 'en-US')
|
|
1283
|
-
*/
|
|
1284
|
-
setCurrency(amount, currency, locale = 'en-US') {
|
|
1285
|
-
try {
|
|
1286
|
-
this.text = new Intl.NumberFormat(locale, {
|
|
1287
|
-
style: 'currency',
|
|
1288
|
-
currency,
|
|
1289
|
-
minimumFractionDigits: 2,
|
|
1290
|
-
maximumFractionDigits: 2,
|
|
1291
|
-
}).format(amount);
|
|
1292
|
-
}
|
|
1293
|
-
catch {
|
|
1294
|
-
this.text = `${amount.toFixed(2)} ${currency}`;
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
/**
|
|
1298
|
-
* Format a number with thousands separators.
|
|
1299
|
-
*/
|
|
1300
|
-
setNumber(value, decimals = 0, locale = 'en-US') {
|
|
1301
|
-
this.text = new Intl.NumberFormat(locale, {
|
|
1302
|
-
minimumFractionDigits: decimals,
|
|
1303
|
-
maximumFractionDigits: decimals,
|
|
1304
|
-
}).format(value);
|
|
1305
|
-
}
|
|
1306
|
-
/** React reconciler update hook */
|
|
1307
|
-
updateConfig(changed) {
|
|
1308
|
-
if ('text' in changed)
|
|
1309
|
-
this.text = changed.text;
|
|
1310
|
-
if ('maxWidth' in changed)
|
|
1311
|
-
this.maxWidth = changed.maxWidth;
|
|
1312
|
-
if ('autoFit' in changed) {
|
|
1313
|
-
this._autoFit = changed.autoFit;
|
|
1314
|
-
this.fitText();
|
|
1315
|
-
}
|
|
1316
|
-
if ('style' in changed && typeof changed.style === 'object') {
|
|
1317
|
-
Object.assign(this._text.style, changed.style);
|
|
1318
|
-
this.fitText();
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
fitText() {
|
|
1322
|
-
if (!this._autoFit || this._maxWidth === Infinity)
|
|
1323
|
-
return;
|
|
1324
|
-
this._text.scale.set(1);
|
|
1325
|
-
if (this._text.width > this._maxWidth) {
|
|
1326
|
-
const scale = this._maxWidth / this._text.width;
|
|
1327
|
-
this._text.scale.set(scale);
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
/**
|
|
1333
|
-
* Two-row text cell with a muted caption above and a prominent value below —
|
|
1334
|
-
* the archetypal casino UI pattern (BALANCE/€500, BET/€1, WIN/€0.00).
|
|
1335
|
-
*
|
|
1336
|
-
* Extends FlexContainer, so it participates in parent flex layouts directly
|
|
1337
|
-
* (you can pass `flexGrow`, `alignSelf`, etc. when it's a child of another
|
|
1338
|
-
* FlexContainer) and auto-sizes to its contents when no explicit size is set.
|
|
1339
|
-
*
|
|
1340
|
-
* @example
|
|
1341
|
-
* ```tsx
|
|
1342
|
-
* <labelValue
|
|
1343
|
-
* label="BALANCE"
|
|
1344
|
-
* value={`€${balance.toFixed(2)}`}
|
|
1345
|
-
* labelStyle={{ fontSize: 12, fill: 0x888888 }}
|
|
1346
|
-
* valueStyle={{ fontSize: 22, fill: 0xffffff, fontWeight: 'bold' }}
|
|
1347
|
-
* gap={6}
|
|
1348
|
-
* align="center"
|
|
1349
|
-
* />
|
|
1350
|
-
* ```
|
|
1351
|
-
*/
|
|
1352
|
-
class LabelValue extends FlexContainer {
|
|
1353
|
-
_labelEl;
|
|
1354
|
-
_valueEl;
|
|
1355
|
-
_valueMaxWidth;
|
|
1356
|
-
constructor(config = {}) {
|
|
1357
|
-
super({
|
|
1358
|
-
direction: 'column',
|
|
1359
|
-
alignItems: toAlignItems(config.align ?? 'center'),
|
|
1360
|
-
gap: config.gap ?? 4,
|
|
1361
|
-
padding: config.padding,
|
|
1362
|
-
});
|
|
1363
|
-
this._valueMaxWidth = config.maxWidth ?? Infinity;
|
|
1364
|
-
this._labelEl = new Label({
|
|
1365
|
-
text: config.label ?? '',
|
|
1366
|
-
style: config.labelStyle,
|
|
1367
|
-
});
|
|
1368
|
-
this._valueEl = new Label({
|
|
1369
|
-
text: config.value ?? '',
|
|
1370
|
-
style: config.valueStyle,
|
|
1371
|
-
maxWidth: config.maxWidth,
|
|
1372
|
-
autoFit: config.maxWidth !== undefined,
|
|
1373
|
-
});
|
|
1374
|
-
this.addFlexChild(this._labelEl);
|
|
1375
|
-
this.addFlexChild(this._valueEl);
|
|
1376
|
-
}
|
|
1377
|
-
/** Get the caption Label (for advanced styling / animation) */
|
|
1378
|
-
get labelElement() {
|
|
1379
|
-
return this._labelEl;
|
|
1380
|
-
}
|
|
1381
|
-
/** Get the value Label */
|
|
1382
|
-
get valueElement() {
|
|
1383
|
-
return this._valueEl;
|
|
1384
|
-
}
|
|
1385
|
-
/** Update caption text */
|
|
1386
|
-
setLabel(text) {
|
|
1387
|
-
this._labelEl.text = text;
|
|
1388
|
-
this.updateLayout();
|
|
1389
|
-
}
|
|
1390
|
-
/** Update value text */
|
|
1391
|
-
setValue(text) {
|
|
1392
|
-
this._valueEl.text = text;
|
|
1393
|
-
this.updateLayout();
|
|
1394
|
-
}
|
|
1395
|
-
/** React reconciler update hook */
|
|
1396
|
-
updateConfig(changed) {
|
|
1397
|
-
if ('label' in changed)
|
|
1398
|
-
this._labelEl.text = changed.label ?? '';
|
|
1399
|
-
if ('value' in changed)
|
|
1400
|
-
this._valueEl.text = changed.value ?? '';
|
|
1401
|
-
if ('labelStyle' in changed && typeof changed.labelStyle === 'object') {
|
|
1402
|
-
Object.assign(this._labelEl.style, changed.labelStyle);
|
|
1403
|
-
}
|
|
1404
|
-
if ('valueStyle' in changed && typeof changed.valueStyle === 'object') {
|
|
1405
|
-
Object.assign(this._valueEl.style, changed.valueStyle);
|
|
1406
|
-
}
|
|
1407
|
-
if ('maxWidth' in changed) {
|
|
1408
|
-
this._valueMaxWidth = changed.maxWidth ?? Infinity;
|
|
1409
|
-
this._valueEl.maxWidth = this._valueMaxWidth;
|
|
1410
|
-
}
|
|
1411
|
-
if ('gap' in changed)
|
|
1412
|
-
this.setGap(changed.gap);
|
|
1413
|
-
if ('align' in changed)
|
|
1414
|
-
this.setAlignItems(toAlignItems(changed.align));
|
|
1415
|
-
// Forward remaining FlexContainer props (e.g. padding, width, height)
|
|
1416
|
-
super.updateConfig(changed);
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
function toAlignItems(align) {
|
|
1420
|
-
return align;
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
/**
|
|
1424
|
-
* Background panel with optional flexbox content layout.
|
|
1425
|
-
*
|
|
1426
|
-
* Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
|
|
1427
|
-
* Children added via `addContent()` participate in flex layout automatically.
|
|
1428
|
-
*
|
|
1429
|
-
* @example
|
|
1430
|
-
* ```ts
|
|
1431
|
-
* // Simple colored panel
|
|
1432
|
-
* const panel = new Panel({ width: 400, height: 300, backgroundColor: 0x222222, borderRadius: 12 });
|
|
1433
|
-
*
|
|
1434
|
-
* // 9-slice panel (texture-based)
|
|
1435
|
-
* const panel = new Panel({
|
|
1436
|
-
* nineSliceTexture: 'panel-bg',
|
|
1437
|
-
* nineSliceBorders: [20, 20, 20, 20],
|
|
1438
|
-
* width: 400, height: 300,
|
|
1439
|
-
* });
|
|
1440
|
-
* ```
|
|
1441
|
-
*/
|
|
1442
|
-
class Panel extends Container {
|
|
1443
|
-
__uiComponent = true;
|
|
1444
|
-
_bg;
|
|
1445
|
-
_content;
|
|
1446
|
-
_internalSetup = true;
|
|
1447
|
-
_panelConfig;
|
|
1448
|
-
constructor(config = {}) {
|
|
1449
|
-
super();
|
|
1450
|
-
const resolvedConfig = {
|
|
1451
|
-
width: config.width ?? 400,
|
|
1452
|
-
height: config.height ?? 300,
|
|
1453
|
-
padding: config.padding ?? 16,
|
|
1454
|
-
backgroundAlpha: config.backgroundAlpha ?? 1,
|
|
1455
|
-
...config,
|
|
1456
|
-
};
|
|
1457
|
-
this._panelConfig = resolvedConfig;
|
|
1458
|
-
// Create background
|
|
1459
|
-
if (config.nineSliceTexture) {
|
|
1460
|
-
const texture = typeof config.nineSliceTexture === 'string'
|
|
1461
|
-
? Texture.from(config.nineSliceTexture)
|
|
1462
|
-
: config.nineSliceTexture;
|
|
1463
|
-
const [left, top, right, bottom] = config.nineSliceBorders ?? [10, 10, 10, 10];
|
|
1464
|
-
const nineSlice = new NineSliceSprite({
|
|
1465
|
-
texture,
|
|
1466
|
-
leftWidth: left,
|
|
1467
|
-
topHeight: top,
|
|
1468
|
-
rightWidth: right,
|
|
1469
|
-
bottomHeight: bottom,
|
|
1470
|
-
});
|
|
1471
|
-
nineSlice.width = resolvedConfig.width;
|
|
1472
|
-
nineSlice.height = resolvedConfig.height;
|
|
1473
|
-
nineSlice.alpha = resolvedConfig.backgroundAlpha;
|
|
1474
|
-
this._bg = nineSlice;
|
|
1475
|
-
}
|
|
1476
|
-
else {
|
|
1477
|
-
const g = new Graphics();
|
|
1478
|
-
const bgColor = config.backgroundColor ?? 0x1a1a2e;
|
|
1479
|
-
const radius = config.borderRadius ?? 0;
|
|
1480
|
-
g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius).fill(bgColor);
|
|
1481
|
-
if (config.borderColor !== undefined && config.borderWidth) {
|
|
1482
|
-
g.roundRect(0, 0, resolvedConfig.width, resolvedConfig.height, radius)
|
|
1483
|
-
.stroke({ color: config.borderColor, width: config.borderWidth });
|
|
1484
|
-
}
|
|
1485
|
-
g.alpha = resolvedConfig.backgroundAlpha;
|
|
1486
|
-
this._bg = g;
|
|
1487
|
-
}
|
|
1488
|
-
this.addChild(this._bg);
|
|
1489
|
-
// Create content flex container
|
|
1490
|
-
this._content = new FlexContainer({
|
|
1491
|
-
...config.layout,
|
|
1492
|
-
direction: config.layout?.direction ?? 'column',
|
|
1493
|
-
justifyContent: config.layout?.justifyContent ?? 'start',
|
|
1494
|
-
alignItems: config.layout?.alignItems ?? 'start',
|
|
1495
|
-
gap: config.layout?.gap ?? 0,
|
|
1496
|
-
padding: resolvedConfig.padding,
|
|
1497
|
-
width: resolvedConfig.width,
|
|
1498
|
-
height: resolvedConfig.height,
|
|
1499
|
-
});
|
|
1500
|
-
this.addChild(this._content);
|
|
1501
|
-
this._internalSetup = false;
|
|
1502
|
-
}
|
|
1503
|
-
/** Access the content flex container — add children here for layout */
|
|
1504
|
-
get content() {
|
|
1505
|
-
return this._content;
|
|
1506
|
-
}
|
|
1507
|
-
/** Suspend content layout recalculation. Call resumeLayout() to flush. */
|
|
1508
|
-
suspendLayout() {
|
|
1509
|
-
this._content.suspendLayout();
|
|
1510
|
-
}
|
|
1511
|
-
/** Resume content layout and flush if dirty. */
|
|
1512
|
-
resumeLayout() {
|
|
1513
|
-
this._content.resumeLayout();
|
|
1514
|
-
}
|
|
1515
|
-
/** Convenience: add a child to the content layout */
|
|
1516
|
-
addContent(child) {
|
|
1517
|
-
this._content.addFlexChild(child);
|
|
1518
|
-
this._content.updateLayout();
|
|
1519
|
-
return this;
|
|
1520
|
-
}
|
|
1521
|
-
/** Resize the panel */
|
|
1522
|
-
setSize(width, height) {
|
|
1523
|
-
this._panelConfig.width = width;
|
|
1524
|
-
this._panelConfig.height = height;
|
|
1525
|
-
// Resize background
|
|
1526
|
-
if (this._bg instanceof NineSliceSprite) {
|
|
1527
|
-
this._bg.width = width;
|
|
1528
|
-
this._bg.height = height;
|
|
1529
|
-
}
|
|
1530
|
-
else if (this._bg instanceof Graphics) {
|
|
1531
|
-
const radius = this._panelConfig.borderRadius ?? 0;
|
|
1532
|
-
const bgColor = this._panelConfig.backgroundColor ?? 0x1a1a2e;
|
|
1533
|
-
this._bg.clear();
|
|
1534
|
-
this._bg.roundRect(0, 0, width, height, radius).fill(bgColor);
|
|
1535
|
-
if (this._panelConfig.borderColor !== undefined && this._panelConfig.borderWidth) {
|
|
1536
|
-
this._bg.roundRect(0, 0, width, height, radius)
|
|
1537
|
-
.stroke({ color: this._panelConfig.borderColor, width: this._panelConfig.borderWidth });
|
|
1538
|
-
}
|
|
1539
|
-
this._bg.alpha = this._panelConfig.backgroundAlpha;
|
|
1540
|
-
}
|
|
1541
|
-
this._content.resize(width, height);
|
|
1542
|
-
}
|
|
1543
|
-
/**
|
|
1544
|
-
* Override addChild so external children are routed to content FlexContainer.
|
|
1545
|
-
* Enables `<panel><label /><button /></panel>` in React JSX.
|
|
1546
|
-
*/
|
|
1547
|
-
addChild(...children) {
|
|
1548
|
-
if (this._internalSetup) {
|
|
1549
|
-
return super.addChild(...children);
|
|
1550
|
-
}
|
|
1551
|
-
for (const child of children) {
|
|
1552
|
-
this._content.addFlexChild(child);
|
|
1553
|
-
}
|
|
1554
|
-
this._content.updateLayout();
|
|
1555
|
-
return children[0];
|
|
1556
|
-
}
|
|
1557
|
-
removeChild(...children) {
|
|
1558
|
-
if (this._internalSetup) {
|
|
1559
|
-
return super.removeChild(...children);
|
|
1560
|
-
}
|
|
1561
|
-
for (const child of children) {
|
|
1562
|
-
this._content.removeFlexChild(child);
|
|
1563
|
-
}
|
|
1564
|
-
return children[0];
|
|
1565
|
-
}
|
|
1566
|
-
/** React reconciler update hook */
|
|
1567
|
-
updateConfig(changed) {
|
|
1568
|
-
if ('width' in changed || 'height' in changed) {
|
|
1569
|
-
this.setSize(changed.width ?? this._panelConfig.width, changed.height ?? this._panelConfig.height);
|
|
1570
|
-
}
|
|
1571
|
-
if ('backgroundAlpha' in changed) {
|
|
1572
|
-
this._panelConfig.backgroundAlpha = changed.backgroundAlpha;
|
|
1573
|
-
this._bg.alpha = changed.backgroundAlpha;
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
destroy(options) {
|
|
1577
|
-
super.destroy(options);
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
/**
|
|
1582
|
-
* Reactive balance display component.
|
|
1583
|
-
*
|
|
1584
|
-
* Automatically formats currency and can animate value changes
|
|
1585
|
-
* with a smooth countup/countdown effect using engine Tween.
|
|
1586
|
-
*
|
|
1587
|
-
* @example
|
|
1588
|
-
* ```ts
|
|
1589
|
-
* const balance = new BalanceDisplay({ currency: 'USD', animated: true });
|
|
1590
|
-
* balance.setValue(1000);
|
|
1591
|
-
*
|
|
1592
|
-
* // Wire to SDK
|
|
1593
|
-
* sdk.on('balanceUpdate', ({ balance: val }) => balance.setValue(val));
|
|
1594
|
-
* ```
|
|
1595
|
-
*/
|
|
1596
|
-
class BalanceDisplay extends Container {
|
|
1597
|
-
__uiComponent = true;
|
|
1598
|
-
_prefixLabel = null;
|
|
1599
|
-
_valueLabel;
|
|
1600
|
-
_config;
|
|
1601
|
-
_currentValue = 0;
|
|
1602
|
-
_displayedValue = 0;
|
|
1603
|
-
/** Internal target for Tween animation */
|
|
1604
|
-
_tweenTarget = { value: 0 };
|
|
1605
|
-
constructor(config = {}) {
|
|
1606
|
-
super();
|
|
1607
|
-
this._config = {
|
|
1608
|
-
currency: config.currency ?? 'USD',
|
|
1609
|
-
locale: config.locale ?? 'en-US',
|
|
1610
|
-
animated: config.animated ?? true,
|
|
1611
|
-
animationDuration: config.animationDuration ?? 500,
|
|
1612
|
-
};
|
|
1613
|
-
// Prefix label
|
|
1614
|
-
if (config.prefix) {
|
|
1615
|
-
this._prefixLabel = new Label({
|
|
1616
|
-
text: config.prefix,
|
|
1617
|
-
style: {
|
|
1618
|
-
fontSize: 16,
|
|
1619
|
-
fill: 0xaaaaaa,
|
|
1620
|
-
...config.style,
|
|
1621
|
-
},
|
|
1622
|
-
});
|
|
1623
|
-
this.addChild(this._prefixLabel);
|
|
1624
|
-
}
|
|
1625
|
-
// Value label
|
|
1626
|
-
this._valueLabel = new Label({
|
|
1627
|
-
text: '0.00',
|
|
1628
|
-
style: {
|
|
1629
|
-
fontSize: 28,
|
|
1630
|
-
fontWeight: 'bold',
|
|
1631
|
-
fill: 0xffffff,
|
|
1632
|
-
...config.style,
|
|
1633
|
-
},
|
|
1634
|
-
maxWidth: config.maxWidth,
|
|
1635
|
-
autoFit: !!config.maxWidth,
|
|
1636
|
-
});
|
|
1637
|
-
this.addChild(this._valueLabel);
|
|
1638
|
-
this.layoutLabels();
|
|
1639
|
-
}
|
|
1640
|
-
/** Current displayed value */
|
|
1641
|
-
get value() {
|
|
1642
|
-
return this._currentValue;
|
|
1643
|
-
}
|
|
1644
|
-
/**
|
|
1645
|
-
* Set the balance value. If animated, smoothly counts to the new value.
|
|
1646
|
-
*/
|
|
1647
|
-
setValue(value) {
|
|
1648
|
-
const oldValue = this._currentValue;
|
|
1649
|
-
this._currentValue = value;
|
|
1650
|
-
if (this._config.animated && oldValue !== value) {
|
|
1651
|
-
this.animateValue(oldValue, value);
|
|
1652
|
-
}
|
|
1653
|
-
else {
|
|
1654
|
-
this._displayedValue = value;
|
|
1655
|
-
this.updateDisplay();
|
|
1656
|
-
}
|
|
1657
|
-
}
|
|
1658
|
-
/**
|
|
1659
|
-
* Set the currency code.
|
|
1660
|
-
*/
|
|
1661
|
-
setCurrency(currency) {
|
|
1662
|
-
this._config.currency = currency;
|
|
1663
|
-
this.updateDisplay();
|
|
1664
|
-
}
|
|
1665
|
-
animateValue(from, to) {
|
|
1666
|
-
// Cancel any running animation
|
|
1667
|
-
Tween.killTweensOf(this._tweenTarget);
|
|
1668
|
-
this._tweenTarget.value = from;
|
|
1669
|
-
Tween.to(this._tweenTarget, { value: to }, this._config.animationDuration, Easing.easeOutCubic, () => {
|
|
1670
|
-
this._displayedValue = this._tweenTarget.value;
|
|
1671
|
-
this.updateDisplay();
|
|
1672
|
-
});
|
|
1673
|
-
}
|
|
1674
|
-
updateDisplay() {
|
|
1675
|
-
this._valueLabel.setCurrency(this._displayedValue, this._config.currency, this._config.locale);
|
|
1676
|
-
}
|
|
1677
|
-
layoutLabels() {
|
|
1678
|
-
if (this._prefixLabel) {
|
|
1679
|
-
this._prefixLabel.y = -14;
|
|
1680
|
-
this._valueLabel.y = 14;
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
/** React reconciler update hook */
|
|
1684
|
-
updateConfig(changed) {
|
|
1685
|
-
if ('value' in changed)
|
|
1686
|
-
this.setValue(changed.value);
|
|
1687
|
-
if ('currency' in changed)
|
|
1688
|
-
this.setCurrency(changed.currency);
|
|
1689
|
-
}
|
|
1690
|
-
destroy(options) {
|
|
1691
|
-
Tween.killTweensOf(this._tweenTarget);
|
|
1692
|
-
super.destroy(options);
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
|
-
/**
|
|
1697
|
-
* Win amount display with countup animation.
|
|
1698
|
-
*
|
|
1699
|
-
* Shows a dramatic countup from 0 to the win amount, with optional
|
|
1700
|
-
* scale pop effect — typical of slot games. Uses engine Tween system.
|
|
1701
|
-
*
|
|
1702
|
-
* @example
|
|
1703
|
-
* ```ts
|
|
1704
|
-
* const winDisplay = new WinDisplay({ currency: 'USD' });
|
|
1705
|
-
* scene.container.addChild(winDisplay);
|
|
1706
|
-
* await winDisplay.showWin(150.50); // countup animation
|
|
1707
|
-
* winDisplay.hide();
|
|
1708
|
-
* ```
|
|
1709
|
-
*/
|
|
1710
|
-
class WinDisplay extends Container {
|
|
1711
|
-
__uiComponent = true;
|
|
1712
|
-
_label;
|
|
1713
|
-
_config;
|
|
1714
|
-
/** Internal target for Tween countup */
|
|
1715
|
-
_tweenTarget = { value: 0 };
|
|
1716
|
-
constructor(config = {}) {
|
|
1717
|
-
super();
|
|
1718
|
-
this._config = {
|
|
1719
|
-
currency: config.currency ?? 'USD',
|
|
1720
|
-
locale: config.locale ?? 'en-US',
|
|
1721
|
-
countupDuration: config.countupDuration ?? 1500,
|
|
1722
|
-
popScale: config.popScale ?? 1.2,
|
|
1723
|
-
};
|
|
1724
|
-
this._label = new Label({
|
|
1725
|
-
text: '',
|
|
1726
|
-
style: {
|
|
1727
|
-
fontSize: 48,
|
|
1728
|
-
fontWeight: 'bold',
|
|
1729
|
-
fill: 0xffd700,
|
|
1730
|
-
stroke: { color: 0x000000, width: 3 },
|
|
1731
|
-
...config.style,
|
|
1732
|
-
},
|
|
1733
|
-
});
|
|
1734
|
-
this.addChild(this._label);
|
|
1735
|
-
this.visible = false;
|
|
1736
|
-
}
|
|
1737
|
-
/**
|
|
1738
|
-
* Show a win with countup animation.
|
|
1739
|
-
*
|
|
1740
|
-
* @param amount - Win amount
|
|
1741
|
-
* @returns Promise that resolves when the animation completes
|
|
1742
|
-
*/
|
|
1743
|
-
async showWin(amount) {
|
|
1744
|
-
this.visible = true;
|
|
1745
|
-
this.alpha = 1;
|
|
1746
|
-
// Cancel any running animation
|
|
1747
|
-
Tween.killTweensOf(this._tweenTarget);
|
|
1748
|
-
Tween.killTweensOf(this);
|
|
1749
|
-
// Setup countup
|
|
1750
|
-
this._tweenTarget.value = 0;
|
|
1751
|
-
this.scale.set(0.5);
|
|
1752
|
-
// Scale pop animation
|
|
1753
|
-
const scalePromise = Tween.to(this, { 'scale.x': 1, 'scale.y': 1 }, 300, Easing.easeOutBack);
|
|
1754
|
-
// Countup animation
|
|
1755
|
-
const countupPromise = Tween.to(this._tweenTarget, { value: amount }, this._config.countupDuration, Easing.easeOutCubic, () => {
|
|
1756
|
-
this.displayAmount(this._tweenTarget.value);
|
|
1757
|
-
});
|
|
1758
|
-
await Promise.all([scalePromise, countupPromise]);
|
|
1759
|
-
// Ensure final value is exact
|
|
1760
|
-
this.displayAmount(amount);
|
|
1761
|
-
this.scale.set(1);
|
|
1762
|
-
}
|
|
1763
|
-
/**
|
|
1764
|
-
* Skip the countup animation and show the final amount immediately.
|
|
1765
|
-
*/
|
|
1766
|
-
skipCountup(amount) {
|
|
1767
|
-
Tween.killTweensOf(this._tweenTarget);
|
|
1768
|
-
Tween.killTweensOf(this);
|
|
1769
|
-
this.displayAmount(amount);
|
|
1770
|
-
this.scale.set(1);
|
|
1771
|
-
}
|
|
1772
|
-
/**
|
|
1773
|
-
* Hide the win display.
|
|
1774
|
-
*/
|
|
1775
|
-
hide() {
|
|
1776
|
-
Tween.killTweensOf(this._tweenTarget);
|
|
1777
|
-
Tween.killTweensOf(this);
|
|
1778
|
-
this.visible = false;
|
|
1779
|
-
this._label.text = '';
|
|
1780
|
-
}
|
|
1781
|
-
displayAmount(amount) {
|
|
1782
|
-
this._label.setCurrency(amount, this._config.currency, this._config.locale);
|
|
1783
|
-
}
|
|
1784
|
-
/** React reconciler update hook */
|
|
1785
|
-
updateConfig(changed) {
|
|
1786
|
-
if ('currency' in changed)
|
|
1787
|
-
this._config.currency = changed.currency;
|
|
1788
|
-
if ('locale' in changed)
|
|
1789
|
-
this._config.locale = changed.locale;
|
|
1790
|
-
}
|
|
1791
|
-
destroy(options) {
|
|
1792
|
-
Tween.killTweensOf(this._tweenTarget);
|
|
1793
|
-
Tween.killTweensOf(this);
|
|
1794
|
-
super.destroy(options);
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
/**
|
|
1799
|
-
* Modal overlay component.
|
|
1800
|
-
* Shows content on top of a dark overlay with enter/exit animations.
|
|
1801
|
-
*
|
|
1802
|
-
* Content is automatically centered via position calculations.
|
|
1803
|
-
*
|
|
1804
|
-
* @example
|
|
1805
|
-
* ```ts
|
|
1806
|
-
* const modal = new Modal({ closeOnOverlay: true });
|
|
1807
|
-
* modal.content.addChild(settingsPanel);
|
|
1808
|
-
* modal.onClose = () => console.log('Closed');
|
|
1809
|
-
* await modal.show(1920, 1080);
|
|
1810
|
-
* ```
|
|
1811
|
-
*/
|
|
1812
|
-
class Modal extends Container {
|
|
1813
|
-
__uiComponent = true;
|
|
1814
|
-
_overlay;
|
|
1815
|
-
_contentContainer;
|
|
1816
|
-
_config;
|
|
1817
|
-
_showing = false;
|
|
1818
|
-
_internalSetup = true;
|
|
1819
|
-
/** Called when the modal is closed */
|
|
1820
|
-
onClose;
|
|
1821
|
-
constructor(config = {}) {
|
|
1822
|
-
super();
|
|
1823
|
-
this._config = {
|
|
1824
|
-
overlayColor: config.overlayColor ?? 0x000000,
|
|
1825
|
-
overlayAlpha: config.overlayAlpha ?? 0.7,
|
|
1826
|
-
closeOnOverlay: config.closeOnOverlay ?? true,
|
|
1827
|
-
animationDuration: config.animationDuration ?? 300,
|
|
1828
|
-
};
|
|
1829
|
-
// Overlay
|
|
1830
|
-
this._overlay = new Graphics();
|
|
1831
|
-
this._overlay.eventMode = 'static';
|
|
1832
|
-
this._overlay.on('pointertap', () => {
|
|
1833
|
-
if (this._config.closeOnOverlay)
|
|
1834
|
-
this.hide();
|
|
1835
|
-
});
|
|
1836
|
-
this.addChild(this._overlay);
|
|
1837
|
-
// Content container
|
|
1838
|
-
this._contentContainer = new Container();
|
|
1839
|
-
this.addChild(this._contentContainer);
|
|
1840
|
-
this.visible = false;
|
|
1841
|
-
this._internalSetup = false;
|
|
1842
|
-
}
|
|
1843
|
-
/** Content container — add your UI here */
|
|
1844
|
-
get content() {
|
|
1845
|
-
return this._contentContainer;
|
|
1846
|
-
}
|
|
1847
|
-
/**
|
|
1848
|
-
* Override addChild so external children are routed to _contentContainer.
|
|
1849
|
-
* Enables `<modal><flexContainer>...</flexContainer></modal>` in React JSX.
|
|
1850
|
-
*/
|
|
1851
|
-
addChild(...children) {
|
|
1852
|
-
if (this._internalSetup) {
|
|
1853
|
-
return super.addChild(...children);
|
|
1854
|
-
}
|
|
1855
|
-
for (const child of children) {
|
|
1856
|
-
this._contentContainer.addChild(child);
|
|
1857
|
-
}
|
|
1858
|
-
return children[0];
|
|
1859
|
-
}
|
|
1860
|
-
removeChild(...children) {
|
|
1861
|
-
if (this._internalSetup) {
|
|
1862
|
-
return super.removeChild(...children);
|
|
1863
|
-
}
|
|
1864
|
-
for (const child of children) {
|
|
1865
|
-
this._contentContainer.removeChild(child);
|
|
1866
|
-
}
|
|
1867
|
-
return children[0];
|
|
1868
|
-
}
|
|
1869
|
-
/** Whether the modal is currently showing */
|
|
1870
|
-
get isShowing() {
|
|
1871
|
-
return this._showing;
|
|
1872
|
-
}
|
|
1873
|
-
/**
|
|
1874
|
-
* Show the modal with animation.
|
|
1875
|
-
*/
|
|
1876
|
-
async show(viewWidth, viewHeight) {
|
|
1877
|
-
this._showing = true;
|
|
1878
|
-
this.visible = true;
|
|
1879
|
-
// Draw overlay to cover full screen
|
|
1880
|
-
this._overlay.clear();
|
|
1881
|
-
this._overlay.rect(0, 0, viewWidth, viewHeight).fill(this._config.overlayColor);
|
|
1882
|
-
this._overlay.alpha = 0;
|
|
1883
|
-
// Center content
|
|
1884
|
-
this._contentContainer.x = viewWidth / 2;
|
|
1885
|
-
this._contentContainer.y = viewHeight / 2;
|
|
1886
|
-
this._contentContainer.alpha = 0;
|
|
1887
|
-
this._contentContainer.scale.set(0.8);
|
|
1888
|
-
// Animate in
|
|
1889
|
-
await Promise.all([
|
|
1890
|
-
Tween.to(this._overlay, { alpha: this._config.overlayAlpha }, this._config.animationDuration, Easing.easeOutCubic),
|
|
1891
|
-
Tween.to(this._contentContainer, { alpha: 1, 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutBack),
|
|
1892
|
-
]);
|
|
1893
|
-
}
|
|
1894
|
-
/**
|
|
1895
|
-
* Hide the modal with animation.
|
|
1896
|
-
*/
|
|
1897
|
-
async hide() {
|
|
1898
|
-
if (!this._showing)
|
|
1899
|
-
return;
|
|
1900
|
-
await Promise.all([
|
|
1901
|
-
Tween.to(this._overlay, { alpha: 0 }, this._config.animationDuration * 0.7, Easing.easeInCubic),
|
|
1902
|
-
Tween.to(this._contentContainer, { alpha: 0, 'scale.x': 0.8, 'scale.y': 0.8 }, this._config.animationDuration * 0.7, Easing.easeInCubic),
|
|
1903
|
-
]);
|
|
1904
|
-
this.visible = false;
|
|
1905
|
-
this._showing = false;
|
|
1906
|
-
this.onClose?.();
|
|
1907
|
-
}
|
|
1908
|
-
/** React reconciler update hook */
|
|
1909
|
-
updateConfig(changed) {
|
|
1910
|
-
if ('overlayAlpha' in changed)
|
|
1911
|
-
this._config.overlayAlpha = changed.overlayAlpha;
|
|
1912
|
-
if ('closeOnOverlay' in changed)
|
|
1913
|
-
this._config.closeOnOverlay = changed.closeOnOverlay;
|
|
1914
|
-
if ('animationDuration' in changed)
|
|
1915
|
-
this._config.animationDuration = changed.animationDuration;
|
|
1916
|
-
if ('onClose' in changed)
|
|
1917
|
-
this.onClose = changed.onClose;
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
const TOAST_COLORS = {
|
|
1922
|
-
info: 0x3498db,
|
|
1923
|
-
success: 0x27ae60,
|
|
1924
|
-
warning: 0xf39c12,
|
|
1925
|
-
error: 0xe74c3c,
|
|
1926
|
-
};
|
|
1927
|
-
/**
|
|
1928
|
-
* Toast notification component for displaying transient messages.
|
|
1929
|
-
*
|
|
1930
|
-
* @example
|
|
1931
|
-
* ```ts
|
|
1932
|
-
* const toast = new Toast();
|
|
1933
|
-
* scene.container.addChild(toast);
|
|
1934
|
-
* await toast.show('Connection lost', 'error', 1920, 1080);
|
|
1935
|
-
* ```
|
|
1936
|
-
*/
|
|
1937
|
-
class Toast extends Container {
|
|
1938
|
-
__uiComponent = true;
|
|
1939
|
-
_bg;
|
|
1940
|
-
_customBg;
|
|
1941
|
-
_text;
|
|
1942
|
-
_config;
|
|
1943
|
-
_dismissPending = false;
|
|
1944
|
-
constructor(config = {}) {
|
|
1945
|
-
super();
|
|
1946
|
-
this._config = {
|
|
1947
|
-
duration: config.duration ?? 3000,
|
|
1948
|
-
bottomOffset: config.bottomOffset ?? 60,
|
|
1949
|
-
};
|
|
1950
|
-
const customBg = resolveView(config.backgroundView);
|
|
1951
|
-
this._customBg = !!customBg;
|
|
1952
|
-
this._bg = customBg ?? new Graphics();
|
|
1953
|
-
this.addChild(this._bg);
|
|
1954
|
-
this._text = new Text({
|
|
1955
|
-
text: '',
|
|
1956
|
-
style: {
|
|
1957
|
-
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
1958
|
-
fontSize: 16,
|
|
1959
|
-
fill: 0xffffff,
|
|
1960
|
-
},
|
|
1961
|
-
});
|
|
1962
|
-
this._text.anchor.set(0.5);
|
|
1963
|
-
this.addChild(this._text);
|
|
1964
|
-
this.visible = false;
|
|
1965
|
-
}
|
|
1966
|
-
/**
|
|
1967
|
-
* Show a toast message.
|
|
1968
|
-
*/
|
|
1969
|
-
async show(message, type = 'info', viewWidth, viewHeight) {
|
|
1970
|
-
// Cancel any pending dismiss
|
|
1971
|
-
Tween.killTweensOf(this);
|
|
1972
|
-
this._dismissPending = false;
|
|
1973
|
-
this._text.text = message;
|
|
1974
|
-
const padding = 20;
|
|
1975
|
-
const width = Math.max(200, this._text.width + padding * 2);
|
|
1976
|
-
const height = 44;
|
|
1977
|
-
const radius = 8;
|
|
1978
|
-
// Draw the background
|
|
1979
|
-
if (this._customBg) {
|
|
1980
|
-
this._bg.width = width;
|
|
1981
|
-
this._bg.height = height;
|
|
1982
|
-
this._bg.x = -width / 2;
|
|
1983
|
-
this._bg.y = -height / 2;
|
|
1984
|
-
}
|
|
1985
|
-
else {
|
|
1986
|
-
const g = this._bg;
|
|
1987
|
-
g.clear();
|
|
1988
|
-
g.roundRect(-width / 2, -height / 2, width, height, radius);
|
|
1989
|
-
g.fill(TOAST_COLORS[type]);
|
|
1990
|
-
}
|
|
1991
|
-
// Position
|
|
1992
|
-
if (viewWidth && viewHeight) {
|
|
1993
|
-
this.x = viewWidth / 2;
|
|
1994
|
-
this.y = viewHeight - this._config.bottomOffset;
|
|
1995
|
-
}
|
|
1996
|
-
this.visible = true;
|
|
1997
|
-
this.alpha = 0;
|
|
1998
|
-
this.y += 20;
|
|
1999
|
-
await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
|
|
2000
|
-
if (this._config.duration > 0) {
|
|
2001
|
-
this._dismissPending = true;
|
|
2002
|
-
await Tween.delay(this._config.duration);
|
|
2003
|
-
if (this._dismissPending) {
|
|
2004
|
-
this._dismissPending = false;
|
|
2005
|
-
await this.dismiss();
|
|
2006
|
-
}
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
/**
|
|
2010
|
-
* Dismiss the toast.
|
|
2011
|
-
*/
|
|
2012
|
-
async dismiss() {
|
|
2013
|
-
if (!this.visible)
|
|
2014
|
-
return;
|
|
2015
|
-
this._dismissPending = false;
|
|
2016
|
-
Tween.killTweensOf(this);
|
|
2017
|
-
await Tween.to(this, { alpha: 0, y: this.y + 20 }, 200, Easing.easeInCubic);
|
|
2018
|
-
this.visible = false;
|
|
2019
|
-
}
|
|
2020
|
-
/** React reconciler update hook */
|
|
2021
|
-
updateConfig(changed) {
|
|
2022
|
-
if ('duration' in changed)
|
|
2023
|
-
this._config.duration = changed.duration;
|
|
2024
|
-
if ('bottomOffset' in changed)
|
|
2025
|
-
this._config.bottomOffset = changed.bottomOffset;
|
|
2026
|
-
}
|
|
2027
|
-
destroy(options) {
|
|
2028
|
-
this._dismissPending = false;
|
|
2029
|
-
Tween.killTweensOf(this);
|
|
2030
|
-
super.destroy(options);
|
|
2031
|
-
}
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
// ─── Helpers ─────────────────────────────────────────────
|
|
2035
|
-
function directionToFlex(direction) {
|
|
2036
|
-
switch (direction) {
|
|
2037
|
-
case 'horizontal': return { direction: 'row', wrap: false };
|
|
2038
|
-
case 'vertical': return { direction: 'column', wrap: false };
|
|
2039
|
-
case 'grid': return { direction: 'row', wrap: true };
|
|
2040
|
-
case 'wrap': return { direction: 'row', wrap: true };
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
/**
|
|
2044
|
-
* Responsive layout container powered by a lightweight built-in flex layout solver.
|
|
2045
|
-
*
|
|
2046
|
-
* Supports horizontal, vertical, grid, and wrap layout modes with
|
|
2047
|
-
* alignment, padding, gap, and viewport-anchor positioning.
|
|
2048
|
-
* Breakpoints allow different layouts for different screen sizes.
|
|
2049
|
-
*
|
|
2050
|
-
* @example
|
|
2051
|
-
* ```ts
|
|
2052
|
-
* const toolbar = new Layout({
|
|
2053
|
-
* direction: 'horizontal',
|
|
2054
|
-
* gap: 20,
|
|
2055
|
-
* alignment: 'center',
|
|
2056
|
-
* anchor: 'bottom-center',
|
|
2057
|
-
* padding: 16,
|
|
2058
|
-
* breakpoints: {
|
|
2059
|
-
* 768: { direction: 'vertical', gap: 10 },
|
|
2060
|
-
* },
|
|
2061
|
-
* });
|
|
2062
|
-
*
|
|
2063
|
-
* toolbar.addItem(spinButton);
|
|
2064
|
-
* toolbar.addItem(betLabel);
|
|
2065
|
-
* scene.container.addChild(toolbar);
|
|
2066
|
-
*
|
|
2067
|
-
* toolbar.updateViewport(width, height);
|
|
2068
|
-
* ```
|
|
2069
|
-
*/
|
|
2070
|
-
class Layout extends Container {
|
|
2071
|
-
__uiComponent = true;
|
|
2072
|
-
_layoutConfig;
|
|
2073
|
-
_padding;
|
|
2074
|
-
_anchor;
|
|
2075
|
-
_maxWidth;
|
|
2076
|
-
_breakpoints;
|
|
2077
|
-
_items = [];
|
|
2078
|
-
_viewportWidth = 0;
|
|
2079
|
-
_viewportHeight = 0;
|
|
2080
|
-
_flex;
|
|
2081
|
-
constructor(config = {}) {
|
|
2082
|
-
super();
|
|
2083
|
-
this._layoutConfig = {
|
|
2084
|
-
direction: config.direction ?? 'vertical',
|
|
2085
|
-
gap: config.gap ?? 0,
|
|
2086
|
-
alignment: config.alignment ?? 'start',
|
|
2087
|
-
autoLayout: config.autoLayout ?? true,
|
|
2088
|
-
columns: config.columns ?? 2,
|
|
2089
|
-
};
|
|
2090
|
-
this._padding = config.padding ?? 0;
|
|
2091
|
-
this._anchor = config.anchor ?? 'top-left';
|
|
2092
|
-
this._maxWidth = config.maxWidth ?? Infinity;
|
|
2093
|
-
this._breakpoints = config.breakpoints
|
|
2094
|
-
? Object.entries(config.breakpoints)
|
|
2095
|
-
.map(([w, cfg]) => [Number(w), cfg])
|
|
2096
|
-
.sort((a, b) => a[0] - b[0])
|
|
2097
|
-
: [];
|
|
2098
|
-
// Create internal FlexContainer
|
|
2099
|
-
this._flex = new FlexContainer();
|
|
2100
|
-
super.addChild(this._flex);
|
|
2101
|
-
this.applyLayoutStyles();
|
|
2102
|
-
}
|
|
2103
|
-
/** Add an item to the layout */
|
|
2104
|
-
addItem(child) {
|
|
2105
|
-
this._items.push(child);
|
|
2106
|
-
const flexConfig = this.buildFlexItemConfig(child);
|
|
2107
|
-
this._flex.addFlexChild(child, flexConfig);
|
|
2108
|
-
if (this._layoutConfig.autoLayout) {
|
|
2109
|
-
this.applyLayoutStyles();
|
|
2110
|
-
}
|
|
2111
|
-
return this;
|
|
2112
|
-
}
|
|
2113
|
-
/** Remove an item from the layout */
|
|
2114
|
-
removeItem(child) {
|
|
2115
|
-
const idx = this._items.indexOf(child);
|
|
2116
|
-
if (idx !== -1) {
|
|
2117
|
-
this._items.splice(idx, 1);
|
|
2118
|
-
this._flex.removeFlexChild(child);
|
|
2119
|
-
}
|
|
2120
|
-
return this;
|
|
2121
|
-
}
|
|
2122
|
-
/** Remove all items */
|
|
2123
|
-
clearItems() {
|
|
2124
|
-
this._flex.clearFlexChildren();
|
|
2125
|
-
this._items.length = 0;
|
|
2126
|
-
return this;
|
|
2127
|
-
}
|
|
2128
|
-
/** Get all layout items */
|
|
2129
|
-
get items() {
|
|
2130
|
-
return this._items;
|
|
2131
|
-
}
|
|
2132
|
-
/**
|
|
2133
|
-
* Update the viewport size and recalculate layout.
|
|
2134
|
-
* Should be called from `Scene.onResize()`.
|
|
2135
|
-
*/
|
|
2136
|
-
updateViewport(width, height) {
|
|
2137
|
-
this._viewportWidth = width;
|
|
2138
|
-
this._viewportHeight = height;
|
|
2139
|
-
this.applyLayoutStyles();
|
|
2140
|
-
this.applyAnchor();
|
|
2141
|
-
}
|
|
2142
|
-
applyLayoutStyles() {
|
|
2143
|
-
const effective = this.resolveConfig();
|
|
2144
|
-
const direction = effective.direction ?? this._layoutConfig.direction;
|
|
2145
|
-
const gap = effective.gap ?? this._layoutConfig.gap;
|
|
2146
|
-
const alignment = effective.alignment ?? this._layoutConfig.alignment;
|
|
2147
|
-
const padding = effective.padding ?? this._padding;
|
|
2148
|
-
const maxWidth = effective.maxWidth ?? this._maxWidth;
|
|
2149
|
-
const { direction: flexDir, wrap } = directionToFlex(direction);
|
|
2150
|
-
this._flex.setDirection(flexDir);
|
|
2151
|
-
this._flex.setJustifyContent('start');
|
|
2152
|
-
this._flex.setAlignItems(alignment);
|
|
2153
|
-
this._flex.setGap(gap);
|
|
2154
|
-
this._flex.setPadding(padding);
|
|
2155
|
-
// Wrap and maxWidth
|
|
2156
|
-
if (wrap) {
|
|
2157
|
-
this._flex._config.flexWrap = true;
|
|
2158
|
-
if (direction === 'grid' && maxWidth < Infinity) {
|
|
2159
|
-
this._flex._maxWidth = maxWidth;
|
|
2160
|
-
}
|
|
2161
|
-
if (maxWidth < Infinity) {
|
|
2162
|
-
this._flex._maxWidth = maxWidth;
|
|
2163
|
-
}
|
|
2164
|
-
}
|
|
2165
|
-
else {
|
|
2166
|
-
this._flex._config.flexWrap = false;
|
|
2167
|
-
}
|
|
2168
|
-
// Update grid child widths
|
|
2169
|
-
if (direction === 'grid') {
|
|
2170
|
-
for (const item of this._items) {
|
|
2171
|
-
const flexConfig = this.buildFlexItemConfig(item);
|
|
2172
|
-
item._flexConfig = flexConfig;
|
|
2173
|
-
}
|
|
2174
|
-
}
|
|
2175
|
-
// Set explicit size if we have viewport dimensions
|
|
2176
|
-
if (this._viewportWidth > 0 && this._viewportHeight > 0) {
|
|
2177
|
-
this._flex.resize(this._viewportWidth, this._viewportHeight);
|
|
2178
|
-
}
|
|
2179
|
-
else {
|
|
2180
|
-
this._flex.updateLayout();
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
buildFlexItemConfig(_child) {
|
|
2184
|
-
const effective = this.resolveConfig();
|
|
2185
|
-
const direction = effective.direction ?? this._layoutConfig.direction;
|
|
2186
|
-
const columns = effective.columns ?? this._layoutConfig.columns;
|
|
2187
|
-
if (direction === 'grid' && columns > 0) {
|
|
2188
|
-
// For grid, give each item a proportional width
|
|
2189
|
-
// The actual pixel width will be computed during layout
|
|
2190
|
-
return { flexGrow: 1 };
|
|
2191
|
-
}
|
|
2192
|
-
return undefined;
|
|
2193
|
-
}
|
|
2194
|
-
applyAnchor() {
|
|
2195
|
-
const anchor = this.resolveConfig().anchor ?? this._anchor;
|
|
2196
|
-
if (this._viewportWidth === 0 || this._viewportHeight === 0)
|
|
2197
|
-
return;
|
|
2198
|
-
const { width: contentW, height: contentH } = this._flex.getContentSize();
|
|
2199
|
-
const vw = this._viewportWidth;
|
|
2200
|
-
const vh = this._viewportHeight;
|
|
2201
|
-
let anchorX = 0;
|
|
2202
|
-
let anchorY = 0;
|
|
2203
|
-
if (anchor.includes('left')) {
|
|
2204
|
-
anchorX = 0;
|
|
2205
|
-
}
|
|
2206
|
-
else if (anchor.includes('right')) {
|
|
2207
|
-
anchorX = vw - contentW;
|
|
2208
|
-
}
|
|
2209
|
-
else {
|
|
2210
|
-
anchorX = (vw - contentW) / 2;
|
|
2211
|
-
}
|
|
2212
|
-
if (anchor.startsWith('top')) {
|
|
2213
|
-
anchorY = 0;
|
|
2214
|
-
}
|
|
2215
|
-
else if (anchor.startsWith('bottom')) {
|
|
2216
|
-
anchorY = vh - contentH;
|
|
2217
|
-
}
|
|
2218
|
-
else {
|
|
2219
|
-
anchorY = (vh - contentH) / 2;
|
|
2220
|
-
}
|
|
2221
|
-
this.x = anchorX;
|
|
2222
|
-
this.y = anchorY;
|
|
2223
|
-
}
|
|
2224
|
-
resolveConfig() {
|
|
2225
|
-
if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
|
|
2226
|
-
return {};
|
|
2227
|
-
}
|
|
2228
|
-
for (const [maxWidth, overrides] of this._breakpoints) {
|
|
2229
|
-
if (this._viewportWidth <= maxWidth) {
|
|
2230
|
-
return overrides;
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
return {};
|
|
2234
|
-
}
|
|
2235
|
-
/** React reconciler update hook */
|
|
2236
|
-
updateConfig(changed) {
|
|
2237
|
-
if ('direction' in changed)
|
|
2238
|
-
this._layoutConfig.direction = changed.direction;
|
|
2239
|
-
if ('gap' in changed)
|
|
2240
|
-
this._layoutConfig.gap = changed.gap;
|
|
2241
|
-
if ('alignment' in changed)
|
|
2242
|
-
this._layoutConfig.alignment = changed.alignment;
|
|
2243
|
-
if ('anchor' in changed)
|
|
2244
|
-
this._anchor = changed.anchor;
|
|
2245
|
-
if ('padding' in changed)
|
|
2246
|
-
this._padding = changed.padding;
|
|
2247
|
-
if ('columns' in changed)
|
|
2248
|
-
this._layoutConfig.columns = changed.columns;
|
|
2249
|
-
this.applyLayoutStyles();
|
|
2250
|
-
if (this._viewportWidth > 0)
|
|
2251
|
-
this.applyAnchor();
|
|
2252
|
-
}
|
|
2253
|
-
destroy(options) {
|
|
2254
|
-
this._items.length = 0;
|
|
2255
|
-
super.destroy(options);
|
|
2256
|
-
}
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
const DECELERATION = 0.95;
|
|
2260
|
-
const MIN_VELOCITY = 0.5;
|
|
2261
|
-
/**
|
|
2262
|
-
* Scrollable container with touch/drag, mouse wheel, and inertia.
|
|
2263
|
-
*
|
|
2264
|
-
* @example
|
|
2265
|
-
* ```ts
|
|
2266
|
-
* const scroll = new ScrollContainer({
|
|
2267
|
-
* width: 600,
|
|
2268
|
-
* height: 400,
|
|
2269
|
-
* direction: 'vertical',
|
|
2270
|
-
* elementsMargin: 8,
|
|
2271
|
-
* });
|
|
2272
|
-
*
|
|
2273
|
-
* for (let i = 0; i < 50; i++) {
|
|
2274
|
-
* scroll.addItem(createRow(i));
|
|
2275
|
-
* }
|
|
2276
|
-
*
|
|
2277
|
-
* scene.container.addChild(scroll);
|
|
2278
|
-
* ```
|
|
2279
|
-
*/
|
|
2280
|
-
class ScrollContainer extends Container {
|
|
2281
|
-
__uiComponent = true;
|
|
2282
|
-
_viewport;
|
|
2283
|
-
_internalSetup = true;
|
|
2284
|
-
_content;
|
|
2285
|
-
_maskGfx;
|
|
2286
|
-
_bg = null;
|
|
2287
|
-
_scrollConfig;
|
|
2288
|
-
_items = [];
|
|
2289
|
-
// Scrollbar
|
|
2290
|
-
_scrollbar = null;
|
|
2291
|
-
_scrollbarConfig;
|
|
2292
|
-
// Drag state
|
|
2293
|
-
_dragging = false;
|
|
2294
|
-
_dragStart = { x: 0, y: 0 };
|
|
2295
|
-
_contentStart = { x: 0, y: 0 };
|
|
2296
|
-
_velocity = { x: 0, y: 0 };
|
|
2297
|
-
_lastDragPos = { x: 0, y: 0 };
|
|
2298
|
-
_lastDragTime = 0;
|
|
2299
|
-
_inertiaActive = false;
|
|
2300
|
-
// Bound handlers for cleanup
|
|
2301
|
-
_onTickBound = null;
|
|
2302
|
-
_onWheelBound = null;
|
|
2303
|
-
constructor(config) {
|
|
2304
|
-
super();
|
|
2305
|
-
this._viewport = { width: config.width, height: config.height };
|
|
2306
|
-
this._scrollConfig = {
|
|
2307
|
-
direction: config.direction ?? 'vertical',
|
|
2308
|
-
elementsMargin: config.elementsMargin ?? 0,
|
|
2309
|
-
padding: config.padding ?? 0,
|
|
2310
|
-
borderRadius: config.borderRadius ?? 0,
|
|
2311
|
-
disableEasing: config.disableEasing ?? false,
|
|
2312
|
-
};
|
|
2313
|
-
// Background
|
|
2314
|
-
if (config.backgroundColor !== undefined) {
|
|
2315
|
-
this._bg = new Graphics();
|
|
2316
|
-
this._bg.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
|
|
2317
|
-
.fill(config.backgroundColor);
|
|
2318
|
-
this.addChild(this._bg);
|
|
2319
|
-
}
|
|
2320
|
-
// Mask
|
|
2321
|
-
this._maskGfx = new Graphics();
|
|
2322
|
-
this._maskGfx.roundRect(0, 0, config.width, config.height, this._scrollConfig.borderRadius)
|
|
2323
|
-
.fill(0xffffff);
|
|
2324
|
-
this.addChild(this._maskGfx);
|
|
2325
|
-
// Content container
|
|
2326
|
-
this._content = new Container();
|
|
2327
|
-
this._content.mask = this._maskGfx;
|
|
2328
|
-
this.addChild(this._content);
|
|
2329
|
-
// Interaction
|
|
2330
|
-
this.eventMode = 'static';
|
|
2331
|
-
this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
|
|
2332
|
-
this.on('pointerdown', this._onPointerDown, this);
|
|
2333
|
-
this.on('pointermove', this._onPointerMove, this);
|
|
2334
|
-
this.on('pointerup', this._onPointerUp, this);
|
|
2335
|
-
this.on('pointerupoutside', this._onPointerUp, this);
|
|
2336
|
-
// Mouse wheel
|
|
2337
|
-
this._onWheelBound = this._onWheel.bind(this);
|
|
2338
|
-
// Scrollbar
|
|
2339
|
-
const sbWidth = config.scrollbarWidth ?? 6;
|
|
2340
|
-
const sbPadding = config.scrollbarPadding ?? 4;
|
|
2341
|
-
this._scrollbarConfig = { width: sbWidth, padding: sbPadding };
|
|
2342
|
-
if (config.scrollbar) {
|
|
2343
|
-
const customThumb = resolveView(config.thumbView);
|
|
2344
|
-
if (customThumb) {
|
|
2345
|
-
this._scrollbar = customThumb;
|
|
2346
|
-
}
|
|
2347
|
-
else {
|
|
2348
|
-
const g = new Graphics();
|
|
2349
|
-
g.roundRect(0, 0, sbWidth, 40, sbWidth / 2).fill(config.scrollbarColor ?? 0xaaaaaa);
|
|
2350
|
-
g.alpha = config.scrollbarAlpha ?? 0.5;
|
|
2351
|
-
this._scrollbar = g;
|
|
2352
|
-
}
|
|
2353
|
-
this._scrollbar.visible = false;
|
|
2354
|
-
super.addChild(this._scrollbar);
|
|
2355
|
-
}
|
|
2356
|
-
this._internalSetup = false;
|
|
2357
|
-
}
|
|
2358
|
-
/**
|
|
2359
|
-
* Override addChild so external children are routed to scroll content.
|
|
2360
|
-
* Enables `<scrollContainer><label /><panel /></scrollContainer>` in React JSX.
|
|
2361
|
-
*/
|
|
2362
|
-
addChild(...children) {
|
|
2363
|
-
if (this._internalSetup) {
|
|
2364
|
-
return super.addChild(...children);
|
|
2365
|
-
}
|
|
2366
|
-
for (const child of children) {
|
|
2367
|
-
this.addItem(child);
|
|
2368
|
-
}
|
|
2369
|
-
return children[0];
|
|
2370
|
-
}
|
|
2371
|
-
removeChild(...children) {
|
|
2372
|
-
if (this._internalSetup) {
|
|
2373
|
-
return super.removeChild(...children);
|
|
2374
|
-
}
|
|
2375
|
-
for (const child of children) {
|
|
2376
|
-
const idx = this._items.indexOf(child);
|
|
2377
|
-
if (idx !== -1) {
|
|
2378
|
-
this._items.splice(idx, 1);
|
|
2379
|
-
this._content.removeChild(child);
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
this.layoutItems();
|
|
2383
|
-
return children[0];
|
|
2384
|
-
}
|
|
2385
|
-
/** React reconciler update hook */
|
|
2386
|
-
updateConfig(changed) {
|
|
2387
|
-
if ('width' in changed || 'height' in changed) {
|
|
2388
|
-
this.setViewportSize(changed.width ?? this._viewport.width, changed.height ?? this._viewport.height);
|
|
2389
|
-
}
|
|
2390
|
-
}
|
|
2391
|
-
/** Enable mouse wheel scrolling (call after adding to stage) */
|
|
2392
|
-
enableWheel(canvas) {
|
|
2393
|
-
if (this._onWheelBound) {
|
|
2394
|
-
canvas.addEventListener('wheel', this._onWheelBound, { passive: false });
|
|
2395
|
-
}
|
|
2396
|
-
}
|
|
2397
|
-
/** Set scrollable content. Replaces any existing items. */
|
|
2398
|
-
setContent(content) {
|
|
2399
|
-
this.clearItems();
|
|
2400
|
-
const children = [...content.children];
|
|
2401
|
-
for (const child of children) {
|
|
2402
|
-
this.addItem(child);
|
|
2403
|
-
}
|
|
2404
|
-
}
|
|
2405
|
-
/** Add a single item */
|
|
2406
|
-
addItem(child) {
|
|
2407
|
-
this._items.push(child);
|
|
2408
|
-
this._content.addChild(child);
|
|
2409
|
-
this.layoutItems();
|
|
2410
|
-
return this;
|
|
2411
|
-
}
|
|
2412
|
-
/** Remove all items */
|
|
2413
|
-
clearItems() {
|
|
2414
|
-
for (const item of this._items) {
|
|
2415
|
-
this._content.removeChild(item);
|
|
2416
|
-
}
|
|
2417
|
-
this._items.length = 0;
|
|
2418
|
-
}
|
|
2419
|
-
/** Get items */
|
|
2420
|
-
get items() {
|
|
2421
|
-
return this._items;
|
|
2422
|
-
}
|
|
2423
|
-
/** Scroll to make a specific item index visible */
|
|
2424
|
-
scrollToItem(index) {
|
|
2425
|
-
if (index < 0 || index >= this._items.length)
|
|
2426
|
-
return;
|
|
2427
|
-
const item = this._items[index];
|
|
2428
|
-
const isVert = this._scrollConfig.direction !== 'horizontal';
|
|
2429
|
-
if (isVert) {
|
|
2430
|
-
this._content.y = -item.y + this._scrollConfig.padding;
|
|
2431
|
-
}
|
|
2432
|
-
else {
|
|
2433
|
-
this._content.x = -item.x + this._scrollConfig.padding;
|
|
2434
|
-
}
|
|
2435
|
-
this.clampScroll();
|
|
2436
|
-
}
|
|
2437
|
-
/** Current scroll position */
|
|
2438
|
-
get scrollPosition() {
|
|
2439
|
-
return { x: this._content.x, y: this._content.y };
|
|
2440
|
-
}
|
|
2441
|
-
/** Resize the scroll viewport */
|
|
2442
|
-
setViewportSize(width, height) {
|
|
2443
|
-
this._viewport.width = width;
|
|
2444
|
-
this._viewport.height = height;
|
|
2445
|
-
this._maskGfx.clear();
|
|
2446
|
-
this._maskGfx.roundRect(0, 0, width, height, this._scrollConfig.borderRadius).fill(0xffffff);
|
|
2447
|
-
if (this._bg) {
|
|
2448
|
-
this._bg.clear();
|
|
2449
|
-
this._bg.roundRect(0, 0, width, height, this._scrollConfig.borderRadius)
|
|
2450
|
-
.fill(0xffffff); // color will be overridden if needed
|
|
2451
|
-
}
|
|
2452
|
-
this.clampScroll();
|
|
2453
|
-
}
|
|
2454
|
-
// ─── Layout ──────────────────────────────────────────
|
|
2455
|
-
layoutItems() {
|
|
2456
|
-
const { direction, elementsMargin, padding } = this._scrollConfig;
|
|
2457
|
-
const isVert = direction !== 'horizontal';
|
|
2458
|
-
let pos = padding;
|
|
2459
|
-
for (const item of this._items) {
|
|
2460
|
-
if (isVert) {
|
|
2461
|
-
item.x = padding;
|
|
2462
|
-
item.y = pos;
|
|
2463
|
-
pos += item.height + elementsMargin;
|
|
2464
|
-
}
|
|
2465
|
-
else {
|
|
2466
|
-
item.x = pos;
|
|
2467
|
-
item.y = padding;
|
|
2468
|
-
pos += item.width + elementsMargin;
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
}
|
|
2472
|
-
// ─── Drag handling ───────────────────────────────────
|
|
2473
|
-
_onPointerDown(e) {
|
|
2474
|
-
this._dragging = true;
|
|
2475
|
-
this._inertiaActive = false;
|
|
2476
|
-
this._dragStart.x = e.globalX;
|
|
2477
|
-
this._dragStart.y = e.globalY;
|
|
2478
|
-
this._contentStart.x = this._content.x;
|
|
2479
|
-
this._contentStart.y = this._content.y;
|
|
2480
|
-
this._lastDragPos.x = e.globalX;
|
|
2481
|
-
this._lastDragPos.y = e.globalY;
|
|
2482
|
-
this._lastDragTime = Date.now();
|
|
2483
|
-
this._velocity.x = 0;
|
|
2484
|
-
this._velocity.y = 0;
|
|
2485
|
-
this.stopInertia();
|
|
2486
|
-
}
|
|
2487
|
-
_onPointerMove(e) {
|
|
2488
|
-
if (!this._dragging)
|
|
2489
|
-
return;
|
|
2490
|
-
const dx = e.globalX - this._dragStart.x;
|
|
2491
|
-
const dy = e.globalY - this._dragStart.y;
|
|
2492
|
-
const { direction } = this._scrollConfig;
|
|
2493
|
-
if (direction !== 'horizontal') {
|
|
2494
|
-
this._content.y = this._contentStart.y + dy;
|
|
2495
|
-
}
|
|
2496
|
-
if (direction !== 'vertical') {
|
|
2497
|
-
this._content.x = this._contentStart.x + dx;
|
|
2498
|
-
}
|
|
2499
|
-
// Track velocity
|
|
2500
|
-
const now = Date.now();
|
|
2501
|
-
const dt = now - this._lastDragTime;
|
|
2502
|
-
if (dt > 0) {
|
|
2503
|
-
this._velocity.x = (e.globalX - this._lastDragPos.x) / dt * 16;
|
|
2504
|
-
this._velocity.y = (e.globalY - this._lastDragPos.y) / dt * 16;
|
|
2505
|
-
}
|
|
2506
|
-
this._lastDragPos.x = e.globalX;
|
|
2507
|
-
this._lastDragPos.y = e.globalY;
|
|
2508
|
-
this._lastDragTime = now;
|
|
2509
|
-
this.clampScroll();
|
|
2510
|
-
}
|
|
2511
|
-
_onPointerUp() {
|
|
2512
|
-
if (!this._dragging)
|
|
2513
|
-
return;
|
|
2514
|
-
this._dragging = false;
|
|
2515
|
-
if (!this._scrollConfig.disableEasing &&
|
|
2516
|
-
(Math.abs(this._velocity.x) > MIN_VELOCITY || Math.abs(this._velocity.y) > MIN_VELOCITY)) {
|
|
2517
|
-
this.startInertia();
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
2520
|
-
// ─── Inertia ─────────────────────────────────────────
|
|
2521
|
-
startInertia() {
|
|
2522
|
-
this._inertiaActive = true;
|
|
2523
|
-
this._onTickBound = this._inertiaTick.bind(this);
|
|
2524
|
-
Ticker.shared.add(this._onTickBound);
|
|
2525
|
-
}
|
|
2526
|
-
stopInertia() {
|
|
2527
|
-
if (this._onTickBound && this._inertiaActive) {
|
|
2528
|
-
Ticker.shared.remove(this._onTickBound);
|
|
2529
|
-
this._inertiaActive = false;
|
|
2530
|
-
}
|
|
2531
|
-
}
|
|
2532
|
-
_inertiaTick() {
|
|
2533
|
-
const { direction } = this._scrollConfig;
|
|
2534
|
-
if (direction !== 'horizontal') {
|
|
2535
|
-
this._content.y += this._velocity.y;
|
|
2536
|
-
this._velocity.y *= DECELERATION;
|
|
2537
|
-
}
|
|
2538
|
-
if (direction !== 'vertical') {
|
|
2539
|
-
this._content.x += this._velocity.x;
|
|
2540
|
-
this._velocity.x *= DECELERATION;
|
|
2541
|
-
}
|
|
2542
|
-
this.clampScroll();
|
|
2543
|
-
if (Math.abs(this._velocity.x) < MIN_VELOCITY && Math.abs(this._velocity.y) < MIN_VELOCITY) {
|
|
2544
|
-
this.stopInertia();
|
|
2545
|
-
}
|
|
2546
|
-
}
|
|
2547
|
-
// ─── Mouse wheel ─────────────────────────────────────
|
|
2548
|
-
_onWheel(e) {
|
|
2549
|
-
const { direction } = this._scrollConfig;
|
|
2550
|
-
e.preventDefault();
|
|
2551
|
-
if (direction !== 'horizontal') {
|
|
2552
|
-
this._content.y -= e.deltaY;
|
|
2553
|
-
}
|
|
2554
|
-
if (direction !== 'vertical') {
|
|
2555
|
-
this._content.x -= e.deltaX;
|
|
2556
|
-
}
|
|
2557
|
-
this.clampScroll();
|
|
2558
|
-
}
|
|
2559
|
-
// ─── Scroll bounds ───────────────────────────────────
|
|
2560
|
-
clampScroll() {
|
|
2561
|
-
const { direction } = this._scrollConfig;
|
|
2562
|
-
const bounds = this._content.getLocalBounds();
|
|
2563
|
-
if (direction !== 'horizontal') {
|
|
2564
|
-
const contentHeight = bounds.height + bounds.y;
|
|
2565
|
-
const maxScroll = Math.min(0, this._viewport.height - contentHeight);
|
|
2566
|
-
this._content.y = Math.max(maxScroll, Math.min(0, this._content.y));
|
|
2567
|
-
}
|
|
2568
|
-
if (direction !== 'vertical') {
|
|
2569
|
-
const contentWidth = bounds.width + bounds.x;
|
|
2570
|
-
const maxScroll = Math.min(0, this._viewport.width - contentWidth);
|
|
2571
|
-
this._content.x = Math.max(maxScroll, Math.min(0, this._content.x));
|
|
2572
|
-
}
|
|
2573
|
-
this.updateScrollbar();
|
|
2574
|
-
}
|
|
2575
|
-
updateScrollbar() {
|
|
2576
|
-
if (!this._scrollbar)
|
|
2577
|
-
return;
|
|
2578
|
-
const { direction } = this._scrollConfig;
|
|
2579
|
-
const { width: sbW, padding: sbPad } = this._scrollbarConfig;
|
|
2580
|
-
const bounds = this._content.getLocalBounds();
|
|
2581
|
-
const isVert = direction !== 'horizontal';
|
|
2582
|
-
if (isVert) {
|
|
2583
|
-
const contentH = bounds.height + bounds.y;
|
|
2584
|
-
if (contentH <= this._viewport.height) {
|
|
2585
|
-
this._scrollbar.visible = false;
|
|
2586
|
-
return;
|
|
2587
|
-
}
|
|
2588
|
-
this._scrollbar.visible = true;
|
|
2589
|
-
const ratio = this._viewport.height / contentH;
|
|
2590
|
-
const thumbH = Math.max(20, this._viewport.height * ratio);
|
|
2591
|
-
const scrollRange = this._viewport.height - thumbH;
|
|
2592
|
-
const scrollProgress = -this._content.y / (contentH - this._viewport.height);
|
|
2593
|
-
this._scrollbar.x = this._viewport.width - sbW - sbPad;
|
|
2594
|
-
this._scrollbar.y = scrollProgress * scrollRange;
|
|
2595
|
-
this._scrollbar.height = thumbH;
|
|
2596
|
-
this._scrollbar.width = sbW;
|
|
2597
|
-
}
|
|
2598
|
-
else {
|
|
2599
|
-
const contentW = bounds.width + bounds.x;
|
|
2600
|
-
if (contentW <= this._viewport.width) {
|
|
2601
|
-
this._scrollbar.visible = false;
|
|
2602
|
-
return;
|
|
2603
|
-
}
|
|
2604
|
-
this._scrollbar.visible = true;
|
|
2605
|
-
const ratio = this._viewport.width / contentW;
|
|
2606
|
-
const thumbW = Math.max(20, this._viewport.width * ratio);
|
|
2607
|
-
const scrollRange = this._viewport.width - thumbW;
|
|
2608
|
-
const scrollProgress = -this._content.x / (contentW - this._viewport.width);
|
|
2609
|
-
this._scrollbar.y = this._viewport.height - sbW - sbPad;
|
|
2610
|
-
this._scrollbar.x = scrollProgress * scrollRange;
|
|
2611
|
-
this._scrollbar.width = thumbW;
|
|
2612
|
-
this._scrollbar.height = sbW;
|
|
2613
|
-
}
|
|
2614
|
-
}
|
|
2615
|
-
destroy(options) {
|
|
2616
|
-
this.stopInertia();
|
|
2617
|
-
this.off('pointerdown', this._onPointerDown, this);
|
|
2618
|
-
this.off('pointermove', this._onPointerMove, this);
|
|
2619
|
-
this.off('pointerup', this._onPointerUp, this);
|
|
2620
|
-
this.off('pointerupoutside', this._onPointerUp, this);
|
|
2621
|
-
this._items.length = 0;
|
|
2622
|
-
super.destroy(options);
|
|
2623
|
-
}
|
|
2624
|
-
}
|
|
2625
|
-
|
|
2626
|
-
/**
|
|
2627
|
-
* Draggable slider with customizable track, fill, and handle views.
|
|
2628
|
-
*
|
|
2629
|
-
* @example
|
|
2630
|
-
* ```ts
|
|
2631
|
-
* const volume = new Slider({
|
|
2632
|
-
* min: 0, max: 1, value: 0.5,
|
|
2633
|
-
* width: 200, height: 8,
|
|
2634
|
-
* fillColor: 0xffd700,
|
|
2635
|
-
* onUpdate: (v) => console.log('Volume:', v),
|
|
2636
|
-
* });
|
|
2637
|
-
* ```
|
|
2638
|
-
*/
|
|
2639
|
-
class Slider extends Container {
|
|
2640
|
-
__uiComponent = true;
|
|
2641
|
-
_track;
|
|
2642
|
-
_fill;
|
|
2643
|
-
_fillMask;
|
|
2644
|
-
_handle;
|
|
2645
|
-
_config;
|
|
2646
|
-
_value;
|
|
2647
|
-
_dragging = false;
|
|
2648
|
-
onUpdate = null;
|
|
2649
|
-
onChange = null;
|
|
2650
|
-
constructor(config = {}) {
|
|
2651
|
-
super();
|
|
2652
|
-
this._config = {
|
|
2653
|
-
min: config.min ?? 0,
|
|
2654
|
-
max: config.max ?? 1,
|
|
2655
|
-
step: config.step ?? 0,
|
|
2656
|
-
width: config.width ?? 200,
|
|
2657
|
-
height: config.height ?? 8,
|
|
2658
|
-
borderRadius: config.borderRadius ?? 4,
|
|
2659
|
-
trackColor: config.trackColor ?? 0x333333,
|
|
2660
|
-
fillColor: config.fillColor ?? 0xffd700,
|
|
2661
|
-
handleRadius: config.handleRadius ?? 12,
|
|
2662
|
-
handleColor: config.handleColor ?? 0xffffff,
|
|
2663
|
-
};
|
|
2664
|
-
this._value = config.value ?? this._config.min;
|
|
2665
|
-
this.onUpdate = config.onUpdate ?? null;
|
|
2666
|
-
this.onChange = config.onChange ?? null;
|
|
2667
|
-
const { width, height, borderRadius, trackColor, fillColor, handleRadius, handleColor } = this._config;
|
|
2668
|
-
// Track
|
|
2669
|
-
const customTrack = resolveView(config.trackView);
|
|
2670
|
-
if (customTrack) {
|
|
2671
|
-
customTrack.width = width;
|
|
2672
|
-
customTrack.height = height;
|
|
2673
|
-
this._track = customTrack;
|
|
2674
|
-
}
|
|
2675
|
-
else {
|
|
2676
|
-
const g = new Graphics();
|
|
2677
|
-
g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
|
|
2678
|
-
this._track = g;
|
|
2679
|
-
}
|
|
2680
|
-
this.addChild(this._track);
|
|
2681
|
-
// Fill
|
|
2682
|
-
const customFill = resolveView(config.fillView);
|
|
2683
|
-
if (customFill) {
|
|
2684
|
-
customFill.width = width;
|
|
2685
|
-
customFill.height = height;
|
|
2686
|
-
this._fill = customFill;
|
|
2687
|
-
}
|
|
2688
|
-
else {
|
|
2689
|
-
const g = new Graphics();
|
|
2690
|
-
g.roundRect(0, 0, width, height, borderRadius).fill(fillColor);
|
|
2691
|
-
this._fill = g;
|
|
2692
|
-
}
|
|
2693
|
-
this.addChild(this._fill);
|
|
2694
|
-
// Fill mask
|
|
2695
|
-
this._fillMask = new Graphics();
|
|
2696
|
-
this.addChild(this._fillMask);
|
|
2697
|
-
this._fill.mask = this._fillMask;
|
|
2698
|
-
// Handle
|
|
2699
|
-
const customHandle = resolveView(config.handleView);
|
|
2700
|
-
if (customHandle) {
|
|
2701
|
-
this._handle = customHandle;
|
|
2702
|
-
}
|
|
2703
|
-
else {
|
|
2704
|
-
const g = new Graphics();
|
|
2705
|
-
g.circle(0, 0, handleRadius).fill(handleColor);
|
|
2706
|
-
this._handle = g;
|
|
2707
|
-
}
|
|
2708
|
-
this._handle.y = height / 2;
|
|
2709
|
-
this.addChild(this._handle);
|
|
2710
|
-
// Interaction
|
|
2711
|
-
this.eventMode = 'static';
|
|
2712
|
-
this.cursor = 'pointer';
|
|
2713
|
-
// Hit area covers track + handle overflow
|
|
2714
|
-
const hitPad = Math.max(handleRadius - height / 2, 0);
|
|
2715
|
-
this.hitArea = { contains: (x, y) => x >= -hitPad && x <= width + hitPad && y >= -hitPad && y <= height + hitPad };
|
|
2716
|
-
this.on('pointerdown', this._onPointerDown, this);
|
|
2717
|
-
this.on('globalpointermove', this._onPointerMove, this);
|
|
2718
|
-
this.on('pointerup', this._onPointerUp, this);
|
|
2719
|
-
this.on('pointerupoutside', this._onPointerUp, this);
|
|
2720
|
-
this._updateVisuals();
|
|
2721
|
-
}
|
|
2722
|
-
/** Current value */
|
|
2723
|
-
get value() {
|
|
2724
|
-
return this._value;
|
|
2725
|
-
}
|
|
2726
|
-
set value(v) {
|
|
2727
|
-
const clamped = this._applyStep(Math.max(this._config.min, Math.min(this._config.max, v)));
|
|
2728
|
-
if (clamped === this._value)
|
|
2729
|
-
return;
|
|
2730
|
-
this._value = clamped;
|
|
2731
|
-
this._updateVisuals();
|
|
2732
|
-
}
|
|
2733
|
-
get min() { return this._config.min; }
|
|
2734
|
-
get max() { return this._config.max; }
|
|
2735
|
-
/** React reconciler update hook */
|
|
2736
|
-
updateConfig(changed) {
|
|
2737
|
-
if ('value' in changed)
|
|
2738
|
-
this.value = changed.value;
|
|
2739
|
-
if ('min' in changed) {
|
|
2740
|
-
this._config.min = changed.min;
|
|
2741
|
-
this._updateVisuals();
|
|
2742
|
-
}
|
|
2743
|
-
if ('max' in changed) {
|
|
2744
|
-
this._config.max = changed.max;
|
|
2745
|
-
this._updateVisuals();
|
|
2746
|
-
}
|
|
2747
|
-
if ('step' in changed)
|
|
2748
|
-
this._config.step = changed.step;
|
|
2749
|
-
if ('onUpdate' in changed)
|
|
2750
|
-
this.onUpdate = changed.onUpdate;
|
|
2751
|
-
if ('onChange' in changed)
|
|
2752
|
-
this.onChange = changed.onChange;
|
|
2753
|
-
}
|
|
2754
|
-
_fraction() {
|
|
2755
|
-
const { min, max } = this._config;
|
|
2756
|
-
return max === min ? 0 : (this._value - min) / (max - min);
|
|
2757
|
-
}
|
|
2758
|
-
_applyStep(v) {
|
|
2759
|
-
const { step, min } = this._config;
|
|
2760
|
-
if (step <= 0)
|
|
2761
|
-
return v;
|
|
2762
|
-
return min + Math.round((v - min) / step) * step;
|
|
2763
|
-
}
|
|
2764
|
-
_updateVisuals() {
|
|
2765
|
-
const frac = this._fraction();
|
|
2766
|
-
const w = this._config.width;
|
|
2767
|
-
const h = this._config.height;
|
|
2768
|
-
// Update fill mask
|
|
2769
|
-
this._fillMask.clear();
|
|
2770
|
-
this._fillMask.rect(0, 0, w * frac, h).fill(0xffffff);
|
|
2771
|
-
// Update handle position
|
|
2772
|
-
this._handle.x = w * frac;
|
|
2773
|
-
}
|
|
2774
|
-
_valueFromPointer(e) {
|
|
2775
|
-
const local = this.toLocal(e.global);
|
|
2776
|
-
const frac = Math.max(0, Math.min(1, local.x / this._config.width));
|
|
2777
|
-
const { min, max } = this._config;
|
|
2778
|
-
return this._applyStep(min + frac * (max - min));
|
|
2779
|
-
}
|
|
2780
|
-
_onPointerDown(e) {
|
|
2781
|
-
this._dragging = true;
|
|
2782
|
-
const newValue = this._valueFromPointer(e);
|
|
2783
|
-
if (newValue !== this._value) {
|
|
2784
|
-
this._value = newValue;
|
|
2785
|
-
this._updateVisuals();
|
|
2786
|
-
this.onUpdate?.(this._value);
|
|
2787
|
-
}
|
|
2788
|
-
}
|
|
2789
|
-
_onPointerMove(e) {
|
|
2790
|
-
if (!this._dragging)
|
|
2791
|
-
return;
|
|
2792
|
-
const newValue = this._valueFromPointer(e);
|
|
2793
|
-
if (newValue !== this._value) {
|
|
2794
|
-
this._value = newValue;
|
|
2795
|
-
this._updateVisuals();
|
|
2796
|
-
this.onUpdate?.(this._value);
|
|
2797
|
-
}
|
|
2798
|
-
}
|
|
2799
|
-
_onPointerUp(_e) {
|
|
2800
|
-
if (!this._dragging)
|
|
2801
|
-
return;
|
|
2802
|
-
this._dragging = false;
|
|
2803
|
-
this.onChange?.(this._value);
|
|
2804
|
-
}
|
|
2805
|
-
destroy(options) {
|
|
2806
|
-
this.off('pointerdown', this._onPointerDown, this);
|
|
2807
|
-
this.off('globalpointermove', this._onPointerMove, this);
|
|
2808
|
-
this.off('pointerup', this._onPointerUp, this);
|
|
2809
|
-
this.off('pointerupoutside', this._onPointerUp, this);
|
|
2810
|
-
this.onUpdate = null;
|
|
2811
|
-
this.onChange = null;
|
|
2812
|
-
super.destroy(options);
|
|
2813
|
-
}
|
|
2814
|
-
}
|
|
2815
|
-
|
|
2816
|
-
/**
|
|
2817
|
-
* Toggle switch with two states.
|
|
2818
|
-
*
|
|
2819
|
-
* Supports custom ON/OFF views or auto-generated Graphics-based toggle.
|
|
2820
|
-
* Click to toggle, or use `forceSwitch(value)` programmatically.
|
|
2821
|
-
*
|
|
2822
|
-
* @example
|
|
2823
|
-
* ```ts
|
|
2824
|
-
* const mute = new Toggle({
|
|
2825
|
-
* value: false,
|
|
2826
|
-
* onColor: 0x22cc22,
|
|
2827
|
-
* onChange: (on) => audioManager.mute(!on),
|
|
2828
|
-
* });
|
|
2829
|
-
* ```
|
|
2830
|
-
*/
|
|
2831
|
-
class Toggle extends Container {
|
|
2832
|
-
__uiComponent = true;
|
|
2833
|
-
_value;
|
|
2834
|
-
_onView = null;
|
|
2835
|
-
_offView = null;
|
|
2836
|
-
_handle = null;
|
|
2837
|
-
_trackGfx = null;
|
|
2838
|
-
_config;
|
|
2839
|
-
_useCustomViews;
|
|
2840
|
-
onChange = null;
|
|
2841
|
-
constructor(config = {}) {
|
|
2842
|
-
super();
|
|
2843
|
-
this._config = {
|
|
2844
|
-
width: config.width ?? 52,
|
|
2845
|
-
height: config.height ?? 28,
|
|
2846
|
-
onColor: config.onColor ?? 0x22cc22,
|
|
2847
|
-
offColor: config.offColor ?? 0x666666,
|
|
2848
|
-
handleColor: config.handleColor ?? 0xffffff,
|
|
2849
|
-
handleRadius: config.handleRadius ?? 0, // 0 = auto
|
|
2850
|
-
animationDuration: config.animationDuration ?? 200,
|
|
2851
|
-
};
|
|
2852
|
-
this._value = config.value ?? false;
|
|
2853
|
-
this.onChange = config.onChange ?? null;
|
|
2854
|
-
const customOn = resolveView(config.onView);
|
|
2855
|
-
const customOff = resolveView(config.offView);
|
|
2856
|
-
this._useCustomViews = !!(customOn || customOff);
|
|
2857
|
-
if (this._useCustomViews) {
|
|
2858
|
-
// Custom view mode: show/hide ON and OFF views
|
|
2859
|
-
if (customOn) {
|
|
2860
|
-
this._onView = customOn;
|
|
2861
|
-
this._onView.visible = this._value;
|
|
2862
|
-
this.addChild(this._onView);
|
|
2863
|
-
}
|
|
2864
|
-
if (customOff) {
|
|
2865
|
-
this._offView = customOff;
|
|
2866
|
-
this._offView.visible = !this._value;
|
|
2867
|
-
this.addChild(this._offView);
|
|
2868
|
-
}
|
|
2869
|
-
}
|
|
2870
|
-
else {
|
|
2871
|
-
// Graphics mode: track + sliding handle
|
|
2872
|
-
const { width, height, handleColor } = this._config;
|
|
2873
|
-
const handleRadius = this._config.handleRadius || (height / 2 - 3);
|
|
2874
|
-
this._config.handleRadius = handleRadius;
|
|
2875
|
-
this._trackGfx = new Graphics();
|
|
2876
|
-
this.addChild(this._trackGfx);
|
|
2877
|
-
this._drawTrack();
|
|
2878
|
-
const handle = new Graphics();
|
|
2879
|
-
handle.circle(0, 0, handleRadius).fill(handleColor);
|
|
2880
|
-
handle.y = height / 2;
|
|
2881
|
-
handle.x = this._value ? width - handleRadius - 3 : handleRadius + 3;
|
|
2882
|
-
this._handle = handle;
|
|
2883
|
-
this.addChild(handle);
|
|
2884
|
-
}
|
|
2885
|
-
// Interaction
|
|
2886
|
-
this.eventMode = 'static';
|
|
2887
|
-
this.cursor = 'pointer';
|
|
2888
|
-
this.on('pointertap', this._onTap, this);
|
|
2889
|
-
}
|
|
2890
|
-
/** Current toggle state */
|
|
2891
|
-
get value() {
|
|
2892
|
-
return this._value;
|
|
2893
|
-
}
|
|
2894
|
-
set value(v) {
|
|
2895
|
-
if (v === this._value)
|
|
2896
|
-
return;
|
|
2897
|
-
this.forceSwitch(v);
|
|
2898
|
-
}
|
|
2899
|
-
/** Programmatically switch to a specific state with animation */
|
|
2900
|
-
forceSwitch(value) {
|
|
2901
|
-
this._value = value;
|
|
2902
|
-
this._animateToState();
|
|
2903
|
-
}
|
|
2904
|
-
/** React reconciler update hook */
|
|
2905
|
-
updateConfig(changed) {
|
|
2906
|
-
if ('value' in changed)
|
|
2907
|
-
this.value = changed.value;
|
|
2908
|
-
if ('onChange' in changed)
|
|
2909
|
-
this.onChange = changed.onChange;
|
|
2910
|
-
if ('animationDuration' in changed)
|
|
2911
|
-
this._config.animationDuration = changed.animationDuration;
|
|
2912
|
-
}
|
|
2913
|
-
_onTap() {
|
|
2914
|
-
this._value = !this._value;
|
|
2915
|
-
this._animateToState();
|
|
2916
|
-
this.onChange?.(this._value);
|
|
2917
|
-
}
|
|
2918
|
-
_animateToState() {
|
|
2919
|
-
const duration = this._config.animationDuration;
|
|
2920
|
-
if (this._useCustomViews) {
|
|
2921
|
-
// Custom views: crossfade
|
|
2922
|
-
if (this._onView) {
|
|
2923
|
-
Tween.killTweensOf(this._onView);
|
|
2924
|
-
if (this._value) {
|
|
2925
|
-
this._onView.visible = true;
|
|
2926
|
-
Tween.to(this._onView, { alpha: 1 }, duration);
|
|
2927
|
-
}
|
|
2928
|
-
else {
|
|
2929
|
-
Tween.to(this._onView, { alpha: 0 }, duration).then(() => {
|
|
2930
|
-
if (this._onView)
|
|
2931
|
-
this._onView.visible = false;
|
|
2932
|
-
});
|
|
2933
|
-
}
|
|
2934
|
-
}
|
|
2935
|
-
if (this._offView) {
|
|
2936
|
-
Tween.killTweensOf(this._offView);
|
|
2937
|
-
if (!this._value) {
|
|
2938
|
-
this._offView.visible = true;
|
|
2939
|
-
Tween.to(this._offView, { alpha: 1 }, duration);
|
|
2940
|
-
}
|
|
2941
|
-
else {
|
|
2942
|
-
Tween.to(this._offView, { alpha: 0 }, duration).then(() => {
|
|
2943
|
-
if (this._offView)
|
|
2944
|
-
this._offView.visible = false;
|
|
2945
|
-
});
|
|
2946
|
-
}
|
|
2947
|
-
}
|
|
2948
|
-
}
|
|
2949
|
-
else {
|
|
2950
|
-
// Graphics mode: slide handle + recolor track
|
|
2951
|
-
this._drawTrack();
|
|
2952
|
-
if (this._handle) {
|
|
2953
|
-
const { width } = this._config;
|
|
2954
|
-
const handleRadius = this._config.handleRadius;
|
|
2955
|
-
const targetX = this._value ? width - handleRadius - 3 : handleRadius + 3;
|
|
2956
|
-
Tween.killTweensOf(this._handle);
|
|
2957
|
-
Tween.to(this._handle, { x: targetX }, duration);
|
|
2958
|
-
}
|
|
2959
|
-
}
|
|
2960
|
-
}
|
|
2961
|
-
_drawTrack() {
|
|
2962
|
-
if (!this._trackGfx)
|
|
2963
|
-
return;
|
|
2964
|
-
const { width, height, onColor, offColor } = this._config;
|
|
2965
|
-
const radius = height / 2;
|
|
2966
|
-
this._trackGfx.clear();
|
|
2967
|
-
this._trackGfx.roundRect(0, 0, width, height, radius).fill(this._value ? onColor : offColor);
|
|
2968
|
-
}
|
|
2969
|
-
destroy(options) {
|
|
2970
|
-
this.off('pointertap', this._onTap, this);
|
|
2971
|
-
if (this._handle)
|
|
2972
|
-
Tween.killTweensOf(this._handle);
|
|
2973
|
-
if (this._onView)
|
|
2974
|
-
Tween.killTweensOf(this._onView);
|
|
2975
|
-
if (this._offView)
|
|
2976
|
-
Tween.killTweensOf(this._offView);
|
|
2977
|
-
this.onChange = null;
|
|
2978
|
-
super.destroy(options);
|
|
2979
|
-
}
|
|
2980
|
-
}
|
|
2981
|
-
|
|
2982
|
-
export { BalanceDisplay, Button, FlexContainer, Label, LabelValue, Layout, Modal, Panel, ProgressBar, ScrollContainer, Slider, Toast, Toggle, WinDisplay, resolveView };
|
|
2983
|
-
//# sourceMappingURL=ui.esm.js.map
|