@energy8platform/game-engine 0.3.0 → 0.4.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 +139 -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 +317 -789
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +61 -129
- package/dist/index.esm.js +304 -790
- package/dist/index.esm.js.map +1 -1
- package/dist/ui.cjs.js +637 -1106
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.d.ts +60 -128
- package/dist/ui.esm.js +620 -1107
- 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 +16 -0
- package/src/ui/BalanceDisplay.ts +0 -3
- package/src/ui/Button.ts +71 -130
- package/src/ui/Layout.ts +102 -180
- 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 +19 -13
- package/src/ui/index.ts +13 -0
- package/src/vite/index.ts +23 -3
package/dist/ui.esm.js
CHANGED
|
@@ -1,434 +1,117 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
export { Layout as PixiLayout } from '@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
|
+
export { ButtonContainer, FancyButton, ScrollBox } from '@pixi/ui';
|
|
5
|
+
import { LayoutContainer } from '@pixi/layout/components';
|
|
6
|
+
export { LayoutContainer } from '@pixi/layout/components';
|
|
275
7
|
|
|
276
8
|
const DEFAULT_COLORS = {
|
|
277
|
-
|
|
9
|
+
default: 0xffd700,
|
|
278
10
|
hover: 0xffe44d,
|
|
279
11
|
pressed: 0xccac00,
|
|
280
12
|
disabled: 0x666666,
|
|
281
13
|
};
|
|
14
|
+
function makeGraphicsView(w, h, radius, color) {
|
|
15
|
+
const g = new Graphics();
|
|
16
|
+
g.roundRect(0, 0, w, h, radius).fill(color);
|
|
17
|
+
// Highlight overlay
|
|
18
|
+
g.roundRect(2, 2, w - 4, h * 0.45, radius).fill({ color: 0xffffff, alpha: 0.1 });
|
|
19
|
+
return g;
|
|
20
|
+
}
|
|
282
21
|
/**
|
|
283
|
-
* Interactive button component
|
|
22
|
+
* Interactive button component powered by `@pixi/ui` FancyButton.
|
|
284
23
|
*
|
|
285
|
-
* Supports both texture-based and Graphics-based rendering
|
|
24
|
+
* Supports both texture-based and Graphics-based rendering with
|
|
25
|
+
* per-state views, press animation, and text.
|
|
286
26
|
*
|
|
287
27
|
* @example
|
|
288
28
|
* ```ts
|
|
289
29
|
* const btn = new Button({
|
|
290
30
|
* width: 200, height: 60, borderRadius: 12,
|
|
291
|
-
* colors: {
|
|
31
|
+
* colors: { default: 0x22aa22, hover: 0x33cc33 },
|
|
32
|
+
* text: 'SPIN',
|
|
292
33
|
* });
|
|
293
34
|
*
|
|
294
|
-
* btn.
|
|
35
|
+
* btn.onPress.connect(() => console.log('Clicked!'));
|
|
295
36
|
* scene.container.addChild(btn);
|
|
296
37
|
* ```
|
|
297
38
|
*/
|
|
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;
|
|
39
|
+
class Button extends FancyButton {
|
|
40
|
+
_buttonConfig;
|
|
307
41
|
constructor(config = {}) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
animationDuration: 100,
|
|
42
|
+
const resolvedConfig = {
|
|
43
|
+
width: config.width ?? 200,
|
|
44
|
+
height: config.height ?? 60,
|
|
45
|
+
borderRadius: config.borderRadius ?? 8,
|
|
46
|
+
pressScale: config.pressScale ?? 0.95,
|
|
47
|
+
animationDuration: config.animationDuration ?? 100,
|
|
315
48
|
...config,
|
|
316
49
|
};
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
50
|
+
const colorMap = { ...DEFAULT_COLORS, ...config.colors };
|
|
51
|
+
const { width, height, borderRadius } = resolvedConfig;
|
|
52
|
+
// Build FancyButton options
|
|
53
|
+
const options = {
|
|
54
|
+
anchor: 0.5,
|
|
55
|
+
animations: {
|
|
56
|
+
hover: {
|
|
57
|
+
props: { scale: { x: 1.03, y: 1.03 } },
|
|
58
|
+
duration: resolvedConfig.animationDuration,
|
|
59
|
+
},
|
|
60
|
+
pressed: {
|
|
61
|
+
props: { scale: { x: resolvedConfig.pressScale, y: resolvedConfig.pressScale } },
|
|
62
|
+
duration: resolvedConfig.animationDuration,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
// Texture-based views
|
|
321
67
|
if (config.textures) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
68
|
+
if (config.textures.default)
|
|
69
|
+
options.defaultView = config.textures.default;
|
|
70
|
+
if (config.textures.hover)
|
|
71
|
+
options.hoverView = config.textures.hover;
|
|
72
|
+
if (config.textures.pressed)
|
|
73
|
+
options.pressedView = config.textures.pressed;
|
|
74
|
+
if (config.textures.disabled)
|
|
75
|
+
options.disabledView = config.textures.disabled;
|
|
330
76
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
this.
|
|
77
|
+
else {
|
|
78
|
+
// Graphics-based views
|
|
79
|
+
options.defaultView = makeGraphicsView(width, height, borderRadius, colorMap.default);
|
|
80
|
+
options.hoverView = makeGraphicsView(width, height, borderRadius, colorMap.hover);
|
|
81
|
+
options.pressedView = makeGraphicsView(width, height, borderRadius, colorMap.pressed);
|
|
82
|
+
options.disabledView = makeGraphicsView(width, height, borderRadius, colorMap.disabled);
|
|
83
|
+
}
|
|
84
|
+
// Text
|
|
85
|
+
if (config.text) {
|
|
86
|
+
options.text = config.text;
|
|
87
|
+
}
|
|
88
|
+
super(options);
|
|
89
|
+
this._buttonConfig = resolvedConfig;
|
|
344
90
|
if (config.disabled) {
|
|
345
|
-
this.
|
|
91
|
+
this.enabled = false;
|
|
346
92
|
}
|
|
347
93
|
}
|
|
348
|
-
/** Current button state */
|
|
349
|
-
get state() {
|
|
350
|
-
return this._state;
|
|
351
|
-
}
|
|
352
94
|
/** Enable the button */
|
|
353
95
|
enable() {
|
|
354
|
-
|
|
355
|
-
this.setState('normal');
|
|
356
|
-
this.eventMode = 'static';
|
|
357
|
-
this.cursor = 'pointer';
|
|
358
|
-
}
|
|
96
|
+
this.enabled = true;
|
|
359
97
|
}
|
|
360
98
|
/** Disable the button */
|
|
361
99
|
disable() {
|
|
362
|
-
this.
|
|
363
|
-
this.eventMode = 'none';
|
|
364
|
-
this.cursor = 'default';
|
|
100
|
+
this.enabled = false;
|
|
365
101
|
}
|
|
366
102
|
/** Whether the button is disabled */
|
|
367
103
|
get disabled() {
|
|
368
|
-
return this.
|
|
369
|
-
}
|
|
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
|
-
}
|
|
104
|
+
return !this.enabled;
|
|
398
105
|
}
|
|
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
106
|
}
|
|
429
107
|
|
|
108
|
+
function makeBarGraphics(w, h, radius, color) {
|
|
109
|
+
return new Graphics().roundRect(0, 0, w, h, radius).fill(color);
|
|
110
|
+
}
|
|
430
111
|
/**
|
|
431
|
-
* Horizontal progress bar
|
|
112
|
+
* Horizontal progress bar powered by `@pixi/ui` ProgressBar.
|
|
113
|
+
*
|
|
114
|
+
* Provides optional smooth animated fill via per-frame `update()`.
|
|
432
115
|
*
|
|
433
116
|
* @example
|
|
434
117
|
* ```ts
|
|
@@ -438,33 +121,48 @@ class Button extends Container {
|
|
|
438
121
|
* ```
|
|
439
122
|
*/
|
|
440
123
|
class ProgressBar extends Container {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
_border;
|
|
124
|
+
_bar;
|
|
125
|
+
_borderGfx;
|
|
444
126
|
_config;
|
|
445
127
|
_progress = 0;
|
|
446
128
|
_displayedProgress = 0;
|
|
447
129
|
constructor(config = {}) {
|
|
448
130
|
super();
|
|
449
131
|
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,
|
|
132
|
+
width: config.width ?? 300,
|
|
133
|
+
height: config.height ?? 16,
|
|
134
|
+
borderRadius: config.borderRadius ?? 8,
|
|
135
|
+
fillColor: config.fillColor ?? 0xffd700,
|
|
136
|
+
trackColor: config.trackColor ?? 0x333333,
|
|
137
|
+
borderColor: config.borderColor ?? 0x555555,
|
|
138
|
+
borderWidth: config.borderWidth ?? 1,
|
|
139
|
+
animated: config.animated ?? true,
|
|
140
|
+
animationSpeed: config.animationSpeed ?? 0.1,
|
|
460
141
|
};
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
142
|
+
const { width, height, borderRadius, fillColor, trackColor, borderColor, borderWidth } = this._config;
|
|
143
|
+
const bgGraphics = makeBarGraphics(width, height, borderRadius, trackColor);
|
|
144
|
+
const fillGraphics = makeBarGraphics(width - borderWidth * 2, height - borderWidth * 2, Math.max(0, borderRadius - 1), fillColor);
|
|
145
|
+
const options = {
|
|
146
|
+
bg: bgGraphics,
|
|
147
|
+
fill: fillGraphics,
|
|
148
|
+
fillPaddings: {
|
|
149
|
+
top: borderWidth,
|
|
150
|
+
right: borderWidth,
|
|
151
|
+
bottom: borderWidth,
|
|
152
|
+
left: borderWidth,
|
|
153
|
+
},
|
|
154
|
+
progress: 0,
|
|
155
|
+
};
|
|
156
|
+
this._bar = new ProgressBar$1(options);
|
|
157
|
+
this.addChild(this._bar);
|
|
158
|
+
// Border overlay
|
|
159
|
+
this._borderGfx = new Graphics();
|
|
160
|
+
if (borderColor !== undefined && borderWidth > 0) {
|
|
161
|
+
this._borderGfx
|
|
162
|
+
.roundRect(0, 0, width, height, borderRadius)
|
|
163
|
+
.stroke({ color: borderColor, width: borderWidth });
|
|
164
|
+
}
|
|
165
|
+
this.addChild(this._borderGfx);
|
|
468
166
|
}
|
|
469
167
|
/** Get/set progress (0..1) */
|
|
470
168
|
get progress() {
|
|
@@ -474,13 +172,13 @@ class ProgressBar extends Container {
|
|
|
474
172
|
this._progress = Math.max(0, Math.min(1, value));
|
|
475
173
|
if (!this._config.animated) {
|
|
476
174
|
this._displayedProgress = this._progress;
|
|
477
|
-
this.
|
|
175
|
+
this._bar.progress = this._displayedProgress * 100;
|
|
478
176
|
}
|
|
479
177
|
}
|
|
480
178
|
/**
|
|
481
179
|
* Call each frame if animated is true.
|
|
482
180
|
*/
|
|
483
|
-
update(
|
|
181
|
+
update(_dt) {
|
|
484
182
|
if (!this._config.animated)
|
|
485
183
|
return;
|
|
486
184
|
if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
|
|
@@ -489,35 +187,7 @@ class ProgressBar extends Container {
|
|
|
489
187
|
}
|
|
490
188
|
this._displayedProgress +=
|
|
491
189
|
(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
|
-
}
|
|
190
|
+
this._bar.progress = this._displayedProgress * 100;
|
|
521
191
|
}
|
|
522
192
|
}
|
|
523
193
|
|
|
@@ -613,7 +283,10 @@ class Label extends Container {
|
|
|
613
283
|
}
|
|
614
284
|
|
|
615
285
|
/**
|
|
616
|
-
* Background panel
|
|
286
|
+
* Background panel powered by `@pixi/layout` LayoutContainer.
|
|
287
|
+
*
|
|
288
|
+
* Supports both Graphics-based (color + border) and 9-slice sprite backgrounds.
|
|
289
|
+
* Children added to `content` participate in flexbox layout automatically.
|
|
617
290
|
*
|
|
618
291
|
* @example
|
|
619
292
|
* ```ts
|
|
@@ -628,75 +301,148 @@ class Label extends Container {
|
|
|
628
301
|
* });
|
|
629
302
|
* ```
|
|
630
303
|
*/
|
|
631
|
-
class Panel extends
|
|
632
|
-
|
|
633
|
-
_content;
|
|
634
|
-
_config;
|
|
304
|
+
class Panel extends LayoutContainer {
|
|
305
|
+
_panelConfig;
|
|
635
306
|
constructor(config = {}) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
backgroundAlpha: 1,
|
|
307
|
+
const resolvedConfig = {
|
|
308
|
+
width: config.width ?? 400,
|
|
309
|
+
height: config.height ?? 300,
|
|
310
|
+
padding: config.padding ?? 16,
|
|
311
|
+
backgroundAlpha: config.backgroundAlpha ?? 1,
|
|
642
312
|
...config,
|
|
643
313
|
};
|
|
644
|
-
//
|
|
314
|
+
// If using a 9-slice texture, pass it as a custom background
|
|
315
|
+
let customBackground;
|
|
645
316
|
if (config.nineSliceTexture) {
|
|
646
317
|
const texture = typeof config.nineSliceTexture === 'string'
|
|
647
318
|
? Texture.from(config.nineSliceTexture)
|
|
648
319
|
: config.nineSliceTexture;
|
|
649
320
|
const [left, top, right, bottom] = config.nineSliceBorders ?? [10, 10, 10, 10];
|
|
650
|
-
|
|
321
|
+
const nineSlice = new NineSliceSprite({
|
|
651
322
|
texture,
|
|
652
323
|
leftWidth: left,
|
|
653
324
|
topHeight: top,
|
|
654
325
|
rightWidth: right,
|
|
655
326
|
bottomHeight: bottom,
|
|
656
327
|
});
|
|
657
|
-
|
|
658
|
-
|
|
328
|
+
nineSlice.width = resolvedConfig.width;
|
|
329
|
+
nineSlice.height = resolvedConfig.height;
|
|
330
|
+
nineSlice.alpha = resolvedConfig.backgroundAlpha;
|
|
331
|
+
customBackground = nineSlice;
|
|
332
|
+
}
|
|
333
|
+
super(customBackground ? { background: customBackground } : undefined);
|
|
334
|
+
this._panelConfig = resolvedConfig;
|
|
335
|
+
// Apply layout styles
|
|
336
|
+
const layoutStyles = {
|
|
337
|
+
width: resolvedConfig.width,
|
|
338
|
+
height: resolvedConfig.height,
|
|
339
|
+
padding: resolvedConfig.padding,
|
|
340
|
+
flexDirection: 'column',
|
|
341
|
+
};
|
|
342
|
+
// Graphics-based background via layout styles
|
|
343
|
+
if (!config.nineSliceTexture) {
|
|
344
|
+
layoutStyles.backgroundColor = config.backgroundColor ?? 0x1a1a2e;
|
|
345
|
+
layoutStyles.borderRadius = config.borderRadius ?? 0;
|
|
346
|
+
if (config.borderColor !== undefined && config.borderWidth) {
|
|
347
|
+
layoutStyles.borderColor = config.borderColor;
|
|
348
|
+
layoutStyles.borderWidth = config.borderWidth;
|
|
349
|
+
}
|
|
659
350
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
this.
|
|
351
|
+
this.layout = layoutStyles;
|
|
352
|
+
if (!config.nineSliceTexture) {
|
|
353
|
+
this.background.alpha = resolvedConfig.backgroundAlpha;
|
|
663
354
|
}
|
|
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
355
|
}
|
|
672
|
-
/**
|
|
356
|
+
/** Access the content container (children added here participate in layout) */
|
|
673
357
|
get content() {
|
|
674
|
-
return this.
|
|
358
|
+
return this.overflowContainer;
|
|
675
359
|
}
|
|
676
360
|
/** Resize the panel */
|
|
677
361
|
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
|
-
}
|
|
362
|
+
this._panelConfig.width = width;
|
|
363
|
+
this._panelConfig.height = height;
|
|
364
|
+
this._layout?.setStyle({ width, height });
|
|
697
365
|
}
|
|
698
366
|
}
|
|
699
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Collection of easing functions for use with Tween and Timeline.
|
|
370
|
+
*
|
|
371
|
+
* All functions take a progress value t (0..1) and return the eased value.
|
|
372
|
+
*/
|
|
373
|
+
const Easing = {
|
|
374
|
+
linear: (t) => t,
|
|
375
|
+
easeInQuad: (t) => t * t,
|
|
376
|
+
easeOutQuad: (t) => t * (2 - t),
|
|
377
|
+
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
|
378
|
+
easeInCubic: (t) => t * t * t,
|
|
379
|
+
easeOutCubic: (t) => --t * t * t + 1,
|
|
380
|
+
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
|
|
381
|
+
easeInQuart: (t) => t * t * t * t,
|
|
382
|
+
easeOutQuart: (t) => 1 - --t * t * t * t,
|
|
383
|
+
easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
|
|
384
|
+
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
385
|
+
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
|
|
386
|
+
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
|
387
|
+
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
|
|
388
|
+
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
|
389
|
+
easeInOutExpo: (t) => t === 0
|
|
390
|
+
? 0
|
|
391
|
+
: t === 1
|
|
392
|
+
? 1
|
|
393
|
+
: t < 0.5
|
|
394
|
+
? Math.pow(2, 20 * t - 10) / 2
|
|
395
|
+
: (2 - Math.pow(2, -20 * t + 10)) / 2,
|
|
396
|
+
easeInBack: (t) => {
|
|
397
|
+
const c1 = 1.70158;
|
|
398
|
+
const c3 = c1 + 1;
|
|
399
|
+
return c3 * t * t * t - c1 * t * t;
|
|
400
|
+
},
|
|
401
|
+
easeOutBack: (t) => {
|
|
402
|
+
const c1 = 1.70158;
|
|
403
|
+
const c3 = c1 + 1;
|
|
404
|
+
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
405
|
+
},
|
|
406
|
+
easeInOutBack: (t) => {
|
|
407
|
+
const c1 = 1.70158;
|
|
408
|
+
const c2 = c1 * 1.525;
|
|
409
|
+
return t < 0.5
|
|
410
|
+
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
|
411
|
+
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
|
|
412
|
+
},
|
|
413
|
+
easeOutBounce: (t) => {
|
|
414
|
+
const n1 = 7.5625;
|
|
415
|
+
const d1 = 2.75;
|
|
416
|
+
if (t < 1 / d1)
|
|
417
|
+
return n1 * t * t;
|
|
418
|
+
if (t < 2 / d1)
|
|
419
|
+
return n1 * (t -= 1.5 / d1) * t + 0.75;
|
|
420
|
+
if (t < 2.5 / d1)
|
|
421
|
+
return n1 * (t -= 2.25 / d1) * t + 0.9375;
|
|
422
|
+
return n1 * (t -= 2.625 / d1) * t + 0.984375;
|
|
423
|
+
},
|
|
424
|
+
easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
|
|
425
|
+
easeInOutBounce: (t) => t < 0.5
|
|
426
|
+
? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
|
|
427
|
+
: (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
|
|
428
|
+
easeOutElastic: (t) => {
|
|
429
|
+
const c4 = (2 * Math.PI) / 3;
|
|
430
|
+
return t === 0
|
|
431
|
+
? 0
|
|
432
|
+
: t === 1
|
|
433
|
+
? 1
|
|
434
|
+
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
|
435
|
+
},
|
|
436
|
+
easeInElastic: (t) => {
|
|
437
|
+
const c4 = (2 * Math.PI) / 3;
|
|
438
|
+
return t === 0
|
|
439
|
+
? 0
|
|
440
|
+
: t === 1
|
|
441
|
+
? 1
|
|
442
|
+
: -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
|
|
700
446
|
/**
|
|
701
447
|
* Reactive balance display component.
|
|
702
448
|
*
|
|
@@ -781,7 +527,6 @@ class BalanceDisplay extends Container {
|
|
|
781
527
|
this.updateDisplay();
|
|
782
528
|
}
|
|
783
529
|
async animateValue(from, to) {
|
|
784
|
-
// Cancel any ongoing animation
|
|
785
530
|
if (this._animating) {
|
|
786
531
|
this._animationCancelled = true;
|
|
787
532
|
}
|
|
@@ -791,7 +536,6 @@ class BalanceDisplay extends Container {
|
|
|
791
536
|
const startTime = Date.now();
|
|
792
537
|
return new Promise((resolve) => {
|
|
793
538
|
const tick = () => {
|
|
794
|
-
// If cancelled by a newer animation, stop immediately
|
|
795
539
|
if (this._animationCancelled) {
|
|
796
540
|
this._animating = false;
|
|
797
541
|
resolve();
|
|
@@ -866,66 +610,261 @@ class WinDisplay extends Container {
|
|
|
866
610
|
this.visible = false;
|
|
867
611
|
}
|
|
868
612
|
/**
|
|
869
|
-
* Show a win with countup animation.
|
|
870
|
-
*
|
|
871
|
-
* @param amount - Win amount
|
|
872
|
-
* @returns Promise that resolves when the animation completes
|
|
613
|
+
* Show a win with countup animation.
|
|
614
|
+
*
|
|
615
|
+
* @param amount - Win amount
|
|
616
|
+
* @returns Promise that resolves when the animation completes
|
|
617
|
+
*/
|
|
618
|
+
async showWin(amount) {
|
|
619
|
+
this.visible = true;
|
|
620
|
+
this._cancelCountup = false;
|
|
621
|
+
this.alpha = 1;
|
|
622
|
+
const duration = this._config.countupDuration;
|
|
623
|
+
const startTime = Date.now();
|
|
624
|
+
// Scale pop
|
|
625
|
+
this.scale.set(0.5);
|
|
626
|
+
return new Promise((resolve) => {
|
|
627
|
+
const tick = () => {
|
|
628
|
+
if (this._cancelCountup) {
|
|
629
|
+
this.displayAmount(amount);
|
|
630
|
+
resolve();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const elapsed = Date.now() - startTime;
|
|
634
|
+
const t = Math.min(elapsed / duration, 1);
|
|
635
|
+
const eased = Easing.easeOutCubic(t);
|
|
636
|
+
// Countup
|
|
637
|
+
const current = amount * eased;
|
|
638
|
+
this.displayAmount(current);
|
|
639
|
+
// Scale animation
|
|
640
|
+
const scaleT = Math.min(elapsed / 300, 1);
|
|
641
|
+
const scaleEased = Easing.easeOutBack(scaleT);
|
|
642
|
+
const targetScale = 1;
|
|
643
|
+
this.scale.set(0.5 + (targetScale - 0.5) * scaleEased);
|
|
644
|
+
if (t < 1) {
|
|
645
|
+
requestAnimationFrame(tick);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
this.displayAmount(amount);
|
|
649
|
+
this.scale.set(1);
|
|
650
|
+
resolve();
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
requestAnimationFrame(tick);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Skip the countup animation and show the final amount immediately.
|
|
658
|
+
*/
|
|
659
|
+
skipCountup(amount) {
|
|
660
|
+
this._cancelCountup = true;
|
|
661
|
+
this.displayAmount(amount);
|
|
662
|
+
this.scale.set(1);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Hide the win display.
|
|
666
|
+
*/
|
|
667
|
+
hide() {
|
|
668
|
+
this.visible = false;
|
|
669
|
+
this._label.text = '';
|
|
670
|
+
}
|
|
671
|
+
displayAmount(amount) {
|
|
672
|
+
this._label.setCurrency(amount, this._config.currency, this._config.locale);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Lightweight tween system integrated with PixiJS Ticker.
|
|
678
|
+
* Zero external dependencies — no GSAP required.
|
|
679
|
+
*
|
|
680
|
+
* All tweens return a Promise that resolves on completion.
|
|
681
|
+
*
|
|
682
|
+
* @example
|
|
683
|
+
* ```ts
|
|
684
|
+
* // Fade in a sprite
|
|
685
|
+
* await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
|
|
686
|
+
*
|
|
687
|
+
* // Move and wait
|
|
688
|
+
* await Tween.to(sprite, { x: 500 }, 300);
|
|
689
|
+
*
|
|
690
|
+
* // From a starting value
|
|
691
|
+
* await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
|
|
692
|
+
* ```
|
|
693
|
+
*/
|
|
694
|
+
class Tween {
|
|
695
|
+
static _tweens = [];
|
|
696
|
+
static _tickerAdded = false;
|
|
697
|
+
/**
|
|
698
|
+
* Animate properties from current values to target values.
|
|
699
|
+
*
|
|
700
|
+
* @param target - Object to animate (Sprite, Container, etc.)
|
|
701
|
+
* @param props - Target property values
|
|
702
|
+
* @param duration - Duration in milliseconds
|
|
703
|
+
* @param easing - Easing function (default: easeOutQuad)
|
|
704
|
+
* @param onUpdate - Progress callback (0..1)
|
|
705
|
+
*/
|
|
706
|
+
static to(target, props, duration, easing, onUpdate) {
|
|
707
|
+
return new Promise((resolve) => {
|
|
708
|
+
// Capture starting values
|
|
709
|
+
const from = {};
|
|
710
|
+
for (const key of Object.keys(props)) {
|
|
711
|
+
from[key] = Tween.getProperty(target, key);
|
|
712
|
+
}
|
|
713
|
+
const tween = {
|
|
714
|
+
target,
|
|
715
|
+
from,
|
|
716
|
+
to: { ...props },
|
|
717
|
+
duration: Math.max(1, duration),
|
|
718
|
+
easing: easing ?? Easing.easeOutQuad,
|
|
719
|
+
elapsed: 0,
|
|
720
|
+
delay: 0,
|
|
721
|
+
resolve,
|
|
722
|
+
onUpdate,
|
|
723
|
+
};
|
|
724
|
+
Tween._tweens.push(tween);
|
|
725
|
+
Tween.ensureTicker();
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Animate properties from given values to current values.
|
|
730
|
+
*/
|
|
731
|
+
static from(target, props, duration, easing, onUpdate) {
|
|
732
|
+
// Capture current values as "to"
|
|
733
|
+
const to = {};
|
|
734
|
+
for (const key of Object.keys(props)) {
|
|
735
|
+
to[key] = Tween.getProperty(target, key);
|
|
736
|
+
Tween.setProperty(target, key, props[key]);
|
|
737
|
+
}
|
|
738
|
+
return Tween.to(target, to, duration, easing, onUpdate);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Animate from one set of values to another.
|
|
742
|
+
*/
|
|
743
|
+
static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
|
|
744
|
+
// Set starting values
|
|
745
|
+
for (const key of Object.keys(fromProps)) {
|
|
746
|
+
Tween.setProperty(target, key, fromProps[key]);
|
|
747
|
+
}
|
|
748
|
+
return Tween.to(target, toProps, duration, easing, onUpdate);
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Wait for a given duration (useful in timelines).
|
|
752
|
+
* Uses PixiJS Ticker for consistent timing with other tweens.
|
|
873
753
|
*/
|
|
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);
|
|
754
|
+
static delay(ms) {
|
|
882
755
|
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);
|
|
756
|
+
let elapsed = 0;
|
|
757
|
+
const onTick = (ticker) => {
|
|
758
|
+
elapsed += ticker.deltaMS;
|
|
759
|
+
if (elapsed >= ms) {
|
|
760
|
+
Ticker.shared.remove(onTick);
|
|
906
761
|
resolve();
|
|
907
762
|
}
|
|
908
763
|
};
|
|
909
|
-
|
|
764
|
+
Ticker.shared.add(onTick);
|
|
910
765
|
});
|
|
911
766
|
}
|
|
912
767
|
/**
|
|
913
|
-
*
|
|
768
|
+
* Kill all tweens on a target.
|
|
914
769
|
*/
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
770
|
+
static killTweensOf(target) {
|
|
771
|
+
Tween._tweens = Tween._tweens.filter((tw) => {
|
|
772
|
+
if (tw.target === target) {
|
|
773
|
+
tw.resolve();
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
return true;
|
|
777
|
+
});
|
|
919
778
|
}
|
|
920
779
|
/**
|
|
921
|
-
*
|
|
780
|
+
* Kill all active tweens.
|
|
922
781
|
*/
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
782
|
+
static killAll() {
|
|
783
|
+
for (const tw of Tween._tweens) {
|
|
784
|
+
tw.resolve();
|
|
785
|
+
}
|
|
786
|
+
Tween._tweens.length = 0;
|
|
926
787
|
}
|
|
927
|
-
|
|
928
|
-
|
|
788
|
+
/** Number of active tweens */
|
|
789
|
+
static get activeTweens() {
|
|
790
|
+
return Tween._tweens.length;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Reset the tween system — kill all tweens and remove the ticker.
|
|
794
|
+
* Useful for cleanup between game instances, tests, or hot-reload.
|
|
795
|
+
*/
|
|
796
|
+
static reset() {
|
|
797
|
+
for (const tw of Tween._tweens) {
|
|
798
|
+
tw.resolve();
|
|
799
|
+
}
|
|
800
|
+
Tween._tweens.length = 0;
|
|
801
|
+
if (Tween._tickerAdded) {
|
|
802
|
+
Ticker.shared.remove(Tween.tick);
|
|
803
|
+
Tween._tickerAdded = false;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// ─── Internal ──────────────────────────────────────────
|
|
807
|
+
static ensureTicker() {
|
|
808
|
+
if (Tween._tickerAdded)
|
|
809
|
+
return;
|
|
810
|
+
Tween._tickerAdded = true;
|
|
811
|
+
Ticker.shared.add(Tween.tick);
|
|
812
|
+
}
|
|
813
|
+
static tick = (ticker) => {
|
|
814
|
+
const dt = ticker.deltaMS;
|
|
815
|
+
const completed = [];
|
|
816
|
+
for (const tw of Tween._tweens) {
|
|
817
|
+
tw.elapsed += dt;
|
|
818
|
+
if (tw.elapsed < tw.delay)
|
|
819
|
+
continue;
|
|
820
|
+
const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
|
|
821
|
+
const t = tw.easing(raw);
|
|
822
|
+
// Interpolate each property
|
|
823
|
+
for (const key of Object.keys(tw.to)) {
|
|
824
|
+
const start = tw.from[key];
|
|
825
|
+
const end = tw.to[key];
|
|
826
|
+
const value = start + (end - start) * t;
|
|
827
|
+
Tween.setProperty(tw.target, key, value);
|
|
828
|
+
}
|
|
829
|
+
tw.onUpdate?.(raw);
|
|
830
|
+
if (raw >= 1) {
|
|
831
|
+
completed.push(tw);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// Remove completed tweens
|
|
835
|
+
for (const tw of completed) {
|
|
836
|
+
const idx = Tween._tweens.indexOf(tw);
|
|
837
|
+
if (idx !== -1)
|
|
838
|
+
Tween._tweens.splice(idx, 1);
|
|
839
|
+
tw.resolve();
|
|
840
|
+
}
|
|
841
|
+
// Remove ticker when no active tweens
|
|
842
|
+
if (Tween._tweens.length === 0 && Tween._tickerAdded) {
|
|
843
|
+
Ticker.shared.remove(Tween.tick);
|
|
844
|
+
Tween._tickerAdded = false;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
/**
|
|
848
|
+
* Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
|
|
849
|
+
*/
|
|
850
|
+
static getProperty(target, key) {
|
|
851
|
+
const parts = key.split('.');
|
|
852
|
+
let obj = target;
|
|
853
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
854
|
+
obj = obj[parts[i]];
|
|
855
|
+
}
|
|
856
|
+
return obj[parts[parts.length - 1]] ?? 0;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Set a potentially nested property.
|
|
860
|
+
*/
|
|
861
|
+
static setProperty(target, key, value) {
|
|
862
|
+
const parts = key.split('.');
|
|
863
|
+
let obj = target;
|
|
864
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
865
|
+
obj = obj[parts[i]];
|
|
866
|
+
}
|
|
867
|
+
obj[parts[parts.length - 1]] = value;
|
|
929
868
|
}
|
|
930
869
|
}
|
|
931
870
|
|
|
@@ -933,6 +872,8 @@ class WinDisplay extends Container {
|
|
|
933
872
|
* Modal overlay component.
|
|
934
873
|
* Shows content on top of a dark overlay with enter/exit animations.
|
|
935
874
|
*
|
|
875
|
+
* The content container uses `@pixi/layout` for automatic centering.
|
|
876
|
+
*
|
|
936
877
|
* @example
|
|
937
878
|
* ```ts
|
|
938
879
|
* const modal = new Modal({ closeOnOverlay: true });
|
|
@@ -951,11 +892,10 @@ class Modal extends Container {
|
|
|
951
892
|
constructor(config = {}) {
|
|
952
893
|
super();
|
|
953
894
|
this._config = {
|
|
954
|
-
overlayColor: 0x000000,
|
|
955
|
-
overlayAlpha: 0.7,
|
|
956
|
-
closeOnOverlay: true,
|
|
957
|
-
animationDuration: 300,
|
|
958
|
-
...config,
|
|
895
|
+
overlayColor: config.overlayColor ?? 0x000000,
|
|
896
|
+
overlayAlpha: config.overlayAlpha ?? 0.7,
|
|
897
|
+
closeOnOverlay: config.closeOnOverlay ?? true,
|
|
898
|
+
animationDuration: config.animationDuration ?? 300,
|
|
959
899
|
};
|
|
960
900
|
// Overlay
|
|
961
901
|
this._overlay = new Graphics();
|
|
@@ -1023,6 +963,8 @@ const TOAST_COLORS = {
|
|
|
1023
963
|
/**
|
|
1024
964
|
* Toast notification component for displaying transient messages.
|
|
1025
965
|
*
|
|
966
|
+
* Uses `@pixi/layout` LayoutContainer for auto-sized background.
|
|
967
|
+
*
|
|
1026
968
|
* @example
|
|
1027
969
|
* ```ts
|
|
1028
970
|
* const toast = new Toast();
|
|
@@ -1038,11 +980,10 @@ class Toast extends Container {
|
|
|
1038
980
|
constructor(config = {}) {
|
|
1039
981
|
super();
|
|
1040
982
|
this._config = {
|
|
1041
|
-
duration: 3000,
|
|
1042
|
-
bottomOffset: 60,
|
|
1043
|
-
...config,
|
|
983
|
+
duration: config.duration ?? 3000,
|
|
984
|
+
bottomOffset: config.bottomOffset ?? 60,
|
|
1044
985
|
};
|
|
1045
|
-
this._bg = new
|
|
986
|
+
this._bg = new LayoutContainer();
|
|
1046
987
|
this.addChild(this._bg);
|
|
1047
988
|
this._text = new Text({
|
|
1048
989
|
text: '',
|
|
@@ -1060,7 +1001,6 @@ class Toast extends Container {
|
|
|
1060
1001
|
* Show a toast message.
|
|
1061
1002
|
*/
|
|
1062
1003
|
async show(message, type = 'info', viewWidth, viewHeight) {
|
|
1063
|
-
// Clear previous dismiss
|
|
1064
1004
|
if (this._dismissTimeout) {
|
|
1065
1005
|
clearTimeout(this._dismissTimeout);
|
|
1066
1006
|
}
|
|
@@ -1069,10 +1009,16 @@ class Toast extends Container {
|
|
|
1069
1009
|
const width = Math.max(200, this._text.width + padding * 2);
|
|
1070
1010
|
const height = 44;
|
|
1071
1011
|
const radius = 8;
|
|
1072
|
-
|
|
1073
|
-
this._bg.
|
|
1074
|
-
|
|
1075
|
-
|
|
1012
|
+
// Style the background
|
|
1013
|
+
this._bg.layout = {
|
|
1014
|
+
width,
|
|
1015
|
+
height,
|
|
1016
|
+
borderRadius: radius,
|
|
1017
|
+
backgroundColor: TOAST_COLORS[type],
|
|
1018
|
+
};
|
|
1019
|
+
// Center the bg around origin
|
|
1020
|
+
this._bg.x = -width / 2;
|
|
1021
|
+
this._bg.y = -height / 2;
|
|
1076
1022
|
// Position
|
|
1077
1023
|
if (viewWidth && viewHeight) {
|
|
1078
1024
|
this.x = viewWidth / 2;
|
|
@@ -1081,9 +1027,7 @@ class Toast extends Container {
|
|
|
1081
1027
|
this.visible = true;
|
|
1082
1028
|
this.alpha = 0;
|
|
1083
1029
|
this.y += 20;
|
|
1084
|
-
// Animate in
|
|
1085
1030
|
await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
|
|
1086
|
-
// Auto-dismiss
|
|
1087
1031
|
if (this._config.duration > 0) {
|
|
1088
1032
|
this._dismissTimeout = setTimeout(() => {
|
|
1089
1033
|
this.dismiss();
|
|
@@ -1105,8 +1049,48 @@ class Toast extends Container {
|
|
|
1105
1049
|
}
|
|
1106
1050
|
}
|
|
1107
1051
|
|
|
1052
|
+
// ─── Helpers ─────────────────────────────────────────────
|
|
1053
|
+
const ALIGNMENT_MAP = {
|
|
1054
|
+
start: 'flex-start',
|
|
1055
|
+
center: 'center',
|
|
1056
|
+
end: 'flex-end',
|
|
1057
|
+
stretch: 'stretch',
|
|
1058
|
+
};
|
|
1059
|
+
function normalizePadding(padding) {
|
|
1060
|
+
if (typeof padding === 'number')
|
|
1061
|
+
return [padding, padding, padding, padding];
|
|
1062
|
+
return padding;
|
|
1063
|
+
}
|
|
1064
|
+
function directionToFlexStyles(direction, maxWidth) {
|
|
1065
|
+
switch (direction) {
|
|
1066
|
+
case 'horizontal':
|
|
1067
|
+
return { flexDirection: 'row', flexWrap: 'nowrap' };
|
|
1068
|
+
case 'vertical':
|
|
1069
|
+
return { flexDirection: 'column', flexWrap: 'nowrap' };
|
|
1070
|
+
case 'grid':
|
|
1071
|
+
return { flexDirection: 'row', flexWrap: 'wrap' };
|
|
1072
|
+
case 'wrap':
|
|
1073
|
+
return {
|
|
1074
|
+
flexDirection: 'row',
|
|
1075
|
+
flexWrap: 'wrap',
|
|
1076
|
+
...(maxWidth < Infinity ? { maxWidth } : {}),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
function buildLayoutStyles(config) {
|
|
1081
|
+
const [pt, pr, pb, pl] = config.padding;
|
|
1082
|
+
return {
|
|
1083
|
+
...directionToFlexStyles(config.direction, config.maxWidth),
|
|
1084
|
+
gap: config.gap,
|
|
1085
|
+
alignItems: ALIGNMENT_MAP[config.alignment],
|
|
1086
|
+
paddingTop: pt,
|
|
1087
|
+
paddingRight: pr,
|
|
1088
|
+
paddingBottom: pb,
|
|
1089
|
+
paddingLeft: pl,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1108
1092
|
/**
|
|
1109
|
-
* Responsive layout container
|
|
1093
|
+
* Responsive layout container powered by `@pixi/layout` (Yoga flexbox engine).
|
|
1110
1094
|
*
|
|
1111
1095
|
* Supports horizontal, vertical, grid, and wrap layout modes with
|
|
1112
1096
|
* alignment, padding, gap, and viewport-anchor positioning.
|
|
@@ -1129,47 +1113,44 @@ class Toast extends Container {
|
|
|
1129
1113
|
* toolbar.addItem(betLabel);
|
|
1130
1114
|
* scene.container.addChild(toolbar);
|
|
1131
1115
|
*
|
|
1132
|
-
* // On resize, update layout position relative to viewport
|
|
1133
1116
|
* toolbar.updateViewport(width, height);
|
|
1134
1117
|
* ```
|
|
1135
1118
|
*/
|
|
1136
1119
|
class Layout extends Container {
|
|
1137
|
-
|
|
1120
|
+
_layoutConfig;
|
|
1138
1121
|
_padding;
|
|
1139
1122
|
_anchor;
|
|
1140
1123
|
_maxWidth;
|
|
1141
1124
|
_breakpoints;
|
|
1142
|
-
_content;
|
|
1143
1125
|
_items = [];
|
|
1144
1126
|
_viewportWidth = 0;
|
|
1145
1127
|
_viewportHeight = 0;
|
|
1146
1128
|
constructor(config = {}) {
|
|
1147
1129
|
super();
|
|
1148
|
-
this.
|
|
1130
|
+
this._layoutConfig = {
|
|
1149
1131
|
direction: config.direction ?? 'vertical',
|
|
1150
1132
|
gap: config.gap ?? 0,
|
|
1151
1133
|
alignment: config.alignment ?? 'start',
|
|
1152
1134
|
autoLayout: config.autoLayout ?? true,
|
|
1153
1135
|
columns: config.columns ?? 2,
|
|
1154
1136
|
};
|
|
1155
|
-
this._padding =
|
|
1137
|
+
this._padding = normalizePadding(config.padding ?? 0);
|
|
1156
1138
|
this._anchor = config.anchor ?? 'top-left';
|
|
1157
1139
|
this._maxWidth = config.maxWidth ?? Infinity;
|
|
1158
|
-
// Sort breakpoints by width ascending for correct resolution
|
|
1159
1140
|
this._breakpoints = config.breakpoints
|
|
1160
1141
|
? Object.entries(config.breakpoints)
|
|
1161
1142
|
.map(([w, cfg]) => [Number(w), cfg])
|
|
1162
1143
|
.sort((a, b) => a[0] - b[0])
|
|
1163
1144
|
: [];
|
|
1164
|
-
this.
|
|
1165
|
-
this.addChild(this._content);
|
|
1145
|
+
this.applyLayoutStyles();
|
|
1166
1146
|
}
|
|
1167
1147
|
/** Add an item to the layout */
|
|
1168
1148
|
addItem(child) {
|
|
1169
1149
|
this._items.push(child);
|
|
1170
|
-
this.
|
|
1171
|
-
if (this.
|
|
1172
|
-
this.
|
|
1150
|
+
this.addChild(child);
|
|
1151
|
+
if (this._layoutConfig.direction === 'grid') {
|
|
1152
|
+
this.applyGridChildWidth(child);
|
|
1153
|
+
}
|
|
1173
1154
|
return this;
|
|
1174
1155
|
}
|
|
1175
1156
|
/** Remove an item from the layout */
|
|
@@ -1177,20 +1158,16 @@ class Layout extends Container {
|
|
|
1177
1158
|
const idx = this._items.indexOf(child);
|
|
1178
1159
|
if (idx !== -1) {
|
|
1179
1160
|
this._items.splice(idx, 1);
|
|
1180
|
-
this.
|
|
1181
|
-
if (this._config.autoLayout)
|
|
1182
|
-
this.layout();
|
|
1161
|
+
this.removeChild(child);
|
|
1183
1162
|
}
|
|
1184
1163
|
return this;
|
|
1185
1164
|
}
|
|
1186
1165
|
/** Remove all items */
|
|
1187
1166
|
clearItems() {
|
|
1188
1167
|
for (const item of this._items) {
|
|
1189
|
-
this.
|
|
1168
|
+
this.removeChild(item);
|
|
1190
1169
|
}
|
|
1191
1170
|
this._items.length = 0;
|
|
1192
|
-
if (this._config.autoLayout)
|
|
1193
|
-
this.layout();
|
|
1194
1171
|
return this;
|
|
1195
1172
|
}
|
|
1196
1173
|
/** Get all layout items */
|
|
@@ -1204,129 +1181,49 @@ class Layout extends Container {
|
|
|
1204
1181
|
updateViewport(width, height) {
|
|
1205
1182
|
this._viewportWidth = width;
|
|
1206
1183
|
this._viewportHeight = height;
|
|
1207
|
-
this.
|
|
1184
|
+
this.applyLayoutStyles();
|
|
1185
|
+
this.applyAnchor();
|
|
1208
1186
|
}
|
|
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)
|
|
1187
|
+
applyLayoutStyles() {
|
|
1216
1188
|
const effective = this.resolveConfig();
|
|
1217
|
-
const
|
|
1218
|
-
const
|
|
1219
|
-
const alignment = effective.alignment ?? this.
|
|
1220
|
-
|
|
1189
|
+
const direction = effective.direction ?? this._layoutConfig.direction;
|
|
1190
|
+
const gap = effective.gap ?? this._layoutConfig.gap;
|
|
1191
|
+
const alignment = effective.alignment ?? this._layoutConfig.alignment;
|
|
1192
|
+
effective.columns ?? this._layoutConfig.columns;
|
|
1221
1193
|
const padding = effective.padding !== undefined
|
|
1222
|
-
?
|
|
1194
|
+
? normalizePadding(effective.padding)
|
|
1223
1195
|
: this._padding;
|
|
1224
1196
|
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;
|
|
1197
|
+
const styles = buildLayoutStyles({ direction, gap, alignment, padding, maxWidth });
|
|
1198
|
+
this.layout = styles;
|
|
1199
|
+
if (direction === 'grid') {
|
|
1200
|
+
for (const item of this._items) {
|
|
1201
|
+
this.applyGridChildWidth(item);
|
|
1268
1202
|
}
|
|
1269
|
-
const mainSize = mainAxis === 'x' ? size.width : size.height;
|
|
1270
|
-
pos += mainSize + gap;
|
|
1271
1203
|
}
|
|
1272
1204
|
}
|
|
1273
|
-
|
|
1274
|
-
const
|
|
1275
|
-
const
|
|
1276
|
-
const
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
for (let i = 0; i < this._items.length; i++) {
|
|
1280
|
-
const item = this._items[i];
|
|
1281
|
-
const col = i % columns;
|
|
1282
|
-
const row = Math.floor(i / columns);
|
|
1283
|
-
const size = sizes[i];
|
|
1284
|
-
// X alignment within cell
|
|
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;
|
|
1205
|
+
applyGridChildWidth(child) {
|
|
1206
|
+
const effective = this.resolveConfig();
|
|
1207
|
+
const columns = effective.columns ?? this._layoutConfig.columns;
|
|
1208
|
+
const pct = `${(100 / columns).toFixed(2)}%`;
|
|
1209
|
+
if (child._layout) {
|
|
1210
|
+
child._layout.setStyle({ width: pct });
|
|
1296
1211
|
}
|
|
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);
|
|
1212
|
+
else {
|
|
1213
|
+
child.layout = { width: pct };
|
|
1317
1214
|
}
|
|
1318
1215
|
}
|
|
1319
|
-
applyAnchor(
|
|
1216
|
+
applyAnchor() {
|
|
1217
|
+
const anchor = this.resolveConfig().anchor ?? this._anchor;
|
|
1320
1218
|
if (this._viewportWidth === 0 || this._viewportHeight === 0)
|
|
1321
1219
|
return;
|
|
1322
|
-
const bounds = this.
|
|
1323
|
-
const contentW = bounds.width;
|
|
1324
|
-
const contentH = bounds.height;
|
|
1220
|
+
const bounds = this.getLocalBounds();
|
|
1221
|
+
const contentW = bounds.width * this.scale.x;
|
|
1222
|
+
const contentH = bounds.height * this.scale.y;
|
|
1325
1223
|
const vw = this._viewportWidth;
|
|
1326
1224
|
const vh = this._viewportHeight;
|
|
1327
1225
|
let anchorX = 0;
|
|
1328
1226
|
let anchorY = 0;
|
|
1329
|
-
// Horizontal
|
|
1330
1227
|
if (anchor.includes('left')) {
|
|
1331
1228
|
anchorX = 0;
|
|
1332
1229
|
}
|
|
@@ -1334,10 +1231,8 @@ class Layout extends Container {
|
|
|
1334
1231
|
anchorX = vw - contentW;
|
|
1335
1232
|
}
|
|
1336
1233
|
else {
|
|
1337
|
-
// center
|
|
1338
1234
|
anchorX = (vw - contentW) / 2;
|
|
1339
1235
|
}
|
|
1340
|
-
// Vertical
|
|
1341
1236
|
if (anchor.startsWith('top')) {
|
|
1342
1237
|
anchorY = 0;
|
|
1343
1238
|
}
|
|
@@ -1345,44 +1240,34 @@ class Layout extends Container {
|
|
|
1345
1240
|
anchorY = vh - contentH;
|
|
1346
1241
|
}
|
|
1347
1242
|
else {
|
|
1348
|
-
// center
|
|
1349
1243
|
anchorY = (vh - contentH) / 2;
|
|
1350
1244
|
}
|
|
1351
|
-
|
|
1352
|
-
this.
|
|
1353
|
-
this.y = anchorY - bounds.y;
|
|
1245
|
+
this.x = anchorX - bounds.x * this.scale.x;
|
|
1246
|
+
this.y = anchorY - bounds.y * this.scale.y;
|
|
1354
1247
|
}
|
|
1355
1248
|
resolveConfig() {
|
|
1356
1249
|
if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
|
|
1357
1250
|
return {};
|
|
1358
1251
|
}
|
|
1359
|
-
// Find the largest breakpoint that's ≤ current viewport width
|
|
1360
|
-
let resolved = {};
|
|
1361
1252
|
for (const [maxWidth, overrides] of this._breakpoints) {
|
|
1362
1253
|
if (this._viewportWidth <= maxWidth) {
|
|
1363
|
-
|
|
1364
|
-
break;
|
|
1254
|
+
return overrides;
|
|
1365
1255
|
}
|
|
1366
1256
|
}
|
|
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;
|
|
1257
|
+
return {};
|
|
1378
1258
|
}
|
|
1379
1259
|
}
|
|
1380
1260
|
|
|
1261
|
+
const DIRECTION_MAP = {
|
|
1262
|
+
vertical: 'vertical',
|
|
1263
|
+
horizontal: 'horizontal',
|
|
1264
|
+
both: 'bidirectional',
|
|
1265
|
+
};
|
|
1381
1266
|
/**
|
|
1382
|
-
* Scrollable container
|
|
1267
|
+
* Scrollable container powered by `@pixi/ui` ScrollBox.
|
|
1383
1268
|
*
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1269
|
+
* Provides touch/drag scrolling, mouse wheel support, inertia, and
|
|
1270
|
+
* dynamic rendering optimization for off-screen items.
|
|
1386
1271
|
*
|
|
1387
1272
|
* @example
|
|
1388
1273
|
* ```ts
|
|
@@ -1390,435 +1275,63 @@ class Layout extends Container {
|
|
|
1390
1275
|
* width: 600,
|
|
1391
1276
|
* height: 400,
|
|
1392
1277
|
* direction: 'vertical',
|
|
1393
|
-
*
|
|
1394
|
-
* elasticity: 0.3,
|
|
1278
|
+
* elementsMargin: 8,
|
|
1395
1279
|
* });
|
|
1396
1280
|
*
|
|
1397
|
-
* // Add content taller than 400px
|
|
1398
|
-
* const list = new Container();
|
|
1399
1281
|
* for (let i = 0; i < 50; i++) {
|
|
1400
|
-
*
|
|
1401
|
-
* row.y = i * 40;
|
|
1402
|
-
* list.addChild(row);
|
|
1282
|
+
* scroll.addItem(createRow(i));
|
|
1403
1283
|
* }
|
|
1404
|
-
* scroll.setContent(list);
|
|
1405
1284
|
*
|
|
1406
1285
|
* scene.container.addChild(scroll);
|
|
1407
1286
|
* ```
|
|
1408
1287
|
*/
|
|
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;
|
|
1288
|
+
class ScrollContainer extends ScrollBox {
|
|
1289
|
+
_scrollConfig;
|
|
1430
1290
|
constructor(config) {
|
|
1431
|
-
|
|
1432
|
-
this._config = {
|
|
1291
|
+
const options = {
|
|
1433
1292
|
width: config.width,
|
|
1434
1293
|
height: config.height,
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
snapSize: config.snapSize ?? 0,
|
|
1443
|
-
borderRadius: config.borderRadius ?? 0,
|
|
1294
|
+
type: DIRECTION_MAP[config.direction ?? 'vertical'],
|
|
1295
|
+
radius: config.borderRadius ?? 0,
|
|
1296
|
+
elementsMargin: config.elementsMargin ?? 0,
|
|
1297
|
+
padding: config.padding ?? 0,
|
|
1298
|
+
disableDynamicRendering: config.disableDynamicRendering ?? false,
|
|
1299
|
+
disableEasing: config.disableEasing ?? false,
|
|
1300
|
+
globalScroll: config.globalScroll ?? true,
|
|
1444
1301
|
};
|
|
1445
|
-
// Background
|
|
1446
|
-
this._bg = new Graphics();
|
|
1447
1302
|
if (config.backgroundColor !== undefined) {
|
|
1448
|
-
|
|
1449
|
-
.fill({ color: config.backgroundColor, alpha: config.backgroundAlpha ?? 1 });
|
|
1450
|
-
}
|
|
1451
|
-
this.addChild(this._bg);
|
|
1452
|
-
// Viewport (masked area)
|
|
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
|
-
}
|
|
1303
|
+
options.background = config.backgroundColor;
|
|
1473
1304
|
}
|
|
1474
|
-
|
|
1475
|
-
this.
|
|
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);
|
|
1305
|
+
super(options);
|
|
1306
|
+
this._scrollConfig = config;
|
|
1483
1307
|
}
|
|
1484
1308
|
/** Set scrollable content. Replaces any existing content. */
|
|
1485
1309
|
setContent(content) {
|
|
1486
|
-
|
|
1487
|
-
|
|
1310
|
+
// Remove existing items
|
|
1311
|
+
const existing = this.items;
|
|
1312
|
+
if (existing.length > 0) {
|
|
1313
|
+
for (let i = existing.length - 1; i >= 0; i--) {
|
|
1314
|
+
this.removeItem(i);
|
|
1315
|
+
}
|
|
1488
1316
|
}
|
|
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;
|
|
1317
|
+
// Add all children from the content container
|
|
1318
|
+
const children = [...content.children];
|
|
1319
|
+
if (children.length > 0) {
|
|
1320
|
+
this.addItems(children);
|
|
1507
1321
|
}
|
|
1508
|
-
|
|
1322
|
+
}
|
|
1323
|
+
/** Add a single item */
|
|
1324
|
+
addItem(...items) {
|
|
1325
|
+
this.addItems(items);
|
|
1326
|
+
return items[0];
|
|
1509
1327
|
}
|
|
1510
1328
|
/** Scroll to make a specific item/child visible */
|
|
1511
1329
|
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
|
-
}
|
|
1330
|
+
this.scrollTo(index);
|
|
1521
1331
|
}
|
|
1522
1332
|
/** Current scroll position */
|
|
1523
1333
|
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);
|
|
1334
|
+
return { x: this.scrollX, y: this.scrollY };
|
|
1822
1335
|
}
|
|
1823
1336
|
}
|
|
1824
1337
|
|