@energy8platform/game-engine 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -44
- package/dist/core.cjs.js +1 -0
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.esm.js +1 -0
- package/dist/core.esm.js.map +1 -1
- package/dist/index.cjs.js +296 -787
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +56 -129
- package/dist/index.esm.js +297 -788
- package/dist/index.esm.js.map +1 -1
- package/dist/ui.cjs.js +613 -1104
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.d.ts +55 -128
- package/dist/ui.esm.js +614 -1105
- package/dist/ui.esm.js.map +1 -1
- package/dist/vite.cjs.js +23 -3
- package/dist/vite.cjs.js.map +1 -1
- package/dist/vite.d.ts +1 -1
- package/dist/vite.esm.js +23 -3
- package/dist/vite.esm.js.map +1 -1
- package/package.json +17 -2
- package/src/core/GameApplication.ts +1 -0
- package/src/index.ts +11 -0
- package/src/ui/BalanceDisplay.ts +0 -3
- package/src/ui/Button.ts +71 -130
- package/src/ui/Layout.ts +109 -181
- package/src/ui/Modal.ts +6 -5
- package/src/ui/Panel.ts +52 -55
- package/src/ui/ProgressBar.ts +52 -57
- package/src/ui/ScrollContainer.ts +58 -489
- package/src/ui/Toast.ts +5 -9
- package/src/ui/index.ts +13 -0
- package/src/vite/index.ts +23 -3
package/dist/ui.esm.js
CHANGED
|
@@ -1,434 +1,115 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* All functions take a progress value t (0..1) and return the eased value.
|
|
7
|
-
*/
|
|
8
|
-
const Easing = {
|
|
9
|
-
linear: (t) => t,
|
|
10
|
-
easeInQuad: (t) => t * t,
|
|
11
|
-
easeOutQuad: (t) => t * (2 - t),
|
|
12
|
-
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
|
13
|
-
easeInCubic: (t) => t * t * t,
|
|
14
|
-
easeOutCubic: (t) => --t * t * t + 1,
|
|
15
|
-
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
|
|
16
|
-
easeInQuart: (t) => t * t * t * t,
|
|
17
|
-
easeOutQuart: (t) => 1 - --t * t * t * t,
|
|
18
|
-
easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
|
|
19
|
-
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
20
|
-
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
|
|
21
|
-
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
|
22
|
-
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
|
|
23
|
-
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
|
24
|
-
easeInOutExpo: (t) => t === 0
|
|
25
|
-
? 0
|
|
26
|
-
: t === 1
|
|
27
|
-
? 1
|
|
28
|
-
: t < 0.5
|
|
29
|
-
? Math.pow(2, 20 * t - 10) / 2
|
|
30
|
-
: (2 - Math.pow(2, -20 * t + 10)) / 2,
|
|
31
|
-
easeInBack: (t) => {
|
|
32
|
-
const c1 = 1.70158;
|
|
33
|
-
const c3 = c1 + 1;
|
|
34
|
-
return c3 * t * t * t - c1 * t * t;
|
|
35
|
-
},
|
|
36
|
-
easeOutBack: (t) => {
|
|
37
|
-
const c1 = 1.70158;
|
|
38
|
-
const c3 = c1 + 1;
|
|
39
|
-
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
40
|
-
},
|
|
41
|
-
easeInOutBack: (t) => {
|
|
42
|
-
const c1 = 1.70158;
|
|
43
|
-
const c2 = c1 * 1.525;
|
|
44
|
-
return t < 0.5
|
|
45
|
-
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
|
46
|
-
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
|
|
47
|
-
},
|
|
48
|
-
easeOutBounce: (t) => {
|
|
49
|
-
const n1 = 7.5625;
|
|
50
|
-
const d1 = 2.75;
|
|
51
|
-
if (t < 1 / d1)
|
|
52
|
-
return n1 * t * t;
|
|
53
|
-
if (t < 2 / d1)
|
|
54
|
-
return n1 * (t -= 1.5 / d1) * t + 0.75;
|
|
55
|
-
if (t < 2.5 / d1)
|
|
56
|
-
return n1 * (t -= 2.25 / d1) * t + 0.9375;
|
|
57
|
-
return n1 * (t -= 2.625 / d1) * t + 0.984375;
|
|
58
|
-
},
|
|
59
|
-
easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
|
|
60
|
-
easeInOutBounce: (t) => t < 0.5
|
|
61
|
-
? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
|
|
62
|
-
: (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
|
|
63
|
-
easeOutElastic: (t) => {
|
|
64
|
-
const c4 = (2 * Math.PI) / 3;
|
|
65
|
-
return t === 0
|
|
66
|
-
? 0
|
|
67
|
-
: t === 1
|
|
68
|
-
? 1
|
|
69
|
-
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
|
70
|
-
},
|
|
71
|
-
easeInElastic: (t) => {
|
|
72
|
-
const c4 = (2 * Math.PI) / 3;
|
|
73
|
-
return t === 0
|
|
74
|
-
? 0
|
|
75
|
-
: t === 1
|
|
76
|
-
? 1
|
|
77
|
-
: -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Lightweight tween system integrated with PixiJS Ticker.
|
|
83
|
-
* Zero external dependencies — no GSAP required.
|
|
84
|
-
*
|
|
85
|
-
* All tweens return a Promise that resolves on completion.
|
|
86
|
-
*
|
|
87
|
-
* @example
|
|
88
|
-
* ```ts
|
|
89
|
-
* // Fade in a sprite
|
|
90
|
-
* await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
|
|
91
|
-
*
|
|
92
|
-
* // Move and wait
|
|
93
|
-
* await Tween.to(sprite, { x: 500 }, 300);
|
|
94
|
-
*
|
|
95
|
-
* // From a starting value
|
|
96
|
-
* await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
|
|
97
|
-
* ```
|
|
98
|
-
*/
|
|
99
|
-
class Tween {
|
|
100
|
-
static _tweens = [];
|
|
101
|
-
static _tickerAdded = false;
|
|
102
|
-
/**
|
|
103
|
-
* Animate properties from current values to target values.
|
|
104
|
-
*
|
|
105
|
-
* @param target - Object to animate (Sprite, Container, etc.)
|
|
106
|
-
* @param props - Target property values
|
|
107
|
-
* @param duration - Duration in milliseconds
|
|
108
|
-
* @param easing - Easing function (default: easeOutQuad)
|
|
109
|
-
* @param onUpdate - Progress callback (0..1)
|
|
110
|
-
*/
|
|
111
|
-
static to(target, props, duration, easing, onUpdate) {
|
|
112
|
-
return new Promise((resolve) => {
|
|
113
|
-
// Capture starting values
|
|
114
|
-
const from = {};
|
|
115
|
-
for (const key of Object.keys(props)) {
|
|
116
|
-
from[key] = Tween.getProperty(target, key);
|
|
117
|
-
}
|
|
118
|
-
const tween = {
|
|
119
|
-
target,
|
|
120
|
-
from,
|
|
121
|
-
to: { ...props },
|
|
122
|
-
duration: Math.max(1, duration),
|
|
123
|
-
easing: easing ?? Easing.easeOutQuad,
|
|
124
|
-
elapsed: 0,
|
|
125
|
-
delay: 0,
|
|
126
|
-
resolve,
|
|
127
|
-
onUpdate,
|
|
128
|
-
};
|
|
129
|
-
Tween._tweens.push(tween);
|
|
130
|
-
Tween.ensureTicker();
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Animate properties from given values to current values.
|
|
135
|
-
*/
|
|
136
|
-
static from(target, props, duration, easing, onUpdate) {
|
|
137
|
-
// Capture current values as "to"
|
|
138
|
-
const to = {};
|
|
139
|
-
for (const key of Object.keys(props)) {
|
|
140
|
-
to[key] = Tween.getProperty(target, key);
|
|
141
|
-
Tween.setProperty(target, key, props[key]);
|
|
142
|
-
}
|
|
143
|
-
return Tween.to(target, to, duration, easing, onUpdate);
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Animate from one set of values to another.
|
|
147
|
-
*/
|
|
148
|
-
static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
|
|
149
|
-
// Set starting values
|
|
150
|
-
for (const key of Object.keys(fromProps)) {
|
|
151
|
-
Tween.setProperty(target, key, fromProps[key]);
|
|
152
|
-
}
|
|
153
|
-
return Tween.to(target, toProps, duration, easing, onUpdate);
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* Wait for a given duration (useful in timelines).
|
|
157
|
-
* Uses PixiJS Ticker for consistent timing with other tweens.
|
|
158
|
-
*/
|
|
159
|
-
static delay(ms) {
|
|
160
|
-
return new Promise((resolve) => {
|
|
161
|
-
let elapsed = 0;
|
|
162
|
-
const onTick = (ticker) => {
|
|
163
|
-
elapsed += ticker.deltaMS;
|
|
164
|
-
if (elapsed >= ms) {
|
|
165
|
-
Ticker.shared.remove(onTick);
|
|
166
|
-
resolve();
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
Ticker.shared.add(onTick);
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Kill all tweens on a target.
|
|
174
|
-
*/
|
|
175
|
-
static killTweensOf(target) {
|
|
176
|
-
Tween._tweens = Tween._tweens.filter((tw) => {
|
|
177
|
-
if (tw.target === target) {
|
|
178
|
-
tw.resolve();
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
return true;
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* Kill all active tweens.
|
|
186
|
-
*/
|
|
187
|
-
static killAll() {
|
|
188
|
-
for (const tw of Tween._tweens) {
|
|
189
|
-
tw.resolve();
|
|
190
|
-
}
|
|
191
|
-
Tween._tweens.length = 0;
|
|
192
|
-
}
|
|
193
|
-
/** Number of active tweens */
|
|
194
|
-
static get activeTweens() {
|
|
195
|
-
return Tween._tweens.length;
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Reset the tween system — kill all tweens and remove the ticker.
|
|
199
|
-
* Useful for cleanup between game instances, tests, or hot-reload.
|
|
200
|
-
*/
|
|
201
|
-
static reset() {
|
|
202
|
-
for (const tw of Tween._tweens) {
|
|
203
|
-
tw.resolve();
|
|
204
|
-
}
|
|
205
|
-
Tween._tweens.length = 0;
|
|
206
|
-
if (Tween._tickerAdded) {
|
|
207
|
-
Ticker.shared.remove(Tween.tick);
|
|
208
|
-
Tween._tickerAdded = false;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
// ─── Internal ──────────────────────────────────────────
|
|
212
|
-
static ensureTicker() {
|
|
213
|
-
if (Tween._tickerAdded)
|
|
214
|
-
return;
|
|
215
|
-
Tween._tickerAdded = true;
|
|
216
|
-
Ticker.shared.add(Tween.tick);
|
|
217
|
-
}
|
|
218
|
-
static tick = (ticker) => {
|
|
219
|
-
const dt = ticker.deltaMS;
|
|
220
|
-
const completed = [];
|
|
221
|
-
for (const tw of Tween._tweens) {
|
|
222
|
-
tw.elapsed += dt;
|
|
223
|
-
if (tw.elapsed < tw.delay)
|
|
224
|
-
continue;
|
|
225
|
-
const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
|
|
226
|
-
const t = tw.easing(raw);
|
|
227
|
-
// Interpolate each property
|
|
228
|
-
for (const key of Object.keys(tw.to)) {
|
|
229
|
-
const start = tw.from[key];
|
|
230
|
-
const end = tw.to[key];
|
|
231
|
-
const value = start + (end - start) * t;
|
|
232
|
-
Tween.setProperty(tw.target, key, value);
|
|
233
|
-
}
|
|
234
|
-
tw.onUpdate?.(raw);
|
|
235
|
-
if (raw >= 1) {
|
|
236
|
-
completed.push(tw);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
// Remove completed tweens
|
|
240
|
-
for (const tw of completed) {
|
|
241
|
-
const idx = Tween._tweens.indexOf(tw);
|
|
242
|
-
if (idx !== -1)
|
|
243
|
-
Tween._tweens.splice(idx, 1);
|
|
244
|
-
tw.resolve();
|
|
245
|
-
}
|
|
246
|
-
// Remove ticker when no active tweens
|
|
247
|
-
if (Tween._tweens.length === 0 && Tween._tickerAdded) {
|
|
248
|
-
Ticker.shared.remove(Tween.tick);
|
|
249
|
-
Tween._tickerAdded = false;
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
/**
|
|
253
|
-
* Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
|
|
254
|
-
*/
|
|
255
|
-
static getProperty(target, key) {
|
|
256
|
-
const parts = key.split('.');
|
|
257
|
-
let obj = target;
|
|
258
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
259
|
-
obj = obj[parts[i]];
|
|
260
|
-
}
|
|
261
|
-
return obj[parts[parts.length - 1]] ?? 0;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Set a potentially nested property.
|
|
265
|
-
*/
|
|
266
|
-
static setProperty(target, key, value) {
|
|
267
|
-
const parts = key.split('.');
|
|
268
|
-
let obj = target;
|
|
269
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
270
|
-
obj = obj[parts[i]];
|
|
271
|
-
}
|
|
272
|
-
obj[parts[parts.length - 1]] = value;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
1
|
+
import '@pixi/layout';
|
|
2
|
+
import { Graphics, Container, Text, Texture, NineSliceSprite, Ticker } from 'pixi.js';
|
|
3
|
+
import { FancyButton, ProgressBar as ProgressBar$1, ScrollBox } from '@pixi/ui';
|
|
4
|
+
import { LayoutContainer } from '@pixi/layout/components';
|
|
275
5
|
|
|
276
6
|
const DEFAULT_COLORS = {
|
|
277
|
-
|
|
7
|
+
default: 0xffd700,
|
|
278
8
|
hover: 0xffe44d,
|
|
279
9
|
pressed: 0xccac00,
|
|
280
10
|
disabled: 0x666666,
|
|
281
11
|
};
|
|
12
|
+
function makeGraphicsView(w, h, radius, color) {
|
|
13
|
+
const g = new Graphics();
|
|
14
|
+
g.roundRect(0, 0, w, h, radius).fill(color);
|
|
15
|
+
// Highlight overlay
|
|
16
|
+
g.roundRect(2, 2, w - 4, h * 0.45, radius).fill({ color: 0xffffff, alpha: 0.1 });
|
|
17
|
+
return g;
|
|
18
|
+
}
|
|
282
19
|
/**
|
|
283
|
-
* Interactive button component
|
|
20
|
+
* Interactive button component powered by `@pixi/ui` FancyButton.
|
|
284
21
|
*
|
|
285
|
-
* Supports both texture-based and Graphics-based rendering
|
|
22
|
+
* Supports both texture-based and Graphics-based rendering with
|
|
23
|
+
* per-state views, press animation, and text.
|
|
286
24
|
*
|
|
287
25
|
* @example
|
|
288
26
|
* ```ts
|
|
289
27
|
* const btn = new Button({
|
|
290
28
|
* width: 200, height: 60, borderRadius: 12,
|
|
291
|
-
* colors: {
|
|
29
|
+
* colors: { default: 0x22aa22, hover: 0x33cc33 },
|
|
30
|
+
* text: 'SPIN',
|
|
292
31
|
* });
|
|
293
32
|
*
|
|
294
|
-
* btn.
|
|
33
|
+
* btn.onPress.connect(() => console.log('Clicked!'));
|
|
295
34
|
* scene.container.addChild(btn);
|
|
296
35
|
* ```
|
|
297
36
|
*/
|
|
298
|
-
class Button extends
|
|
299
|
-
|
|
300
|
-
_bg;
|
|
301
|
-
_sprites = {};
|
|
302
|
-
_config;
|
|
303
|
-
/** Called when the button is tapped/clicked */
|
|
304
|
-
onTap;
|
|
305
|
-
/** Called when the button state changes */
|
|
306
|
-
onStateChange;
|
|
37
|
+
class Button extends FancyButton {
|
|
38
|
+
_buttonConfig;
|
|
307
39
|
constructor(config = {}) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
animationDuration: 100,
|
|
40
|
+
const resolvedConfig = {
|
|
41
|
+
width: config.width ?? 200,
|
|
42
|
+
height: config.height ?? 60,
|
|
43
|
+
borderRadius: config.borderRadius ?? 8,
|
|
44
|
+
pressScale: config.pressScale ?? 0.95,
|
|
45
|
+
animationDuration: config.animationDuration ?? 100,
|
|
315
46
|
...config,
|
|
316
47
|
};
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
48
|
+
const colorMap = { ...DEFAULT_COLORS, ...config.colors };
|
|
49
|
+
const { width, height, borderRadius } = resolvedConfig;
|
|
50
|
+
// Build FancyButton options
|
|
51
|
+
const options = {
|
|
52
|
+
anchor: 0.5,
|
|
53
|
+
animations: {
|
|
54
|
+
hover: {
|
|
55
|
+
props: { scale: { x: 1.03, y: 1.03 } },
|
|
56
|
+
duration: resolvedConfig.animationDuration,
|
|
57
|
+
},
|
|
58
|
+
pressed: {
|
|
59
|
+
props: { scale: { x: resolvedConfig.pressScale, y: resolvedConfig.pressScale } },
|
|
60
|
+
duration: resolvedConfig.animationDuration,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
// Texture-based views
|
|
321
65
|
if (config.textures) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
66
|
+
if (config.textures.default)
|
|
67
|
+
options.defaultView = config.textures.default;
|
|
68
|
+
if (config.textures.hover)
|
|
69
|
+
options.hoverView = config.textures.hover;
|
|
70
|
+
if (config.textures.pressed)
|
|
71
|
+
options.pressedView = config.textures.pressed;
|
|
72
|
+
if (config.textures.disabled)
|
|
73
|
+
options.disabledView = config.textures.disabled;
|
|
330
74
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
this.
|
|
75
|
+
else {
|
|
76
|
+
// Graphics-based views
|
|
77
|
+
options.defaultView = makeGraphicsView(width, height, borderRadius, colorMap.default);
|
|
78
|
+
options.hoverView = makeGraphicsView(width, height, borderRadius, colorMap.hover);
|
|
79
|
+
options.pressedView = makeGraphicsView(width, height, borderRadius, colorMap.pressed);
|
|
80
|
+
options.disabledView = makeGraphicsView(width, height, borderRadius, colorMap.disabled);
|
|
81
|
+
}
|
|
82
|
+
// Text
|
|
83
|
+
if (config.text) {
|
|
84
|
+
options.text = config.text;
|
|
85
|
+
}
|
|
86
|
+
super(options);
|
|
87
|
+
this._buttonConfig = resolvedConfig;
|
|
344
88
|
if (config.disabled) {
|
|
345
|
-
this.
|
|
89
|
+
this.enabled = false;
|
|
346
90
|
}
|
|
347
91
|
}
|
|
348
|
-
/** Current button state */
|
|
349
|
-
get state() {
|
|
350
|
-
return this._state;
|
|
351
|
-
}
|
|
352
92
|
/** Enable the button */
|
|
353
93
|
enable() {
|
|
354
|
-
|
|
355
|
-
this.setState('normal');
|
|
356
|
-
this.eventMode = 'static';
|
|
357
|
-
this.cursor = 'pointer';
|
|
358
|
-
}
|
|
94
|
+
this.enabled = true;
|
|
359
95
|
}
|
|
360
96
|
/** Disable the button */
|
|
361
97
|
disable() {
|
|
362
|
-
this.
|
|
363
|
-
this.eventMode = 'none';
|
|
364
|
-
this.cursor = 'default';
|
|
98
|
+
this.enabled = false;
|
|
365
99
|
}
|
|
366
100
|
/** Whether the button is disabled */
|
|
367
101
|
get disabled() {
|
|
368
|
-
return this.
|
|
102
|
+
return !this.enabled;
|
|
369
103
|
}
|
|
370
|
-
setState(state) {
|
|
371
|
-
if (this._state === state)
|
|
372
|
-
return;
|
|
373
|
-
this._state = state;
|
|
374
|
-
this.render();
|
|
375
|
-
this.onStateChange?.(state);
|
|
376
|
-
}
|
|
377
|
-
render() {
|
|
378
|
-
const { width, height, borderRadius, colors } = this._config;
|
|
379
|
-
const colorMap = { ...DEFAULT_COLORS, ...colors };
|
|
380
|
-
// Update Graphics
|
|
381
|
-
this._bg.clear();
|
|
382
|
-
this._bg.roundRect(0, 0, width, height, borderRadius).fill(colorMap[this._state]);
|
|
383
|
-
// Add highlight for normal/hover
|
|
384
|
-
if (this._state === 'normal' || this._state === 'hover') {
|
|
385
|
-
this._bg
|
|
386
|
-
.roundRect(2, 2, width - 4, height * 0.45, borderRadius)
|
|
387
|
-
.fill({ color: 0xffffff, alpha: 0.1 });
|
|
388
|
-
}
|
|
389
|
-
// Update sprite visibility
|
|
390
|
-
for (const [state, sprite] of Object.entries(this._sprites)) {
|
|
391
|
-
if (sprite)
|
|
392
|
-
sprite.visible = state === this._state;
|
|
393
|
-
}
|
|
394
|
-
// Fall back to normal sprite if state sprite doesn't exist
|
|
395
|
-
if (!this._sprites[this._state] && this._sprites.normal) {
|
|
396
|
-
this._sprites.normal.visible = true;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
onPointerOver = () => {
|
|
400
|
-
if (this._state === 'disabled')
|
|
401
|
-
return;
|
|
402
|
-
this.setState('hover');
|
|
403
|
-
};
|
|
404
|
-
onPointerOut = () => {
|
|
405
|
-
if (this._state === 'disabled')
|
|
406
|
-
return;
|
|
407
|
-
this.setState('normal');
|
|
408
|
-
Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration);
|
|
409
|
-
};
|
|
410
|
-
onPointerDown = () => {
|
|
411
|
-
if (this._state === 'disabled')
|
|
412
|
-
return;
|
|
413
|
-
this.setState('pressed');
|
|
414
|
-
const s = this._config.pressScale;
|
|
415
|
-
Tween.to(this.scale, { x: s, y: s }, this._config.animationDuration, Easing.easeOutQuad);
|
|
416
|
-
};
|
|
417
|
-
onPointerUp = () => {
|
|
418
|
-
if (this._state === 'disabled')
|
|
419
|
-
return;
|
|
420
|
-
this.setState('hover');
|
|
421
|
-
Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration, Easing.easeOutBack);
|
|
422
|
-
};
|
|
423
|
-
onPointerTap = () => {
|
|
424
|
-
if (this._state === 'disabled')
|
|
425
|
-
return;
|
|
426
|
-
this.onTap?.();
|
|
427
|
-
};
|
|
428
104
|
}
|
|
429
105
|
|
|
106
|
+
function makeBarGraphics(w, h, radius, color) {
|
|
107
|
+
return new Graphics().roundRect(0, 0, w, h, radius).fill(color);
|
|
108
|
+
}
|
|
430
109
|
/**
|
|
431
|
-
* Horizontal progress bar
|
|
110
|
+
* Horizontal progress bar powered by `@pixi/ui` ProgressBar.
|
|
111
|
+
*
|
|
112
|
+
* Provides optional smooth animated fill via per-frame `update()`.
|
|
432
113
|
*
|
|
433
114
|
* @example
|
|
434
115
|
* ```ts
|
|
@@ -438,33 +119,48 @@ class Button extends Container {
|
|
|
438
119
|
* ```
|
|
439
120
|
*/
|
|
440
121
|
class ProgressBar extends Container {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
_border;
|
|
122
|
+
_bar;
|
|
123
|
+
_borderGfx;
|
|
444
124
|
_config;
|
|
445
125
|
_progress = 0;
|
|
446
126
|
_displayedProgress = 0;
|
|
447
127
|
constructor(config = {}) {
|
|
448
128
|
super();
|
|
449
129
|
this._config = {
|
|
450
|
-
width: 300,
|
|
451
|
-
height: 16,
|
|
452
|
-
borderRadius: 8,
|
|
453
|
-
fillColor: 0xffd700,
|
|
454
|
-
trackColor: 0x333333,
|
|
455
|
-
borderColor: 0x555555,
|
|
456
|
-
borderWidth: 1,
|
|
457
|
-
animated: true,
|
|
458
|
-
animationSpeed: 0.1,
|
|
459
|
-
...config,
|
|
130
|
+
width: config.width ?? 300,
|
|
131
|
+
height: config.height ?? 16,
|
|
132
|
+
borderRadius: config.borderRadius ?? 8,
|
|
133
|
+
fillColor: config.fillColor ?? 0xffd700,
|
|
134
|
+
trackColor: config.trackColor ?? 0x333333,
|
|
135
|
+
borderColor: config.borderColor ?? 0x555555,
|
|
136
|
+
borderWidth: config.borderWidth ?? 1,
|
|
137
|
+
animated: config.animated ?? true,
|
|
138
|
+
animationSpeed: config.animationSpeed ?? 0.1,
|
|
460
139
|
};
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
140
|
+
const { width, height, borderRadius, fillColor, trackColor, borderColor, borderWidth } = this._config;
|
|
141
|
+
const bgGraphics = makeBarGraphics(width, height, borderRadius, trackColor);
|
|
142
|
+
const fillGraphics = makeBarGraphics(width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1), fillColor);
|
|
143
|
+
const options = {
|
|
144
|
+
bg: bgGraphics,
|
|
145
|
+
fill: fillGraphics,
|
|
146
|
+
fillPaddings: {
|
|
147
|
+
top: borderWidth,
|
|
148
|
+
right: borderWidth,
|
|
149
|
+
bottom: borderWidth,
|
|
150
|
+
left: borderWidth,
|
|
151
|
+
},
|
|
152
|
+
progress: 0,
|
|
153
|
+
};
|
|
154
|
+
this._bar = new ProgressBar$1(options);
|
|
155
|
+
this.addChild(this._bar);
|
|
156
|
+
// Border overlay
|
|
157
|
+
this._borderGfx = new Graphics();
|
|
158
|
+
if (borderColor !== undefined && borderWidth > 0) {
|
|
159
|
+
this._borderGfx
|
|
160
|
+
.roundRect(0, 0, width, height, borderRadius)
|
|
161
|
+
.stroke({ color: borderColor, width: borderWidth });
|
|
162
|
+
}
|
|
163
|
+
this.addChild(this._borderGfx);
|
|
468
164
|
}
|
|
469
165
|
/** Get/set progress (0..1) */
|
|
470
166
|
get progress() {
|
|
@@ -474,13 +170,13 @@ class ProgressBar extends Container {
|
|
|
474
170
|
this._progress = Math.max(0, Math.min(1, value));
|
|
475
171
|
if (!this._config.animated) {
|
|
476
172
|
this._displayedProgress = this._progress;
|
|
477
|
-
this.
|
|
173
|
+
this._bar.progress = this._displayedProgress * 100;
|
|
478
174
|
}
|
|
479
175
|
}
|
|
480
176
|
/**
|
|
481
177
|
* Call each frame if animated is true.
|
|
482
178
|
*/
|
|
483
|
-
update(
|
|
179
|
+
update(_dt) {
|
|
484
180
|
if (!this._config.animated)
|
|
485
181
|
return;
|
|
486
182
|
if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
|
|
@@ -489,35 +185,7 @@ class ProgressBar extends Container {
|
|
|
489
185
|
}
|
|
490
186
|
this._displayedProgress +=
|
|
491
187
|
(this._progress - this._displayedProgress) * this._config.animationSpeed;
|
|
492
|
-
this.
|
|
493
|
-
}
|
|
494
|
-
drawTrack() {
|
|
495
|
-
const { width, height, borderRadius, trackColor } = this._config;
|
|
496
|
-
this._track.clear();
|
|
497
|
-
this._track.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
|
|
498
|
-
}
|
|
499
|
-
drawBorder() {
|
|
500
|
-
const { width, height, borderRadius, borderColor, borderWidth } = this._config;
|
|
501
|
-
this._border.clear();
|
|
502
|
-
this._border
|
|
503
|
-
.roundRect(0, 0, width, height, borderRadius)
|
|
504
|
-
.stroke({ color: borderColor, width: borderWidth });
|
|
505
|
-
}
|
|
506
|
-
drawFill(progress) {
|
|
507
|
-
const { width, height, borderRadius, fillColor, borderWidth } = this._config;
|
|
508
|
-
const innerWidth = width - borderWidth * 2;
|
|
509
|
-
const innerHeight = height - borderWidth * 2;
|
|
510
|
-
const fillWidth = Math.max(0, innerWidth * progress);
|
|
511
|
-
this._fill.clear();
|
|
512
|
-
if (fillWidth > 0) {
|
|
513
|
-
this._fill.x = borderWidth;
|
|
514
|
-
this._fill.y = borderWidth;
|
|
515
|
-
this._fill.roundRect(0, 0, fillWidth, innerHeight, borderRadius - 1).fill(fillColor);
|
|
516
|
-
// Highlight
|
|
517
|
-
this._fill
|
|
518
|
-
.roundRect(0, 0, fillWidth, innerHeight * 0.4, borderRadius - 1)
|
|
519
|
-
.fill({ color: 0xffffff, alpha: 0.15 });
|
|
520
|
-
}
|
|
188
|
+
this._bar.progress = this._displayedProgress * 100;
|
|
521
189
|
}
|
|
522
190
|
}
|
|
523
191
|
|
|
@@ -613,7 +281,10 @@ class Label extends Container {
|
|
|
613
281
|
}
|
|
614
282
|
|
|
615
283
|
/**
|
|
616
|
-
* Background panel
|
|
284
|
+
* Background panel powered by `@pixi/layout` LayoutContainer.
|
|
285
|
+
*
|
|
286
|
+
* Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
|
|
287
|
+
* Children added to `content` participate in flexbox layout automatically.
|
|
617
288
|
*
|
|
618
289
|
* @example
|
|
619
290
|
* ```ts
|
|
@@ -628,75 +299,148 @@ class Label extends Container {
|
|
|
628
299
|
* });
|
|
629
300
|
* ```
|
|
630
301
|
*/
|
|
631
|
-
class Panel extends
|
|
632
|
-
|
|
633
|
-
_content;
|
|
634
|
-
_config;
|
|
302
|
+
class Panel extends LayoutContainer {
|
|
303
|
+
_panelConfig;
|
|
635
304
|
constructor(config = {}) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
backgroundAlpha: 1,
|
|
305
|
+
const resolvedConfig = {
|
|
306
|
+
width: config.width ?? 400,
|
|
307
|
+
height: config.height ?? 300,
|
|
308
|
+
padding: config.padding ?? 16,
|
|
309
|
+
backgroundAlpha: config.backgroundAlpha ?? 1,
|
|
642
310
|
...config,
|
|
643
311
|
};
|
|
644
|
-
//
|
|
312
|
+
// If using a 9-slice texture, pass it as a custom background
|
|
313
|
+
let customBackground;
|
|
645
314
|
if (config.nineSliceTexture) {
|
|
646
315
|
const texture = typeof config.nineSliceTexture === 'string'
|
|
647
316
|
? Texture.from(config.nineSliceTexture)
|
|
648
317
|
: config.nineSliceTexture;
|
|
649
318
|
const [left, top, right, bottom] = config.nineSliceBorders ?? [10, 10, 10, 10];
|
|
650
|
-
|
|
319
|
+
const nineSlice = new NineSliceSprite({
|
|
651
320
|
texture,
|
|
652
321
|
leftWidth: left,
|
|
653
322
|
topHeight: top,
|
|
654
323
|
rightWidth: right,
|
|
655
324
|
bottomHeight: bottom,
|
|
656
325
|
});
|
|
657
|
-
|
|
658
|
-
|
|
326
|
+
nineSlice.width = resolvedConfig.width;
|
|
327
|
+
nineSlice.height = resolvedConfig.height;
|
|
328
|
+
nineSlice.alpha = resolvedConfig.backgroundAlpha;
|
|
329
|
+
customBackground = nineSlice;
|
|
330
|
+
}
|
|
331
|
+
super(customBackground ? { background: customBackground } : undefined);
|
|
332
|
+
this._panelConfig = resolvedConfig;
|
|
333
|
+
// Apply layout styles
|
|
334
|
+
const layoutStyles = {
|
|
335
|
+
width: resolvedConfig.width,
|
|
336
|
+
height: resolvedConfig.height,
|
|
337
|
+
padding: resolvedConfig.padding,
|
|
338
|
+
flexDirection: 'column',
|
|
339
|
+
};
|
|
340
|
+
// Graphics-based background via layout styles
|
|
341
|
+
if (!config.nineSliceTexture) {
|
|
342
|
+
layoutStyles.backgroundColor = config.backgroundColor ?? 0x1a1a2e;
|
|
343
|
+
layoutStyles.borderRadius = config.borderRadius ?? 0;
|
|
344
|
+
if (config.borderColor !== undefined && config.borderWidth) {
|
|
345
|
+
layoutStyles.borderColor = config.borderColor;
|
|
346
|
+
layoutStyles.borderWidth = config.borderWidth;
|
|
347
|
+
}
|
|
659
348
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
this.
|
|
349
|
+
this.layout = layoutStyles;
|
|
350
|
+
if (!config.nineSliceTexture) {
|
|
351
|
+
this.background.alpha = resolvedConfig.backgroundAlpha;
|
|
663
352
|
}
|
|
664
|
-
this._bg.alpha = this._config.backgroundAlpha;
|
|
665
|
-
this.addChild(this._bg);
|
|
666
|
-
// Content container with padding
|
|
667
|
-
this._content = new Container();
|
|
668
|
-
this._content.x = this._config.padding;
|
|
669
|
-
this._content.y = this._config.padding;
|
|
670
|
-
this.addChild(this._content);
|
|
671
353
|
}
|
|
672
|
-
/**
|
|
354
|
+
/** Access the content container (children added here participate in layout) */
|
|
673
355
|
get content() {
|
|
674
|
-
return this.
|
|
356
|
+
return this.overflowContainer;
|
|
675
357
|
}
|
|
676
358
|
/** Resize the panel */
|
|
677
359
|
setSize(width, height) {
|
|
678
|
-
this.
|
|
679
|
-
this.
|
|
680
|
-
|
|
681
|
-
this.drawGraphicsBg();
|
|
682
|
-
}
|
|
683
|
-
else {
|
|
684
|
-
this._bg.width = width;
|
|
685
|
-
this._bg.height = height;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
drawGraphicsBg() {
|
|
689
|
-
const bg = this._bg;
|
|
690
|
-
const { width, height, backgroundColor, borderRadius, borderColor, borderWidth, } = this._config;
|
|
691
|
-
bg.clear();
|
|
692
|
-
bg.roundRect(0, 0, width, height, borderRadius ?? 0).fill(backgroundColor ?? 0x1a1a2e);
|
|
693
|
-
if (borderColor !== undefined && borderWidth) {
|
|
694
|
-
bg.roundRect(0, 0, width, height, borderRadius ?? 0)
|
|
695
|
-
.stroke({ color: borderColor, width: borderWidth });
|
|
696
|
-
}
|
|
360
|
+
this._panelConfig.width = width;
|
|
361
|
+
this._panelConfig.height = height;
|
|
362
|
+
this._layout?.setStyle({ width, height });
|
|
697
363
|
}
|
|
698
364
|
}
|
|
699
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Collection of easing functions for use with Tween and Timeline.
|
|
368
|
+
*
|
|
369
|
+
* All functions take a progress value t (0..1) and return the eased value.
|
|
370
|
+
*/
|
|
371
|
+
const Easing = {
|
|
372
|
+
linear: (t) => t,
|
|
373
|
+
easeInQuad: (t) => t * t,
|
|
374
|
+
easeOutQuad: (t) => t * (2 - t),
|
|
375
|
+
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
|
376
|
+
easeInCubic: (t) => t * t * t,
|
|
377
|
+
easeOutCubic: (t) => --t * t * t + 1,
|
|
378
|
+
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
|
|
379
|
+
easeInQuart: (t) => t * t * t * t,
|
|
380
|
+
easeOutQuart: (t) => 1 - --t * t * t * t,
|
|
381
|
+
easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
|
|
382
|
+
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
383
|
+
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
|
|
384
|
+
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
|
385
|
+
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
|
|
386
|
+
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
|
387
|
+
easeInOutExpo: (t) => t === 0
|
|
388
|
+
? 0
|
|
389
|
+
: t === 1
|
|
390
|
+
? 1
|
|
391
|
+
: t < 0.5
|
|
392
|
+
? Math.pow(2, 20 * t - 10) / 2
|
|
393
|
+
: (2 - Math.pow(2, -20 * t + 10)) / 2,
|
|
394
|
+
easeInBack: (t) => {
|
|
395
|
+
const c1 = 1.70158;
|
|
396
|
+
const c3 = c1 + 1;
|
|
397
|
+
return c3 * t * t * t - c1 * t * t;
|
|
398
|
+
},
|
|
399
|
+
easeOutBack: (t) => {
|
|
400
|
+
const c1 = 1.70158;
|
|
401
|
+
const c3 = c1 + 1;
|
|
402
|
+
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
403
|
+
},
|
|
404
|
+
easeInOutBack: (t) => {
|
|
405
|
+
const c1 = 1.70158;
|
|
406
|
+
const c2 = c1 * 1.525;
|
|
407
|
+
return t < 0.5
|
|
408
|
+
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
|
409
|
+
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
|
|
410
|
+
},
|
|
411
|
+
easeOutBounce: (t) => {
|
|
412
|
+
const n1 = 7.5625;
|
|
413
|
+
const d1 = 2.75;
|
|
414
|
+
if (t < 1 / d1)
|
|
415
|
+
return n1 * t * t;
|
|
416
|
+
if (t < 2 / d1)
|
|
417
|
+
return n1 * (t -= 1.5 / d1) * t + 0.75;
|
|
418
|
+
if (t < 2.5 / d1)
|
|
419
|
+
return n1 * (t -= 2.25 / d1) * t + 0.9375;
|
|
420
|
+
return n1 * (t -= 2.625 / d1) * t + 0.984375;
|
|
421
|
+
},
|
|
422
|
+
easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
|
|
423
|
+
easeInOutBounce: (t) => t < 0.5
|
|
424
|
+
? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
|
|
425
|
+
: (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
|
|
426
|
+
easeOutElastic: (t) => {
|
|
427
|
+
const c4 = (2 * Math.PI) / 3;
|
|
428
|
+
return t === 0
|
|
429
|
+
? 0
|
|
430
|
+
: t === 1
|
|
431
|
+
? 1
|
|
432
|
+
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
|
433
|
+
},
|
|
434
|
+
easeInElastic: (t) => {
|
|
435
|
+
const c4 = (2 * Math.PI) / 3;
|
|
436
|
+
return t === 0
|
|
437
|
+
? 0
|
|
438
|
+
: t === 1
|
|
439
|
+
? 1
|
|
440
|
+
: -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
|
|
700
444
|
/**
|
|
701
445
|
* Reactive balance display component.
|
|
702
446
|
*
|
|
@@ -781,7 +525,6 @@ class BalanceDisplay extends Container {
|
|
|
781
525
|
this.updateDisplay();
|
|
782
526
|
}
|
|
783
527
|
async animateValue(from, to) {
|
|
784
|
-
// Cancel any ongoing animation
|
|
785
528
|
if (this._animating) {
|
|
786
529
|
this._animationCancelled = true;
|
|
787
530
|
}
|
|
@@ -791,7 +534,6 @@ class BalanceDisplay extends Container {
|
|
|
791
534
|
const startTime = Date.now();
|
|
792
535
|
return new Promise((resolve) => {
|
|
793
536
|
const tick = () => {
|
|
794
|
-
// If cancelled by a newer animation, stop immediately
|
|
795
537
|
if (this._animationCancelled) {
|
|
796
538
|
this._animating = false;
|
|
797
539
|
resolve();
|
|
@@ -866,66 +608,261 @@ class WinDisplay extends Container {
|
|
|
866
608
|
this.visible = false;
|
|
867
609
|
}
|
|
868
610
|
/**
|
|
869
|
-
* Show a win with countup animation.
|
|
870
|
-
*
|
|
871
|
-
* @param amount - Win amount
|
|
872
|
-
* @returns Promise that resolves when the animation completes
|
|
611
|
+
* Show a win with countup animation.
|
|
612
|
+
*
|
|
613
|
+
* @param amount - Win amount
|
|
614
|
+
* @returns Promise that resolves when the animation completes
|
|
615
|
+
*/
|
|
616
|
+
async showWin(amount) {
|
|
617
|
+
this.visible = true;
|
|
618
|
+
this._cancelCountup = false;
|
|
619
|
+
this.alpha = 1;
|
|
620
|
+
const duration = this._config.countupDuration;
|
|
621
|
+
const startTime = Date.now();
|
|
622
|
+
// Scale pop
|
|
623
|
+
this.scale.set(0.5);
|
|
624
|
+
return new Promise((resolve) => {
|
|
625
|
+
const tick = () => {
|
|
626
|
+
if (this._cancelCountup) {
|
|
627
|
+
this.displayAmount(amount);
|
|
628
|
+
resolve();
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const elapsed = Date.now() - startTime;
|
|
632
|
+
const t = Math.min(elapsed / duration, 1);
|
|
633
|
+
const eased = Easing.easeOutCubic(t);
|
|
634
|
+
// Countup
|
|
635
|
+
const current = amount * eased;
|
|
636
|
+
this.displayAmount(current);
|
|
637
|
+
// Scale animation
|
|
638
|
+
const scaleT = Math.min(elapsed / 300, 1);
|
|
639
|
+
const scaleEased = Easing.easeOutBack(scaleT);
|
|
640
|
+
const targetScale = 1;
|
|
641
|
+
this.scale.set(0.5 + (targetScale - 0.5) * scaleEased);
|
|
642
|
+
if (t < 1) {
|
|
643
|
+
requestAnimationFrame(tick);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
this.displayAmount(amount);
|
|
647
|
+
this.scale.set(1);
|
|
648
|
+
resolve();
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
requestAnimationFrame(tick);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Skip the countup animation and show the final amount immediately.
|
|
656
|
+
*/
|
|
657
|
+
skipCountup(amount) {
|
|
658
|
+
this._cancelCountup = true;
|
|
659
|
+
this.displayAmount(amount);
|
|
660
|
+
this.scale.set(1);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Hide the win display.
|
|
664
|
+
*/
|
|
665
|
+
hide() {
|
|
666
|
+
this.visible = false;
|
|
667
|
+
this._label.text = '';
|
|
668
|
+
}
|
|
669
|
+
displayAmount(amount) {
|
|
670
|
+
this._label.setCurrency(amount, this._config.currency, this._config.locale);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Lightweight tween system integrated with PixiJS Ticker.
|
|
676
|
+
* Zero external dependencies — no GSAP required.
|
|
677
|
+
*
|
|
678
|
+
* All tweens return a Promise that resolves on completion.
|
|
679
|
+
*
|
|
680
|
+
* @example
|
|
681
|
+
* ```ts
|
|
682
|
+
* // Fade in a sprite
|
|
683
|
+
* await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
|
|
684
|
+
*
|
|
685
|
+
* // Move and wait
|
|
686
|
+
* await Tween.to(sprite, { x: 500 }, 300);
|
|
687
|
+
*
|
|
688
|
+
* // From a starting value
|
|
689
|
+
* await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
|
|
690
|
+
* ```
|
|
691
|
+
*/
|
|
692
|
+
class Tween {
|
|
693
|
+
static _tweens = [];
|
|
694
|
+
static _tickerAdded = false;
|
|
695
|
+
/**
|
|
696
|
+
* Animate properties from current values to target values.
|
|
697
|
+
*
|
|
698
|
+
* @param target - Object to animate (Sprite, Container, etc.)
|
|
699
|
+
* @param props - Target property values
|
|
700
|
+
* @param duration - Duration in milliseconds
|
|
701
|
+
* @param easing - Easing function (default: easeOutQuad)
|
|
702
|
+
* @param onUpdate - Progress callback (0..1)
|
|
703
|
+
*/
|
|
704
|
+
static to(target, props, duration, easing, onUpdate) {
|
|
705
|
+
return new Promise((resolve) => {
|
|
706
|
+
// Capture starting values
|
|
707
|
+
const from = {};
|
|
708
|
+
for (const key of Object.keys(props)) {
|
|
709
|
+
from[key] = Tween.getProperty(target, key);
|
|
710
|
+
}
|
|
711
|
+
const tween = {
|
|
712
|
+
target,
|
|
713
|
+
from,
|
|
714
|
+
to: { ...props },
|
|
715
|
+
duration: Math.max(1, duration),
|
|
716
|
+
easing: easing ?? Easing.easeOutQuad,
|
|
717
|
+
elapsed: 0,
|
|
718
|
+
delay: 0,
|
|
719
|
+
resolve,
|
|
720
|
+
onUpdate,
|
|
721
|
+
};
|
|
722
|
+
Tween._tweens.push(tween);
|
|
723
|
+
Tween.ensureTicker();
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Animate properties from given values to current values.
|
|
728
|
+
*/
|
|
729
|
+
static from(target, props, duration, easing, onUpdate) {
|
|
730
|
+
// Capture current values as "to"
|
|
731
|
+
const to = {};
|
|
732
|
+
for (const key of Object.keys(props)) {
|
|
733
|
+
to[key] = Tween.getProperty(target, key);
|
|
734
|
+
Tween.setProperty(target, key, props[key]);
|
|
735
|
+
}
|
|
736
|
+
return Tween.to(target, to, duration, easing, onUpdate);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Animate from one set of values to another.
|
|
740
|
+
*/
|
|
741
|
+
static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
|
|
742
|
+
// Set starting values
|
|
743
|
+
for (const key of Object.keys(fromProps)) {
|
|
744
|
+
Tween.setProperty(target, key, fromProps[key]);
|
|
745
|
+
}
|
|
746
|
+
return Tween.to(target, toProps, duration, easing, onUpdate);
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Wait for a given duration (useful in timelines).
|
|
750
|
+
* Uses PixiJS Ticker for consistent timing with other tweens.
|
|
873
751
|
*/
|
|
874
|
-
|
|
875
|
-
this.visible = true;
|
|
876
|
-
this._cancelCountup = false;
|
|
877
|
-
this.alpha = 1;
|
|
878
|
-
const duration = this._config.countupDuration;
|
|
879
|
-
const startTime = Date.now();
|
|
880
|
-
// Scale pop
|
|
881
|
-
this.scale.set(0.5);
|
|
752
|
+
static delay(ms) {
|
|
882
753
|
return new Promise((resolve) => {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
const elapsed = Date.now() - startTime;
|
|
890
|
-
const t = Math.min(elapsed / duration, 1);
|
|
891
|
-
const eased = Easing.easeOutCubic(t);
|
|
892
|
-
// Countup
|
|
893
|
-
const current = amount * eased;
|
|
894
|
-
this.displayAmount(current);
|
|
895
|
-
// Scale animation
|
|
896
|
-
const scaleT = Math.min(elapsed / 300, 1);
|
|
897
|
-
const scaleEased = Easing.easeOutBack(scaleT);
|
|
898
|
-
const targetScale = 1;
|
|
899
|
-
this.scale.set(0.5 + (targetScale - 0.5) * scaleEased);
|
|
900
|
-
if (t < 1) {
|
|
901
|
-
requestAnimationFrame(tick);
|
|
902
|
-
}
|
|
903
|
-
else {
|
|
904
|
-
this.displayAmount(amount);
|
|
905
|
-
this.scale.set(1);
|
|
754
|
+
let elapsed = 0;
|
|
755
|
+
const onTick = (ticker) => {
|
|
756
|
+
elapsed += ticker.deltaMS;
|
|
757
|
+
if (elapsed >= ms) {
|
|
758
|
+
Ticker.shared.remove(onTick);
|
|
906
759
|
resolve();
|
|
907
760
|
}
|
|
908
761
|
};
|
|
909
|
-
|
|
762
|
+
Ticker.shared.add(onTick);
|
|
910
763
|
});
|
|
911
764
|
}
|
|
912
765
|
/**
|
|
913
|
-
*
|
|
766
|
+
* Kill all tweens on a target.
|
|
914
767
|
*/
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
768
|
+
static killTweensOf(target) {
|
|
769
|
+
Tween._tweens = Tween._tweens.filter((tw) => {
|
|
770
|
+
if (tw.target === target) {
|
|
771
|
+
tw.resolve();
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
return true;
|
|
775
|
+
});
|
|
919
776
|
}
|
|
920
777
|
/**
|
|
921
|
-
*
|
|
778
|
+
* Kill all active tweens.
|
|
922
779
|
*/
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
780
|
+
static killAll() {
|
|
781
|
+
for (const tw of Tween._tweens) {
|
|
782
|
+
tw.resolve();
|
|
783
|
+
}
|
|
784
|
+
Tween._tweens.length = 0;
|
|
926
785
|
}
|
|
927
|
-
|
|
928
|
-
|
|
786
|
+
/** Number of active tweens */
|
|
787
|
+
static get activeTweens() {
|
|
788
|
+
return Tween._tweens.length;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Reset the tween system — kill all tweens and remove the ticker.
|
|
792
|
+
* Useful for cleanup between game instances, tests, or hot-reload.
|
|
793
|
+
*/
|
|
794
|
+
static reset() {
|
|
795
|
+
for (const tw of Tween._tweens) {
|
|
796
|
+
tw.resolve();
|
|
797
|
+
}
|
|
798
|
+
Tween._tweens.length = 0;
|
|
799
|
+
if (Tween._tickerAdded) {
|
|
800
|
+
Ticker.shared.remove(Tween.tick);
|
|
801
|
+
Tween._tickerAdded = false;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// ─── Internal ──────────────────────────────────────────
|
|
805
|
+
static ensureTicker() {
|
|
806
|
+
if (Tween._tickerAdded)
|
|
807
|
+
return;
|
|
808
|
+
Tween._tickerAdded = true;
|
|
809
|
+
Ticker.shared.add(Tween.tick);
|
|
810
|
+
}
|
|
811
|
+
static tick = (ticker) => {
|
|
812
|
+
const dt = ticker.deltaMS;
|
|
813
|
+
const completed = [];
|
|
814
|
+
for (const tw of Tween._tweens) {
|
|
815
|
+
tw.elapsed += dt;
|
|
816
|
+
if (tw.elapsed < tw.delay)
|
|
817
|
+
continue;
|
|
818
|
+
const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
|
|
819
|
+
const t = tw.easing(raw);
|
|
820
|
+
// Interpolate each property
|
|
821
|
+
for (const key of Object.keys(tw.to)) {
|
|
822
|
+
const start = tw.from[key];
|
|
823
|
+
const end = tw.to[key];
|
|
824
|
+
const value = start + (end - start) * t;
|
|
825
|
+
Tween.setProperty(tw.target, key, value);
|
|
826
|
+
}
|
|
827
|
+
tw.onUpdate?.(raw);
|
|
828
|
+
if (raw >= 1) {
|
|
829
|
+
completed.push(tw);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// Remove completed tweens
|
|
833
|
+
for (const tw of completed) {
|
|
834
|
+
const idx = Tween._tweens.indexOf(tw);
|
|
835
|
+
if (idx !== -1)
|
|
836
|
+
Tween._tweens.splice(idx, 1);
|
|
837
|
+
tw.resolve();
|
|
838
|
+
}
|
|
839
|
+
// Remove ticker when no active tweens
|
|
840
|
+
if (Tween._tweens.length === 0 && Tween._tickerAdded) {
|
|
841
|
+
Ticker.shared.remove(Tween.tick);
|
|
842
|
+
Tween._tickerAdded = false;
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
/**
|
|
846
|
+
* Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
|
|
847
|
+
*/
|
|
848
|
+
static getProperty(target, key) {
|
|
849
|
+
const parts = key.split('.');
|
|
850
|
+
let obj = target;
|
|
851
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
852
|
+
obj = obj[parts[i]];
|
|
853
|
+
}
|
|
854
|
+
return obj[parts[parts.length - 1]] ?? 0;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Set a potentially nested property.
|
|
858
|
+
*/
|
|
859
|
+
static setProperty(target, key, value) {
|
|
860
|
+
const parts = key.split('.');
|
|
861
|
+
let obj = target;
|
|
862
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
863
|
+
obj = obj[parts[i]];
|
|
864
|
+
}
|
|
865
|
+
obj[parts[parts.length - 1]] = value;
|
|
929
866
|
}
|
|
930
867
|
}
|
|
931
868
|
|
|
@@ -933,6 +870,8 @@ class WinDisplay extends Container {
|
|
|
933
870
|
* Modal overlay component.
|
|
934
871
|
* Shows content on top of a dark overlay with enter/exit animations.
|
|
935
872
|
*
|
|
873
|
+
* The content container uses `@pixi/layout` for automatic centering.
|
|
874
|
+
*
|
|
936
875
|
* @example
|
|
937
876
|
* ```ts
|
|
938
877
|
* const modal = new Modal({ closeOnOverlay: true });
|
|
@@ -951,11 +890,10 @@ class Modal extends Container {
|
|
|
951
890
|
constructor(config = {}) {
|
|
952
891
|
super();
|
|
953
892
|
this._config = {
|
|
954
|
-
overlayColor: 0x000000,
|
|
955
|
-
overlayAlpha: 0.7,
|
|
956
|
-
closeOnOverlay: true,
|
|
957
|
-
animationDuration: 300,
|
|
958
|
-
...config,
|
|
893
|
+
overlayColor: config.overlayColor ?? 0x000000,
|
|
894
|
+
overlayAlpha: config.overlayAlpha ?? 0.7,
|
|
895
|
+
closeOnOverlay: config.closeOnOverlay ?? true,
|
|
896
|
+
animationDuration: config.animationDuration ?? 300,
|
|
959
897
|
};
|
|
960
898
|
// Overlay
|
|
961
899
|
this._overlay = new Graphics();
|
|
@@ -1038,9 +976,8 @@ class Toast extends Container {
|
|
|
1038
976
|
constructor(config = {}) {
|
|
1039
977
|
super();
|
|
1040
978
|
this._config = {
|
|
1041
|
-
duration: 3000,
|
|
1042
|
-
bottomOffset: 60,
|
|
1043
|
-
...config,
|
|
979
|
+
duration: config.duration ?? 3000,
|
|
980
|
+
bottomOffset: config.bottomOffset ?? 60,
|
|
1044
981
|
};
|
|
1045
982
|
this._bg = new Graphics();
|
|
1046
983
|
this.addChild(this._bg);
|
|
@@ -1060,7 +997,6 @@ class Toast extends Container {
|
|
|
1060
997
|
* Show a toast message.
|
|
1061
998
|
*/
|
|
1062
999
|
async show(message, type = 'info', viewWidth, viewHeight) {
|
|
1063
|
-
// Clear previous dismiss
|
|
1064
1000
|
if (this._dismissTimeout) {
|
|
1065
1001
|
clearTimeout(this._dismissTimeout);
|
|
1066
1002
|
}
|
|
@@ -1069,10 +1005,10 @@ class Toast extends Container {
|
|
|
1069
1005
|
const width = Math.max(200, this._text.width + padding * 2);
|
|
1070
1006
|
const height = 44;
|
|
1071
1007
|
const radius = 8;
|
|
1008
|
+
// Draw the background
|
|
1072
1009
|
this._bg.clear();
|
|
1073
|
-
this._bg.roundRect(-width / 2, -height / 2, width, height, radius)
|
|
1074
|
-
this._bg.
|
|
1075
|
-
.fill({ color: 0x000000, alpha: 0.2 });
|
|
1010
|
+
this._bg.roundRect(-width / 2, -height / 2, width, height, radius);
|
|
1011
|
+
this._bg.fill(TOAST_COLORS[type]);
|
|
1076
1012
|
// Position
|
|
1077
1013
|
if (viewWidth && viewHeight) {
|
|
1078
1014
|
this.x = viewWidth / 2;
|
|
@@ -1081,9 +1017,7 @@ class Toast extends Container {
|
|
|
1081
1017
|
this.visible = true;
|
|
1082
1018
|
this.alpha = 0;
|
|
1083
1019
|
this.y += 20;
|
|
1084
|
-
// Animate in
|
|
1085
1020
|
await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
|
|
1086
|
-
// Auto-dismiss
|
|
1087
1021
|
if (this._config.duration > 0) {
|
|
1088
1022
|
this._dismissTimeout = setTimeout(() => {
|
|
1089
1023
|
this.dismiss();
|
|
@@ -1105,8 +1039,48 @@ class Toast extends Container {
|
|
|
1105
1039
|
}
|
|
1106
1040
|
}
|
|
1107
1041
|
|
|
1042
|
+
// ─── Helpers ─────────────────────────────────────────────
|
|
1043
|
+
const ALIGNMENT_MAP = {
|
|
1044
|
+
start: 'flex-start',
|
|
1045
|
+
center: 'center',
|
|
1046
|
+
end: 'flex-end',
|
|
1047
|
+
stretch: 'stretch',
|
|
1048
|
+
};
|
|
1049
|
+
function normalizePadding(padding) {
|
|
1050
|
+
if (typeof padding === 'number')
|
|
1051
|
+
return [padding, padding, padding, padding];
|
|
1052
|
+
return padding;
|
|
1053
|
+
}
|
|
1054
|
+
function directionToFlexStyles(direction, maxWidth) {
|
|
1055
|
+
switch (direction) {
|
|
1056
|
+
case 'horizontal':
|
|
1057
|
+
return { flexDirection: 'row', flexWrap: 'nowrap' };
|
|
1058
|
+
case 'vertical':
|
|
1059
|
+
return { flexDirection: 'column', flexWrap: 'nowrap' };
|
|
1060
|
+
case 'grid':
|
|
1061
|
+
return { flexDirection: 'row', flexWrap: 'wrap' };
|
|
1062
|
+
case 'wrap':
|
|
1063
|
+
return {
|
|
1064
|
+
flexDirection: 'row',
|
|
1065
|
+
flexWrap: 'wrap',
|
|
1066
|
+
...(maxWidth < Infinity ? { maxWidth } : {}),
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
function buildLayoutStyles(config) {
|
|
1071
|
+
const [pt, pr, pb, pl] = config.padding;
|
|
1072
|
+
return {
|
|
1073
|
+
...directionToFlexStyles(config.direction, config.maxWidth),
|
|
1074
|
+
gap: config.gap,
|
|
1075
|
+
alignItems: ALIGNMENT_MAP[config.alignment],
|
|
1076
|
+
paddingTop: pt,
|
|
1077
|
+
paddingRight: pr,
|
|
1078
|
+
paddingBottom: pb,
|
|
1079
|
+
paddingLeft: pl,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1108
1082
|
/**
|
|
1109
|
-
* Responsive layout container
|
|
1083
|
+
* Responsive layout container powered by `@pixi/layout` (Yoga flexbox engine).
|
|
1110
1084
|
*
|
|
1111
1085
|
* Supports horizontal, vertical, grid, and wrap layout modes with
|
|
1112
1086
|
* alignment, padding, gap, and viewport-anchor positioning.
|
|
@@ -1129,47 +1103,44 @@ class Toast extends Container {
|
|
|
1129
1103
|
* toolbar.addItem(betLabel);
|
|
1130
1104
|
* scene.container.addChild(toolbar);
|
|
1131
1105
|
*
|
|
1132
|
-
* // On resize, update layout position relative to viewport
|
|
1133
1106
|
* toolbar.updateViewport(width, height);
|
|
1134
1107
|
* ```
|
|
1135
1108
|
*/
|
|
1136
1109
|
class Layout extends Container {
|
|
1137
|
-
|
|
1110
|
+
_layoutConfig;
|
|
1138
1111
|
_padding;
|
|
1139
1112
|
_anchor;
|
|
1140
1113
|
_maxWidth;
|
|
1141
1114
|
_breakpoints;
|
|
1142
|
-
_content;
|
|
1143
1115
|
_items = [];
|
|
1144
1116
|
_viewportWidth = 0;
|
|
1145
1117
|
_viewportHeight = 0;
|
|
1146
1118
|
constructor(config = {}) {
|
|
1147
1119
|
super();
|
|
1148
|
-
this.
|
|
1120
|
+
this._layoutConfig = {
|
|
1149
1121
|
direction: config.direction ?? 'vertical',
|
|
1150
1122
|
gap: config.gap ?? 0,
|
|
1151
1123
|
alignment: config.alignment ?? 'start',
|
|
1152
1124
|
autoLayout: config.autoLayout ?? true,
|
|
1153
1125
|
columns: config.columns ?? 2,
|
|
1154
1126
|
};
|
|
1155
|
-
this._padding =
|
|
1127
|
+
this._padding = normalizePadding(config.padding ?? 0);
|
|
1156
1128
|
this._anchor = config.anchor ?? 'top-left';
|
|
1157
1129
|
this._maxWidth = config.maxWidth ?? Infinity;
|
|
1158
|
-
// Sort breakpoints by width ascending for correct resolution
|
|
1159
1130
|
this._breakpoints = config.breakpoints
|
|
1160
1131
|
? Object.entries(config.breakpoints)
|
|
1161
1132
|
.map(([w, cfg]) => [Number(w), cfg])
|
|
1162
1133
|
.sort((a, b) => a[0] - b[0])
|
|
1163
1134
|
: [];
|
|
1164
|
-
this.
|
|
1165
|
-
this.addChild(this._content);
|
|
1135
|
+
this.applyLayoutStyles();
|
|
1166
1136
|
}
|
|
1167
1137
|
/** Add an item to the layout */
|
|
1168
1138
|
addItem(child) {
|
|
1169
1139
|
this._items.push(child);
|
|
1170
|
-
this.
|
|
1171
|
-
if (this.
|
|
1172
|
-
this.
|
|
1140
|
+
this.addChild(child);
|
|
1141
|
+
if (this._layoutConfig.direction === 'grid') {
|
|
1142
|
+
this.applyGridChildWidth(child);
|
|
1143
|
+
}
|
|
1173
1144
|
return this;
|
|
1174
1145
|
}
|
|
1175
1146
|
/** Remove an item from the layout */
|
|
@@ -1177,20 +1148,16 @@ class Layout extends Container {
|
|
|
1177
1148
|
const idx = this._items.indexOf(child);
|
|
1178
1149
|
if (idx !== -1) {
|
|
1179
1150
|
this._items.splice(idx, 1);
|
|
1180
|
-
this.
|
|
1181
|
-
if (this._config.autoLayout)
|
|
1182
|
-
this.layout();
|
|
1151
|
+
this.removeChild(child);
|
|
1183
1152
|
}
|
|
1184
1153
|
return this;
|
|
1185
1154
|
}
|
|
1186
1155
|
/** Remove all items */
|
|
1187
1156
|
clearItems() {
|
|
1188
1157
|
for (const item of this._items) {
|
|
1189
|
-
this.
|
|
1158
|
+
this.removeChild(item);
|
|
1190
1159
|
}
|
|
1191
1160
|
this._items.length = 0;
|
|
1192
|
-
if (this._config.autoLayout)
|
|
1193
|
-
this.layout();
|
|
1194
1161
|
return this;
|
|
1195
1162
|
}
|
|
1196
1163
|
/** Get all layout items */
|
|
@@ -1204,129 +1171,55 @@ class Layout extends Container {
|
|
|
1204
1171
|
updateViewport(width, height) {
|
|
1205
1172
|
this._viewportWidth = width;
|
|
1206
1173
|
this._viewportHeight = height;
|
|
1207
|
-
this.
|
|
1174
|
+
this.applyLayoutStyles();
|
|
1175
|
+
this.applyAnchor();
|
|
1208
1176
|
}
|
|
1209
|
-
|
|
1210
|
-
* Recalculate layout positions of all children.
|
|
1211
|
-
*/
|
|
1212
|
-
layout() {
|
|
1213
|
-
if (this._items.length === 0)
|
|
1214
|
-
return;
|
|
1215
|
-
// Resolve effective config (apply breakpoint overrides)
|
|
1177
|
+
applyLayoutStyles() {
|
|
1216
1178
|
const effective = this.resolveConfig();
|
|
1217
|
-
const
|
|
1218
|
-
const
|
|
1219
|
-
const alignment = effective.alignment ?? this.
|
|
1220
|
-
|
|
1179
|
+
const direction = effective.direction ?? this._layoutConfig.direction;
|
|
1180
|
+
const gap = effective.gap ?? this._layoutConfig.gap;
|
|
1181
|
+
const alignment = effective.alignment ?? this._layoutConfig.alignment;
|
|
1182
|
+
effective.columns ?? this._layoutConfig.columns;
|
|
1221
1183
|
const padding = effective.padding !== undefined
|
|
1222
|
-
?
|
|
1184
|
+
? normalizePadding(effective.padding)
|
|
1223
1185
|
: this._padding;
|
|
1224
1186
|
const maxWidth = effective.maxWidth ?? this._maxWidth;
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
case 'vertical':
|
|
1231
|
-
this.layoutLinear('y', 'x', gap, alignment, pt, pl);
|
|
1232
|
-
break;
|
|
1233
|
-
case 'grid':
|
|
1234
|
-
this.layoutGrid(columns, gap, alignment, pl, pt);
|
|
1235
|
-
break;
|
|
1236
|
-
case 'wrap':
|
|
1237
|
-
this.layoutWrap(maxWidth - pl - pr, gap, alignment, pl, pt);
|
|
1238
|
-
break;
|
|
1239
|
-
}
|
|
1240
|
-
// Apply anchor positioning relative to viewport
|
|
1241
|
-
this.applyAnchor(effective.anchor ?? this._anchor);
|
|
1242
|
-
}
|
|
1243
|
-
// ─── Private layout helpers ────────────────────────────
|
|
1244
|
-
layoutLinear(mainAxis, crossAxis, gap, alignment, mainOffset, crossOffset) {
|
|
1245
|
-
let pos = mainOffset;
|
|
1246
|
-
const sizes = this._items.map(item => this.getItemSize(item));
|
|
1247
|
-
const maxCross = Math.max(...sizes.map(s => (crossAxis === 'x' ? s.width : s.height)));
|
|
1248
|
-
for (let i = 0; i < this._items.length; i++) {
|
|
1249
|
-
const item = this._items[i];
|
|
1250
|
-
const size = sizes[i];
|
|
1251
|
-
item[mainAxis] = pos;
|
|
1252
|
-
// Cross-axis alignment
|
|
1253
|
-
const itemCross = crossAxis === 'x' ? size.width : size.height;
|
|
1254
|
-
switch (alignment) {
|
|
1255
|
-
case 'start':
|
|
1256
|
-
item[crossAxis] = crossOffset;
|
|
1257
|
-
break;
|
|
1258
|
-
case 'center':
|
|
1259
|
-
item[crossAxis] = crossOffset + (maxCross - itemCross) / 2;
|
|
1260
|
-
break;
|
|
1261
|
-
case 'end':
|
|
1262
|
-
item[crossAxis] = crossOffset + maxCross - itemCross;
|
|
1263
|
-
break;
|
|
1264
|
-
case 'stretch':
|
|
1265
|
-
item[crossAxis] = crossOffset;
|
|
1266
|
-
// Note: stretch doesn't resize children — that's up to the item
|
|
1267
|
-
break;
|
|
1187
|
+
const styles = buildLayoutStyles({ direction, gap, alignment, padding, maxWidth });
|
|
1188
|
+
this.layout = styles;
|
|
1189
|
+
if (direction === 'grid') {
|
|
1190
|
+
for (const item of this._items) {
|
|
1191
|
+
this.applyGridChildWidth(item);
|
|
1268
1192
|
}
|
|
1269
|
-
const mainSize = mainAxis === 'x' ? size.width : size.height;
|
|
1270
|
-
pos += mainSize + gap;
|
|
1271
1193
|
}
|
|
1272
1194
|
}
|
|
1273
|
-
|
|
1274
|
-
const
|
|
1275
|
-
const
|
|
1276
|
-
const
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
switch (alignment) {
|
|
1286
|
-
case 'center':
|
|
1287
|
-
item.x = offsetX + col * cellW + (maxItemWidth - size.width) / 2;
|
|
1288
|
-
break;
|
|
1289
|
-
case 'end':
|
|
1290
|
-
item.x = offsetX + col * cellW + maxItemWidth - size.width;
|
|
1291
|
-
break;
|
|
1292
|
-
default:
|
|
1293
|
-
item.x = offsetX + col * cellW;
|
|
1294
|
-
}
|
|
1295
|
-
item.y = offsetY + row * cellH;
|
|
1195
|
+
applyGridChildWidth(child) {
|
|
1196
|
+
const effective = this.resolveConfig();
|
|
1197
|
+
const columns = effective.columns ?? this._layoutConfig.columns;
|
|
1198
|
+
const gap = effective.gap ?? this._layoutConfig.gap;
|
|
1199
|
+
// Account for gaps between columns: total gap space = gap * (columns - 1)
|
|
1200
|
+
// Each column gets: (100% - total_gap) / columns
|
|
1201
|
+
// We use flexBasis + flexGrow to let Yoga handle the math when gap > 0
|
|
1202
|
+
const styles = gap > 0
|
|
1203
|
+
? { flexBasis: 0, flexGrow: 1, flexShrink: 1, maxWidth: `${(100 / columns).toFixed(2)}%` }
|
|
1204
|
+
: { width: `${(100 / columns).toFixed(2)}%` };
|
|
1205
|
+
if (child._layout) {
|
|
1206
|
+
child._layout.setStyle(styles);
|
|
1296
1207
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
let x = offsetX;
|
|
1300
|
-
let y = offsetY;
|
|
1301
|
-
let rowHeight = 0;
|
|
1302
|
-
const sizes = this._items.map(item => this.getItemSize(item));
|
|
1303
|
-
for (let i = 0; i < this._items.length; i++) {
|
|
1304
|
-
const item = this._items[i];
|
|
1305
|
-
const size = sizes[i];
|
|
1306
|
-
// Check if item fits in current row
|
|
1307
|
-
if (x + size.width > maxWidth + offsetX && x > offsetX) {
|
|
1308
|
-
// Wrap to next row
|
|
1309
|
-
x = offsetX;
|
|
1310
|
-
y += rowHeight + gap;
|
|
1311
|
-
rowHeight = 0;
|
|
1312
|
-
}
|
|
1313
|
-
item.x = x;
|
|
1314
|
-
item.y = y;
|
|
1315
|
-
x += size.width + gap;
|
|
1316
|
-
rowHeight = Math.max(rowHeight, size.height);
|
|
1208
|
+
else {
|
|
1209
|
+
child.layout = styles;
|
|
1317
1210
|
}
|
|
1318
1211
|
}
|
|
1319
|
-
applyAnchor(
|
|
1212
|
+
applyAnchor() {
|
|
1213
|
+
const anchor = this.resolveConfig().anchor ?? this._anchor;
|
|
1320
1214
|
if (this._viewportWidth === 0 || this._viewportHeight === 0)
|
|
1321
1215
|
return;
|
|
1322
|
-
const bounds = this.
|
|
1323
|
-
const contentW = bounds.width;
|
|
1324
|
-
const contentH = bounds.height;
|
|
1216
|
+
const bounds = this.getLocalBounds();
|
|
1217
|
+
const contentW = bounds.width * this.scale.x;
|
|
1218
|
+
const contentH = bounds.height * this.scale.y;
|
|
1325
1219
|
const vw = this._viewportWidth;
|
|
1326
1220
|
const vh = this._viewportHeight;
|
|
1327
1221
|
let anchorX = 0;
|
|
1328
1222
|
let anchorY = 0;
|
|
1329
|
-
// Horizontal
|
|
1330
1223
|
if (anchor.includes('left')) {
|
|
1331
1224
|
anchorX = 0;
|
|
1332
1225
|
}
|
|
@@ -1334,10 +1227,8 @@ class Layout extends Container {
|
|
|
1334
1227
|
anchorX = vw - contentW;
|
|
1335
1228
|
}
|
|
1336
1229
|
else {
|
|
1337
|
-
// center
|
|
1338
1230
|
anchorX = (vw - contentW) / 2;
|
|
1339
1231
|
}
|
|
1340
|
-
// Vertical
|
|
1341
1232
|
if (anchor.startsWith('top')) {
|
|
1342
1233
|
anchorY = 0;
|
|
1343
1234
|
}
|
|
@@ -1345,44 +1236,34 @@ class Layout extends Container {
|
|
|
1345
1236
|
anchorY = vh - contentH;
|
|
1346
1237
|
}
|
|
1347
1238
|
else {
|
|
1348
|
-
// center
|
|
1349
1239
|
anchorY = (vh - contentH) / 2;
|
|
1350
1240
|
}
|
|
1351
|
-
|
|
1352
|
-
this.
|
|
1353
|
-
this.y = anchorY - bounds.y;
|
|
1241
|
+
this.x = anchorX - bounds.x * this.scale.x;
|
|
1242
|
+
this.y = anchorY - bounds.y * this.scale.y;
|
|
1354
1243
|
}
|
|
1355
1244
|
resolveConfig() {
|
|
1356
1245
|
if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
|
|
1357
1246
|
return {};
|
|
1358
1247
|
}
|
|
1359
|
-
// Find the largest breakpoint that's ≤ current viewport width
|
|
1360
|
-
let resolved = {};
|
|
1361
1248
|
for (const [maxWidth, overrides] of this._breakpoints) {
|
|
1362
1249
|
if (this._viewportWidth <= maxWidth) {
|
|
1363
|
-
|
|
1364
|
-
break;
|
|
1250
|
+
return overrides;
|
|
1365
1251
|
}
|
|
1366
1252
|
}
|
|
1367
|
-
return
|
|
1368
|
-
}
|
|
1369
|
-
getItemSize(item) {
|
|
1370
|
-
const bounds = item.getBounds();
|
|
1371
|
-
return { width: bounds.width, height: bounds.height };
|
|
1372
|
-
}
|
|
1373
|
-
static normalizePadding(padding) {
|
|
1374
|
-
if (typeof padding === 'number') {
|
|
1375
|
-
return [padding, padding, padding, padding];
|
|
1376
|
-
}
|
|
1377
|
-
return padding;
|
|
1253
|
+
return {};
|
|
1378
1254
|
}
|
|
1379
1255
|
}
|
|
1380
1256
|
|
|
1257
|
+
const DIRECTION_MAP = {
|
|
1258
|
+
vertical: 'vertical',
|
|
1259
|
+
horizontal: 'horizontal',
|
|
1260
|
+
both: 'bidirectional',
|
|
1261
|
+
};
|
|
1381
1262
|
/**
|
|
1382
|
-
* Scrollable container
|
|
1263
|
+
* Scrollable container powered by `@pixi/ui` ScrollBox.
|
|
1383
1264
|
*
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1265
|
+
* Provides touch/drag scrolling, mouse wheel support, inertia, and
|
|
1266
|
+
* dynamic rendering optimization for off-screen items.
|
|
1386
1267
|
*
|
|
1387
1268
|
* @example
|
|
1388
1269
|
* ```ts
|
|
@@ -1390,435 +1271,63 @@ class Layout extends Container {
|
|
|
1390
1271
|
* width: 600,
|
|
1391
1272
|
* height: 400,
|
|
1392
1273
|
* direction: 'vertical',
|
|
1393
|
-
*
|
|
1394
|
-
* elasticity: 0.3,
|
|
1274
|
+
* elementsMargin: 8,
|
|
1395
1275
|
* });
|
|
1396
1276
|
*
|
|
1397
|
-
* // Add content taller than 400px
|
|
1398
|
-
* const list = new Container();
|
|
1399
1277
|
* for (let i = 0; i < 50; i++) {
|
|
1400
|
-
*
|
|
1401
|
-
* row.y = i * 40;
|
|
1402
|
-
* list.addChild(row);
|
|
1278
|
+
* scroll.addItem(createRow(i));
|
|
1403
1279
|
* }
|
|
1404
|
-
* scroll.setContent(list);
|
|
1405
1280
|
*
|
|
1406
1281
|
* scene.container.addChild(scroll);
|
|
1407
1282
|
* ```
|
|
1408
1283
|
*/
|
|
1409
|
-
class ScrollContainer extends
|
|
1410
|
-
|
|
1411
|
-
_viewport;
|
|
1412
|
-
_content = null;
|
|
1413
|
-
_mask;
|
|
1414
|
-
_bg;
|
|
1415
|
-
_scrollbarV = null;
|
|
1416
|
-
_scrollbarH = null;
|
|
1417
|
-
_scrollbarFadeTimeout = null;
|
|
1418
|
-
// Scroll state
|
|
1419
|
-
_scrollX = 0;
|
|
1420
|
-
_scrollY = 0;
|
|
1421
|
-
_velocityX = 0;
|
|
1422
|
-
_velocityY = 0;
|
|
1423
|
-
_isDragging = false;
|
|
1424
|
-
_dragStart = { x: 0, y: 0 };
|
|
1425
|
-
_scrollStart = { x: 0, y: 0 };
|
|
1426
|
-
_lastDragPos = { x: 0, y: 0 };
|
|
1427
|
-
_lastDragTime = 0;
|
|
1428
|
-
_isAnimating = false;
|
|
1429
|
-
_animationFrame = null;
|
|
1284
|
+
class ScrollContainer extends ScrollBox {
|
|
1285
|
+
_scrollConfig;
|
|
1430
1286
|
constructor(config) {
|
|
1431
|
-
|
|
1432
|
-
this._config = {
|
|
1287
|
+
const options = {
|
|
1433
1288
|
width: config.width,
|
|
1434
1289
|
height: config.height,
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
snapSize: config.snapSize ?? 0,
|
|
1443
|
-
borderRadius: config.borderRadius ?? 0,
|
|
1290
|
+
type: DIRECTION_MAP[config.direction ?? 'vertical'],
|
|
1291
|
+
radius: config.borderRadius ?? 0,
|
|
1292
|
+
elementsMargin: config.elementsMargin ?? 0,
|
|
1293
|
+
padding: config.padding ?? 0,
|
|
1294
|
+
disableDynamicRendering: config.disableDynamicRendering ?? false,
|
|
1295
|
+
disableEasing: config.disableEasing ?? false,
|
|
1296
|
+
globalScroll: config.globalScroll ?? true,
|
|
1444
1297
|
};
|
|
1445
|
-
// Background
|
|
1446
|
-
this._bg = new Graphics();
|
|
1447
1298
|
if (config.backgroundColor !== undefined) {
|
|
1448
|
-
|
|
1449
|
-
.fill({ color: config.backgroundColor, alpha: config.backgroundAlpha ?? 1 });
|
|
1299
|
+
options.background = config.backgroundColor;
|
|
1450
1300
|
}
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
this._viewport = new Container();
|
|
1454
|
-
this.addChild(this._viewport);
|
|
1455
|
-
// Mask
|
|
1456
|
-
this._mask = new Graphics();
|
|
1457
|
-
this._mask.roundRect(0, 0, config.width, config.height, this._config.borderRadius)
|
|
1458
|
-
.fill(0xffffff);
|
|
1459
|
-
this.addChild(this._mask);
|
|
1460
|
-
this._viewport.mask = this._mask;
|
|
1461
|
-
// Scrollbars
|
|
1462
|
-
if (this._config.showScrollbar) {
|
|
1463
|
-
if (this._config.direction !== 'horizontal') {
|
|
1464
|
-
this._scrollbarV = new Graphics();
|
|
1465
|
-
this._scrollbarV.alpha = 0;
|
|
1466
|
-
this.addChild(this._scrollbarV);
|
|
1467
|
-
}
|
|
1468
|
-
if (this._config.direction !== 'vertical') {
|
|
1469
|
-
this._scrollbarH = new Graphics();
|
|
1470
|
-
this._scrollbarH.alpha = 0;
|
|
1471
|
-
this.addChild(this._scrollbarH);
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
// Interaction
|
|
1475
|
-
this.eventMode = 'static';
|
|
1476
|
-
this.cursor = 'grab';
|
|
1477
|
-
this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
|
|
1478
|
-
this.on('pointerdown', this.onPointerDown);
|
|
1479
|
-
this.on('pointermove', this.onPointerMove);
|
|
1480
|
-
this.on('pointerup', this.onPointerUp);
|
|
1481
|
-
this.on('pointerupoutside', this.onPointerUp);
|
|
1482
|
-
this.on('wheel', this.onWheel);
|
|
1301
|
+
super(options);
|
|
1302
|
+
this._scrollConfig = config;
|
|
1483
1303
|
}
|
|
1484
1304
|
/** Set scrollable content. Replaces any existing content. */
|
|
1485
1305
|
setContent(content) {
|
|
1486
|
-
|
|
1487
|
-
|
|
1306
|
+
// Remove existing items
|
|
1307
|
+
const existing = this.items;
|
|
1308
|
+
if (existing.length > 0) {
|
|
1309
|
+
for (let i = existing.length - 1; i >= 0; i--) {
|
|
1310
|
+
this.removeItem(i);
|
|
1311
|
+
}
|
|
1488
1312
|
}
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
this.applyScroll();
|
|
1494
|
-
}
|
|
1495
|
-
/** Get the content container */
|
|
1496
|
-
get content() {
|
|
1497
|
-
return this._content;
|
|
1498
|
-
}
|
|
1499
|
-
/** Scroll to a specific position (in content coordinates) */
|
|
1500
|
-
scrollTo(x, y, animate = true) {
|
|
1501
|
-
if (!animate) {
|
|
1502
|
-
this._scrollX = x;
|
|
1503
|
-
this._scrollY = y;
|
|
1504
|
-
this.clampScroll();
|
|
1505
|
-
this.applyScroll();
|
|
1506
|
-
return;
|
|
1313
|
+
// Add all children from the content container
|
|
1314
|
+
const children = [...content.children];
|
|
1315
|
+
if (children.length > 0) {
|
|
1316
|
+
this.addItems(children);
|
|
1507
1317
|
}
|
|
1508
|
-
|
|
1318
|
+
}
|
|
1319
|
+
/** Add a single item */
|
|
1320
|
+
addItem(...items) {
|
|
1321
|
+
this.addItems(items);
|
|
1322
|
+
return items[0];
|
|
1509
1323
|
}
|
|
1510
1324
|
/** Scroll to make a specific item/child visible */
|
|
1511
1325
|
scrollToItem(index) {
|
|
1512
|
-
|
|
1513
|
-
const pos = index * this._config.snapSize;
|
|
1514
|
-
if (this._config.direction === 'horizontal') {
|
|
1515
|
-
this.scrollTo(pos, this._scrollY);
|
|
1516
|
-
}
|
|
1517
|
-
else {
|
|
1518
|
-
this.scrollTo(this._scrollX, pos);
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1326
|
+
this.scrollTo(index);
|
|
1521
1327
|
}
|
|
1522
1328
|
/** Current scroll position */
|
|
1523
1329
|
get scrollPosition() {
|
|
1524
|
-
return { x: this.
|
|
1525
|
-
}
|
|
1526
|
-
/** Resize the scroll viewport */
|
|
1527
|
-
resize(width, height) {
|
|
1528
|
-
this._config.width = width;
|
|
1529
|
-
this._config.height = height;
|
|
1530
|
-
// Redraw mask and background
|
|
1531
|
-
this._mask.clear();
|
|
1532
|
-
this._mask.roundRect(0, 0, width, height, this._config.borderRadius).fill(0xffffff);
|
|
1533
|
-
this._bg.clear();
|
|
1534
|
-
this.hitArea = { contains: (x, y) => x >= 0 && x <= width && y >= 0 && y <= height };
|
|
1535
|
-
this.clampScroll();
|
|
1536
|
-
this.applyScroll();
|
|
1537
|
-
}
|
|
1538
|
-
/** Destroy and clean up */
|
|
1539
|
-
destroy(options) {
|
|
1540
|
-
this.stopAnimation();
|
|
1541
|
-
if (this._scrollbarFadeTimeout !== null) {
|
|
1542
|
-
clearTimeout(this._scrollbarFadeTimeout);
|
|
1543
|
-
}
|
|
1544
|
-
this.off('pointerdown', this.onPointerDown);
|
|
1545
|
-
this.off('pointermove', this.onPointerMove);
|
|
1546
|
-
this.off('pointerup', this.onPointerUp);
|
|
1547
|
-
this.off('pointerupoutside', this.onPointerUp);
|
|
1548
|
-
this.off('wheel', this.onWheel);
|
|
1549
|
-
super.destroy(options);
|
|
1550
|
-
}
|
|
1551
|
-
// ─── Scroll mechanics ─────────────────────────────────
|
|
1552
|
-
get contentWidth() {
|
|
1553
|
-
if (!this._content)
|
|
1554
|
-
return 0;
|
|
1555
|
-
const bounds = this._content.getBounds();
|
|
1556
|
-
return bounds.width;
|
|
1557
|
-
}
|
|
1558
|
-
get contentHeight() {
|
|
1559
|
-
if (!this._content)
|
|
1560
|
-
return 0;
|
|
1561
|
-
const bounds = this._content.getBounds();
|
|
1562
|
-
return bounds.height;
|
|
1563
|
-
}
|
|
1564
|
-
get maxScrollX() {
|
|
1565
|
-
return Math.max(0, this.contentWidth - this._config.width);
|
|
1566
|
-
}
|
|
1567
|
-
get maxScrollY() {
|
|
1568
|
-
return Math.max(0, this.contentHeight - this._config.height);
|
|
1569
|
-
}
|
|
1570
|
-
canScrollX() {
|
|
1571
|
-
return this._config.direction === 'horizontal' || this._config.direction === 'both';
|
|
1572
|
-
}
|
|
1573
|
-
canScrollY() {
|
|
1574
|
-
return this._config.direction === 'vertical' || this._config.direction === 'both';
|
|
1575
|
-
}
|
|
1576
|
-
clampScroll() {
|
|
1577
|
-
if (this.canScrollX()) {
|
|
1578
|
-
this._scrollX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
|
|
1579
|
-
}
|
|
1580
|
-
else {
|
|
1581
|
-
this._scrollX = 0;
|
|
1582
|
-
}
|
|
1583
|
-
if (this.canScrollY()) {
|
|
1584
|
-
this._scrollY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
|
|
1585
|
-
}
|
|
1586
|
-
else {
|
|
1587
|
-
this._scrollY = 0;
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
applyScroll() {
|
|
1591
|
-
if (!this._content)
|
|
1592
|
-
return;
|
|
1593
|
-
this._content.x = -this._scrollX;
|
|
1594
|
-
this._content.y = -this._scrollY;
|
|
1595
|
-
this.updateScrollbars();
|
|
1596
|
-
}
|
|
1597
|
-
// ─── Input handlers ────────────────────────────────────
|
|
1598
|
-
onPointerDown = (e) => {
|
|
1599
|
-
this._isDragging = true;
|
|
1600
|
-
this._isAnimating = false;
|
|
1601
|
-
this.stopAnimation();
|
|
1602
|
-
this.cursor = 'grabbing';
|
|
1603
|
-
const local = e.getLocalPosition(this);
|
|
1604
|
-
this._dragStart = { x: local.x, y: local.y };
|
|
1605
|
-
this._scrollStart = { x: this._scrollX, y: this._scrollY };
|
|
1606
|
-
this._lastDragPos = { x: local.x, y: local.y };
|
|
1607
|
-
this._lastDragTime = Date.now();
|
|
1608
|
-
this._velocityX = 0;
|
|
1609
|
-
this._velocityY = 0;
|
|
1610
|
-
this.showScrollbars();
|
|
1611
|
-
};
|
|
1612
|
-
onPointerMove = (e) => {
|
|
1613
|
-
if (!this._isDragging)
|
|
1614
|
-
return;
|
|
1615
|
-
const local = e.getLocalPosition(this);
|
|
1616
|
-
const dx = local.x - this._dragStart.x;
|
|
1617
|
-
const dy = local.y - this._dragStart.y;
|
|
1618
|
-
const now = Date.now();
|
|
1619
|
-
const dt = Math.max(1, now - this._lastDragTime);
|
|
1620
|
-
// Calculate velocity for inertia
|
|
1621
|
-
this._velocityX = (local.x - this._lastDragPos.x) / dt * 16; // normalize to ~60fps
|
|
1622
|
-
this._velocityY = (local.y - this._lastDragPos.y) / dt * 16;
|
|
1623
|
-
this._lastDragPos = { x: local.x, y: local.y };
|
|
1624
|
-
this._lastDragTime = now;
|
|
1625
|
-
// Apply scroll with elasticity for overscroll
|
|
1626
|
-
let newX = this._scrollStart.x - dx;
|
|
1627
|
-
let newY = this._scrollStart.y - dy;
|
|
1628
|
-
const elasticity = this._config.elasticity;
|
|
1629
|
-
if (this.canScrollX()) {
|
|
1630
|
-
if (newX < 0)
|
|
1631
|
-
newX *= elasticity;
|
|
1632
|
-
else if (newX > this.maxScrollX)
|
|
1633
|
-
newX = this.maxScrollX + (newX - this.maxScrollX) * elasticity;
|
|
1634
|
-
this._scrollX = newX;
|
|
1635
|
-
}
|
|
1636
|
-
if (this.canScrollY()) {
|
|
1637
|
-
if (newY < 0)
|
|
1638
|
-
newY *= elasticity;
|
|
1639
|
-
else if (newY > this.maxScrollY)
|
|
1640
|
-
newY = this.maxScrollY + (newY - this.maxScrollY) * elasticity;
|
|
1641
|
-
this._scrollY = newY;
|
|
1642
|
-
}
|
|
1643
|
-
this.applyScroll();
|
|
1644
|
-
};
|
|
1645
|
-
onPointerUp = () => {
|
|
1646
|
-
if (!this._isDragging)
|
|
1647
|
-
return;
|
|
1648
|
-
this._isDragging = false;
|
|
1649
|
-
this.cursor = 'grab';
|
|
1650
|
-
// Start inertia
|
|
1651
|
-
if (Math.abs(this._velocityX) > 0.5 || Math.abs(this._velocityY) > 0.5) {
|
|
1652
|
-
this.startInertia();
|
|
1653
|
-
}
|
|
1654
|
-
else {
|
|
1655
|
-
this.snapAndBounce();
|
|
1656
|
-
}
|
|
1657
|
-
};
|
|
1658
|
-
onWheel = (e) => {
|
|
1659
|
-
e.preventDefault?.();
|
|
1660
|
-
const delta = e.deltaY ?? 0;
|
|
1661
|
-
const deltaX = e.deltaX ?? 0;
|
|
1662
|
-
if (this.canScrollY()) {
|
|
1663
|
-
this._scrollY += delta * 0.5;
|
|
1664
|
-
}
|
|
1665
|
-
if (this.canScrollX()) {
|
|
1666
|
-
this._scrollX += deltaX * 0.5;
|
|
1667
|
-
}
|
|
1668
|
-
this.clampScroll();
|
|
1669
|
-
this.applyScroll();
|
|
1670
|
-
this.showScrollbars();
|
|
1671
|
-
this.scheduleScrollbarFade();
|
|
1672
|
-
};
|
|
1673
|
-
// ─── Inertia & snap ───────────────────────────────────
|
|
1674
|
-
startInertia() {
|
|
1675
|
-
this._isAnimating = true;
|
|
1676
|
-
const tick = () => {
|
|
1677
|
-
if (!this._isAnimating)
|
|
1678
|
-
return;
|
|
1679
|
-
this._velocityX *= this._config.inertia;
|
|
1680
|
-
this._velocityY *= this._config.inertia;
|
|
1681
|
-
if (this.canScrollX())
|
|
1682
|
-
this._scrollX -= this._velocityX;
|
|
1683
|
-
if (this.canScrollY())
|
|
1684
|
-
this._scrollY -= this._velocityY;
|
|
1685
|
-
// Bounce back if overscrolled
|
|
1686
|
-
let bounced = false;
|
|
1687
|
-
if (this.canScrollX()) {
|
|
1688
|
-
if (this._scrollX < 0) {
|
|
1689
|
-
this._scrollX *= 0.8;
|
|
1690
|
-
bounced = true;
|
|
1691
|
-
}
|
|
1692
|
-
else if (this._scrollX > this.maxScrollX) {
|
|
1693
|
-
this._scrollX = this.maxScrollX + (this._scrollX - this.maxScrollX) * 0.8;
|
|
1694
|
-
bounced = true;
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
if (this.canScrollY()) {
|
|
1698
|
-
if (this._scrollY < 0) {
|
|
1699
|
-
this._scrollY *= 0.8;
|
|
1700
|
-
bounced = true;
|
|
1701
|
-
}
|
|
1702
|
-
else if (this._scrollY > this.maxScrollY) {
|
|
1703
|
-
this._scrollY = this.maxScrollY + (this._scrollY - this.maxScrollY) * 0.8;
|
|
1704
|
-
bounced = true;
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
this.applyScroll();
|
|
1708
|
-
const speed = Math.abs(this._velocityX) + Math.abs(this._velocityY);
|
|
1709
|
-
if (speed < 0.1 && !bounced) {
|
|
1710
|
-
this._isAnimating = false;
|
|
1711
|
-
this.snapAndBounce();
|
|
1712
|
-
return;
|
|
1713
|
-
}
|
|
1714
|
-
this._animationFrame = requestAnimationFrame(tick);
|
|
1715
|
-
};
|
|
1716
|
-
this._animationFrame = requestAnimationFrame(tick);
|
|
1717
|
-
}
|
|
1718
|
-
snapAndBounce() {
|
|
1719
|
-
// Clamp first
|
|
1720
|
-
let targetX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
|
|
1721
|
-
let targetY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
|
|
1722
|
-
// Snap
|
|
1723
|
-
if (this._config.snapSize > 0) {
|
|
1724
|
-
if (this.canScrollY()) {
|
|
1725
|
-
targetY = Math.round(targetY / this._config.snapSize) * this._config.snapSize;
|
|
1726
|
-
targetY = Math.max(0, Math.min(targetY, this.maxScrollY));
|
|
1727
|
-
}
|
|
1728
|
-
if (this.canScrollX()) {
|
|
1729
|
-
targetX = Math.round(targetX / this._config.snapSize) * this._config.snapSize;
|
|
1730
|
-
targetX = Math.max(0, Math.min(targetX, this.maxScrollX));
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
if (Math.abs(targetX - this._scrollX) < 0.5 && Math.abs(targetY - this._scrollY) < 0.5) {
|
|
1734
|
-
this._scrollX = targetX;
|
|
1735
|
-
this._scrollY = targetY;
|
|
1736
|
-
this.applyScroll();
|
|
1737
|
-
this.scheduleScrollbarFade();
|
|
1738
|
-
return;
|
|
1739
|
-
}
|
|
1740
|
-
this.animateScrollTo(targetX, targetY);
|
|
1741
|
-
}
|
|
1742
|
-
animateScrollTo(targetX, targetY) {
|
|
1743
|
-
this._isAnimating = true;
|
|
1744
|
-
const startX = this._scrollX;
|
|
1745
|
-
const startY = this._scrollY;
|
|
1746
|
-
const startTime = Date.now();
|
|
1747
|
-
const duration = 300;
|
|
1748
|
-
const tick = () => {
|
|
1749
|
-
if (!this._isAnimating)
|
|
1750
|
-
return;
|
|
1751
|
-
const elapsed = Date.now() - startTime;
|
|
1752
|
-
const t = Math.min(elapsed / duration, 1);
|
|
1753
|
-
// easeOutCubic
|
|
1754
|
-
const eased = 1 - Math.pow(1 - t, 3);
|
|
1755
|
-
this._scrollX = startX + (targetX - startX) * eased;
|
|
1756
|
-
this._scrollY = startY + (targetY - startY) * eased;
|
|
1757
|
-
this.applyScroll();
|
|
1758
|
-
if (t < 1) {
|
|
1759
|
-
this._animationFrame = requestAnimationFrame(tick);
|
|
1760
|
-
}
|
|
1761
|
-
else {
|
|
1762
|
-
this._isAnimating = false;
|
|
1763
|
-
this.scheduleScrollbarFade();
|
|
1764
|
-
}
|
|
1765
|
-
};
|
|
1766
|
-
this._animationFrame = requestAnimationFrame(tick);
|
|
1767
|
-
}
|
|
1768
|
-
stopAnimation() {
|
|
1769
|
-
this._isAnimating = false;
|
|
1770
|
-
if (this._animationFrame !== null) {
|
|
1771
|
-
cancelAnimationFrame(this._animationFrame);
|
|
1772
|
-
this._animationFrame = null;
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
// ─── Scrollbars ────────────────────────────────────────
|
|
1776
|
-
updateScrollbars() {
|
|
1777
|
-
const { width, height, scrollbarWidth, scrollbarColor, scrollbarAlpha } = this._config;
|
|
1778
|
-
if (this._scrollbarV && this.canScrollY() && this.contentHeight > height) {
|
|
1779
|
-
const ratio = height / this.contentHeight;
|
|
1780
|
-
const barH = Math.max(20, height * ratio);
|
|
1781
|
-
const barY = (this._scrollY / this.maxScrollY) * (height - barH);
|
|
1782
|
-
this._scrollbarV.clear();
|
|
1783
|
-
this._scrollbarV.roundRect(width - scrollbarWidth - 2, Math.max(0, barY), scrollbarWidth, barH, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
|
|
1784
|
-
}
|
|
1785
|
-
if (this._scrollbarH && this.canScrollX() && this.contentWidth > width) {
|
|
1786
|
-
const ratio = width / this.contentWidth;
|
|
1787
|
-
const barW = Math.max(20, width * ratio);
|
|
1788
|
-
const barX = (this._scrollX / this.maxScrollX) * (width - barW);
|
|
1789
|
-
this._scrollbarH.clear();
|
|
1790
|
-
this._scrollbarH.roundRect(Math.max(0, barX), height - scrollbarWidth - 2, barW, scrollbarWidth, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
showScrollbars() {
|
|
1794
|
-
if (this._scrollbarV)
|
|
1795
|
-
this._scrollbarV.alpha = 1;
|
|
1796
|
-
if (this._scrollbarH)
|
|
1797
|
-
this._scrollbarH.alpha = 1;
|
|
1798
|
-
}
|
|
1799
|
-
scheduleScrollbarFade() {
|
|
1800
|
-
if (this._scrollbarFadeTimeout !== null) {
|
|
1801
|
-
clearTimeout(this._scrollbarFadeTimeout);
|
|
1802
|
-
}
|
|
1803
|
-
this._scrollbarFadeTimeout = window.setTimeout(() => {
|
|
1804
|
-
this.fadeScrollbars();
|
|
1805
|
-
}, 1000);
|
|
1806
|
-
}
|
|
1807
|
-
fadeScrollbars() {
|
|
1808
|
-
const duration = 300;
|
|
1809
|
-
const startTime = Date.now();
|
|
1810
|
-
const startAlphaV = this._scrollbarV?.alpha ?? 0;
|
|
1811
|
-
const startAlphaH = this._scrollbarH?.alpha ?? 0;
|
|
1812
|
-
const tick = () => {
|
|
1813
|
-
const t = Math.min((Date.now() - startTime) / duration, 1);
|
|
1814
|
-
if (this._scrollbarV)
|
|
1815
|
-
this._scrollbarV.alpha = startAlphaV * (1 - t);
|
|
1816
|
-
if (this._scrollbarH)
|
|
1817
|
-
this._scrollbarH.alpha = startAlphaH * (1 - t);
|
|
1818
|
-
if (t < 1)
|
|
1819
|
-
requestAnimationFrame(tick);
|
|
1820
|
-
};
|
|
1821
|
-
requestAnimationFrame(tick);
|
|
1330
|
+
return { x: this.scrollX, y: this.scrollY };
|
|
1822
1331
|
}
|
|
1823
1332
|
}
|
|
1824
1333
|
|