@energy8platform/game-engine 0.1.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 +1134 -0
- package/dist/animation.cjs.js +505 -0
- package/dist/animation.cjs.js.map +1 -0
- package/dist/animation.d.ts +235 -0
- package/dist/animation.esm.js +500 -0
- package/dist/animation.esm.js.map +1 -0
- package/dist/assets.cjs.js +148 -0
- package/dist/assets.cjs.js.map +1 -0
- package/dist/assets.d.ts +97 -0
- package/dist/assets.esm.js +146 -0
- package/dist/assets.esm.js.map +1 -0
- package/dist/audio.cjs.js +345 -0
- package/dist/audio.cjs.js.map +1 -0
- package/dist/audio.d.ts +135 -0
- package/dist/audio.esm.js +343 -0
- package/dist/audio.esm.js.map +1 -0
- package/dist/core.cjs.js +1832 -0
- package/dist/core.cjs.js.map +1 -0
- package/dist/core.d.ts +633 -0
- package/dist/core.esm.js +1827 -0
- package/dist/core.esm.js.map +1 -0
- package/dist/debug.cjs.js +298 -0
- package/dist/debug.cjs.js.map +1 -0
- package/dist/debug.d.ts +114 -0
- package/dist/debug.esm.js +295 -0
- package/dist/debug.esm.js.map +1 -0
- package/dist/index.cjs.js +3623 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +1607 -0
- package/dist/index.esm.js +3598 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/ui.cjs.js +1081 -0
- package/dist/ui.cjs.js.map +1 -0
- package/dist/ui.d.ts +387 -0
- package/dist/ui.esm.js +1072 -0
- package/dist/ui.esm.js.map +1 -0
- package/dist/vite.cjs.js +125 -0
- package/dist/vite.cjs.js.map +1 -0
- package/dist/vite.d.ts +42 -0
- package/dist/vite.esm.js +122 -0
- package/dist/vite.esm.js.map +1 -0
- package/package.json +107 -0
- package/src/animation/Easing.ts +116 -0
- package/src/animation/SpineHelper.ts +162 -0
- package/src/animation/Timeline.ts +138 -0
- package/src/animation/Tween.ts +225 -0
- package/src/animation/index.ts +4 -0
- package/src/assets/AssetManager.ts +174 -0
- package/src/assets/index.ts +2 -0
- package/src/audio/AudioManager.ts +366 -0
- package/src/audio/index.ts +1 -0
- package/src/core/EventEmitter.ts +47 -0
- package/src/core/GameApplication.ts +306 -0
- package/src/core/Scene.ts +48 -0
- package/src/core/SceneManager.ts +258 -0
- package/src/core/index.ts +4 -0
- package/src/debug/DevBridge.ts +259 -0
- package/src/debug/FPSOverlay.ts +102 -0
- package/src/debug/index.ts +3 -0
- package/src/index.ts +71 -0
- package/src/input/InputManager.ts +171 -0
- package/src/input/index.ts +1 -0
- package/src/loading/CSSPreloader.ts +155 -0
- package/src/loading/LoadingScene.ts +356 -0
- package/src/loading/index.ts +2 -0
- package/src/state/StateMachine.ts +228 -0
- package/src/state/index.ts +1 -0
- package/src/types.ts +218 -0
- package/src/ui/BalanceDisplay.ts +155 -0
- package/src/ui/Button.ts +199 -0
- package/src/ui/Label.ts +111 -0
- package/src/ui/Modal.ts +134 -0
- package/src/ui/Panel.ts +125 -0
- package/src/ui/ProgressBar.ts +121 -0
- package/src/ui/Toast.ts +124 -0
- package/src/ui/WinDisplay.ts +133 -0
- package/src/ui/index.ts +16 -0
- package/src/viewport/ViewportManager.ts +241 -0
- package/src/viewport/index.ts +1 -0
- package/src/vite/index.ts +153 -0
|
@@ -0,0 +1,3598 @@
|
|
|
1
|
+
import { Ticker, Assets, Container, Application, Graphics, Texture, Sprite, Text, NineSliceSprite } from 'pixi.js';
|
|
2
|
+
import { CasinoGameSDK } from '@energy8platform/game-sdk';
|
|
3
|
+
|
|
4
|
+
// ─── Scale Modes ───────────────────────────────────────────
|
|
5
|
+
var ScaleMode;
|
|
6
|
+
(function (ScaleMode) {
|
|
7
|
+
/** Fit inside container, maintain aspect ratio (letterbox/pillarbox) */
|
|
8
|
+
ScaleMode["FIT"] = "FIT";
|
|
9
|
+
/** Fill container, maintain aspect ratio (crop edges) */
|
|
10
|
+
ScaleMode["FILL"] = "FILL";
|
|
11
|
+
/** Stretch to fill (distorts) */
|
|
12
|
+
ScaleMode["STRETCH"] = "STRETCH";
|
|
13
|
+
})(ScaleMode || (ScaleMode = {}));
|
|
14
|
+
// ─── Orientation ───────────────────────────────────────────
|
|
15
|
+
var Orientation;
|
|
16
|
+
(function (Orientation) {
|
|
17
|
+
Orientation["LANDSCAPE"] = "landscape";
|
|
18
|
+
Orientation["PORTRAIT"] = "portrait";
|
|
19
|
+
Orientation["ANY"] = "any";
|
|
20
|
+
})(Orientation || (Orientation = {}));
|
|
21
|
+
// ─── Transition Types ──────────────────────────────────────
|
|
22
|
+
var TransitionType;
|
|
23
|
+
(function (TransitionType) {
|
|
24
|
+
TransitionType["NONE"] = "none";
|
|
25
|
+
TransitionType["FADE"] = "fade";
|
|
26
|
+
TransitionType["SLIDE_LEFT"] = "slide-left";
|
|
27
|
+
TransitionType["SLIDE_RIGHT"] = "slide-right";
|
|
28
|
+
})(TransitionType || (TransitionType = {}));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimal typed event emitter.
|
|
32
|
+
* Used internally by GameApplication, SceneManager, AudioManager, etc.
|
|
33
|
+
*/
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
35
|
+
class EventEmitter {
|
|
36
|
+
listeners = new Map();
|
|
37
|
+
on(event, handler) {
|
|
38
|
+
if (!this.listeners.has(event)) {
|
|
39
|
+
this.listeners.set(event, new Set());
|
|
40
|
+
}
|
|
41
|
+
this.listeners.get(event).add(handler);
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
once(event, handler) {
|
|
45
|
+
const wrapper = (data) => {
|
|
46
|
+
this.off(event, wrapper);
|
|
47
|
+
handler(data);
|
|
48
|
+
};
|
|
49
|
+
return this.on(event, wrapper);
|
|
50
|
+
}
|
|
51
|
+
off(event, handler) {
|
|
52
|
+
this.listeners.get(event)?.delete(handler);
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
emit(event, data) {
|
|
56
|
+
const handlers = this.listeners.get(event);
|
|
57
|
+
if (handlers) {
|
|
58
|
+
for (const handler of handlers) {
|
|
59
|
+
handler(data);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
removeAllListeners(event) {
|
|
64
|
+
if (event) {
|
|
65
|
+
this.listeners.delete(event);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
this.listeners.clear();
|
|
69
|
+
}
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Collection of easing functions for use with Tween and Timeline.
|
|
76
|
+
*
|
|
77
|
+
* All functions take a progress value t (0..1) and return the eased value.
|
|
78
|
+
*/
|
|
79
|
+
const Easing = {
|
|
80
|
+
linear: (t) => t,
|
|
81
|
+
easeInQuad: (t) => t * t,
|
|
82
|
+
easeOutQuad: (t) => t * (2 - t),
|
|
83
|
+
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
|
84
|
+
easeInCubic: (t) => t * t * t,
|
|
85
|
+
easeOutCubic: (t) => --t * t * t + 1,
|
|
86
|
+
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
|
|
87
|
+
easeInQuart: (t) => t * t * t * t,
|
|
88
|
+
easeOutQuart: (t) => 1 - --t * t * t * t,
|
|
89
|
+
easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
|
|
90
|
+
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
91
|
+
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
|
|
92
|
+
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
|
93
|
+
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
|
|
94
|
+
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
|
95
|
+
easeInOutExpo: (t) => t === 0
|
|
96
|
+
? 0
|
|
97
|
+
: t === 1
|
|
98
|
+
? 1
|
|
99
|
+
: t < 0.5
|
|
100
|
+
? Math.pow(2, 20 * t - 10) / 2
|
|
101
|
+
: (2 - Math.pow(2, -20 * t + 10)) / 2,
|
|
102
|
+
easeInBack: (t) => {
|
|
103
|
+
const c1 = 1.70158;
|
|
104
|
+
const c3 = c1 + 1;
|
|
105
|
+
return c3 * t * t * t - c1 * t * t;
|
|
106
|
+
},
|
|
107
|
+
easeOutBack: (t) => {
|
|
108
|
+
const c1 = 1.70158;
|
|
109
|
+
const c3 = c1 + 1;
|
|
110
|
+
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
111
|
+
},
|
|
112
|
+
easeInOutBack: (t) => {
|
|
113
|
+
const c1 = 1.70158;
|
|
114
|
+
const c2 = c1 * 1.525;
|
|
115
|
+
return t < 0.5
|
|
116
|
+
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
|
117
|
+
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
|
|
118
|
+
},
|
|
119
|
+
easeOutBounce: (t) => {
|
|
120
|
+
const n1 = 7.5625;
|
|
121
|
+
const d1 = 2.75;
|
|
122
|
+
if (t < 1 / d1)
|
|
123
|
+
return n1 * t * t;
|
|
124
|
+
if (t < 2 / d1)
|
|
125
|
+
return n1 * (t -= 1.5 / d1) * t + 0.75;
|
|
126
|
+
if (t < 2.5 / d1)
|
|
127
|
+
return n1 * (t -= 2.25 / d1) * t + 0.9375;
|
|
128
|
+
return n1 * (t -= 2.625 / d1) * t + 0.984375;
|
|
129
|
+
},
|
|
130
|
+
easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
|
|
131
|
+
easeInOutBounce: (t) => t < 0.5
|
|
132
|
+
? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
|
|
133
|
+
: (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
|
|
134
|
+
easeOutElastic: (t) => {
|
|
135
|
+
const c4 = (2 * Math.PI) / 3;
|
|
136
|
+
return t === 0
|
|
137
|
+
? 0
|
|
138
|
+
: t === 1
|
|
139
|
+
? 1
|
|
140
|
+
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
|
141
|
+
},
|
|
142
|
+
easeInElastic: (t) => {
|
|
143
|
+
const c4 = (2 * Math.PI) / 3;
|
|
144
|
+
return t === 0
|
|
145
|
+
? 0
|
|
146
|
+
: t === 1
|
|
147
|
+
? 1
|
|
148
|
+
: -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Lightweight tween system integrated with PixiJS Ticker.
|
|
154
|
+
* Zero external dependencies — no GSAP required.
|
|
155
|
+
*
|
|
156
|
+
* All tweens return a Promise that resolves on completion.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* // Fade in a sprite
|
|
161
|
+
* await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
|
|
162
|
+
*
|
|
163
|
+
* // Move and wait
|
|
164
|
+
* await Tween.to(sprite, { x: 500 }, 300);
|
|
165
|
+
*
|
|
166
|
+
* // From a starting value
|
|
167
|
+
* await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
class Tween {
|
|
171
|
+
static _tweens = [];
|
|
172
|
+
static _tickerAdded = false;
|
|
173
|
+
/**
|
|
174
|
+
* Animate properties from current values to target values.
|
|
175
|
+
*
|
|
176
|
+
* @param target - Object to animate (Sprite, Container, etc.)
|
|
177
|
+
* @param props - Target property values
|
|
178
|
+
* @param duration - Duration in milliseconds
|
|
179
|
+
* @param easing - Easing function (default: easeOutQuad)
|
|
180
|
+
* @param onUpdate - Progress callback (0..1)
|
|
181
|
+
*/
|
|
182
|
+
static to(target, props, duration, easing, onUpdate) {
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
// Capture starting values
|
|
185
|
+
const from = {};
|
|
186
|
+
for (const key of Object.keys(props)) {
|
|
187
|
+
from[key] = Tween.getProperty(target, key);
|
|
188
|
+
}
|
|
189
|
+
const tween = {
|
|
190
|
+
target,
|
|
191
|
+
from,
|
|
192
|
+
to: { ...props },
|
|
193
|
+
duration: Math.max(1, duration),
|
|
194
|
+
easing: easing ?? Easing.easeOutQuad,
|
|
195
|
+
elapsed: 0,
|
|
196
|
+
delay: 0,
|
|
197
|
+
resolve,
|
|
198
|
+
onUpdate,
|
|
199
|
+
};
|
|
200
|
+
Tween._tweens.push(tween);
|
|
201
|
+
Tween.ensureTicker();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Animate properties from given values to current values.
|
|
206
|
+
*/
|
|
207
|
+
static from(target, props, duration, easing, onUpdate) {
|
|
208
|
+
// Capture current values as "to"
|
|
209
|
+
const to = {};
|
|
210
|
+
for (const key of Object.keys(props)) {
|
|
211
|
+
to[key] = Tween.getProperty(target, key);
|
|
212
|
+
Tween.setProperty(target, key, props[key]);
|
|
213
|
+
}
|
|
214
|
+
return Tween.to(target, to, duration, easing, onUpdate);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Animate from one set of values to another.
|
|
218
|
+
*/
|
|
219
|
+
static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
|
|
220
|
+
// Set starting values
|
|
221
|
+
for (const key of Object.keys(fromProps)) {
|
|
222
|
+
Tween.setProperty(target, key, fromProps[key]);
|
|
223
|
+
}
|
|
224
|
+
return Tween.to(target, toProps, duration, easing, onUpdate);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Wait for a given duration (useful in timelines).
|
|
228
|
+
*/
|
|
229
|
+
static delay(ms) {
|
|
230
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Kill all tweens on a target.
|
|
234
|
+
*/
|
|
235
|
+
static killTweensOf(target) {
|
|
236
|
+
Tween._tweens = Tween._tweens.filter((tw) => {
|
|
237
|
+
if (tw.target === target) {
|
|
238
|
+
tw.resolve();
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Kill all active tweens.
|
|
246
|
+
*/
|
|
247
|
+
static killAll() {
|
|
248
|
+
for (const tw of Tween._tweens) {
|
|
249
|
+
tw.resolve();
|
|
250
|
+
}
|
|
251
|
+
Tween._tweens.length = 0;
|
|
252
|
+
}
|
|
253
|
+
/** Number of active tweens */
|
|
254
|
+
static get activeTweens() {
|
|
255
|
+
return Tween._tweens.length;
|
|
256
|
+
}
|
|
257
|
+
// ─── Internal ──────────────────────────────────────────
|
|
258
|
+
static ensureTicker() {
|
|
259
|
+
if (Tween._tickerAdded)
|
|
260
|
+
return;
|
|
261
|
+
Tween._tickerAdded = true;
|
|
262
|
+
Ticker.shared.add(Tween.tick);
|
|
263
|
+
}
|
|
264
|
+
static tick = (ticker) => {
|
|
265
|
+
const dt = ticker.deltaMS;
|
|
266
|
+
const completed = [];
|
|
267
|
+
for (const tw of Tween._tweens) {
|
|
268
|
+
tw.elapsed += dt;
|
|
269
|
+
if (tw.elapsed < tw.delay)
|
|
270
|
+
continue;
|
|
271
|
+
const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
|
|
272
|
+
const t = tw.easing(raw);
|
|
273
|
+
// Interpolate each property
|
|
274
|
+
for (const key of Object.keys(tw.to)) {
|
|
275
|
+
const start = tw.from[key];
|
|
276
|
+
const end = tw.to[key];
|
|
277
|
+
const value = start + (end - start) * t;
|
|
278
|
+
Tween.setProperty(tw.target, key, value);
|
|
279
|
+
}
|
|
280
|
+
tw.onUpdate?.(raw);
|
|
281
|
+
if (raw >= 1) {
|
|
282
|
+
completed.push(tw);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Remove completed tweens
|
|
286
|
+
for (const tw of completed) {
|
|
287
|
+
const idx = Tween._tweens.indexOf(tw);
|
|
288
|
+
if (idx !== -1)
|
|
289
|
+
Tween._tweens.splice(idx, 1);
|
|
290
|
+
tw.resolve();
|
|
291
|
+
}
|
|
292
|
+
// Remove ticker when no active tweens
|
|
293
|
+
if (Tween._tweens.length === 0 && Tween._tickerAdded) {
|
|
294
|
+
Ticker.shared.remove(Tween.tick);
|
|
295
|
+
Tween._tickerAdded = false;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
|
|
300
|
+
*/
|
|
301
|
+
static getProperty(target, key) {
|
|
302
|
+
const parts = key.split('.');
|
|
303
|
+
let obj = target;
|
|
304
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
305
|
+
obj = obj[parts[i]];
|
|
306
|
+
}
|
|
307
|
+
return obj[parts[parts.length - 1]] ?? 0;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Set a potentially nested property.
|
|
311
|
+
*/
|
|
312
|
+
static setProperty(target, key, value) {
|
|
313
|
+
const parts = key.split('.');
|
|
314
|
+
let obj = target;
|
|
315
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
316
|
+
obj = obj[parts[i]];
|
|
317
|
+
}
|
|
318
|
+
obj[parts[parts.length - 1]] = value;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Manages the scene stack and transitions between scenes.
|
|
324
|
+
*
|
|
325
|
+
* @example
|
|
326
|
+
* ```ts
|
|
327
|
+
* const scenes = new SceneManager(app.stage);
|
|
328
|
+
* scenes.register('loading', LoadingScene);
|
|
329
|
+
* scenes.register('game', GameScene);
|
|
330
|
+
* await scenes.goto('loading');
|
|
331
|
+
* ```
|
|
332
|
+
*/
|
|
333
|
+
class SceneManager extends EventEmitter {
|
|
334
|
+
/** Root container that scenes are added to */
|
|
335
|
+
root;
|
|
336
|
+
registry = new Map();
|
|
337
|
+
stack = [];
|
|
338
|
+
_transitioning = false;
|
|
339
|
+
/** Current viewport dimensions — set by ViewportManager */
|
|
340
|
+
_width = 0;
|
|
341
|
+
_height = 0;
|
|
342
|
+
constructor(root) {
|
|
343
|
+
super();
|
|
344
|
+
if (root)
|
|
345
|
+
this.root = root;
|
|
346
|
+
}
|
|
347
|
+
/** @internal Set the root container (called by GameApplication after PixiJS init) */
|
|
348
|
+
setRoot(root) {
|
|
349
|
+
this.root = root;
|
|
350
|
+
}
|
|
351
|
+
/** Register a scene class by key */
|
|
352
|
+
register(key, ctor) {
|
|
353
|
+
this.registry.set(key, ctor);
|
|
354
|
+
return this;
|
|
355
|
+
}
|
|
356
|
+
/** Get the current (topmost) scene entry */
|
|
357
|
+
get current() {
|
|
358
|
+
return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null;
|
|
359
|
+
}
|
|
360
|
+
/** Get the current scene key */
|
|
361
|
+
get currentKey() {
|
|
362
|
+
return this.current?.key ?? null;
|
|
363
|
+
}
|
|
364
|
+
/** Whether a scene transition is in progress */
|
|
365
|
+
get isTransitioning() {
|
|
366
|
+
return this._transitioning;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Navigate to a scene, replacing the entire stack.
|
|
370
|
+
*/
|
|
371
|
+
async goto(key, data, transition) {
|
|
372
|
+
const prevKey = this.currentKey;
|
|
373
|
+
// Exit all current scenes
|
|
374
|
+
while (this.stack.length > 0) {
|
|
375
|
+
await this.popInternal(false);
|
|
376
|
+
}
|
|
377
|
+
// Enter new scene
|
|
378
|
+
await this.pushInternal(key, data, transition);
|
|
379
|
+
this.emit('change', { from: prevKey, to: key });
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Push a scene onto the stack (the previous scene stays underneath).
|
|
383
|
+
* Useful for overlays, modals, pause screens.
|
|
384
|
+
*/
|
|
385
|
+
async push(key, data, transition) {
|
|
386
|
+
const prevKey = this.currentKey;
|
|
387
|
+
await this.pushInternal(key, data, transition);
|
|
388
|
+
this.emit('change', { from: prevKey, to: key });
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Pop the top scene from the stack.
|
|
392
|
+
*/
|
|
393
|
+
async pop(transition) {
|
|
394
|
+
if (this.stack.length <= 1) {
|
|
395
|
+
console.warn('[SceneManager] Cannot pop the last scene');
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const prevKey = this.currentKey;
|
|
399
|
+
await this.popInternal(true, transition);
|
|
400
|
+
this.emit('change', { from: prevKey, to: this.currentKey });
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Replace the top scene with a new one.
|
|
404
|
+
*/
|
|
405
|
+
async replace(key, data, transition) {
|
|
406
|
+
const prevKey = this.currentKey;
|
|
407
|
+
await this.popInternal(false);
|
|
408
|
+
await this.pushInternal(key, data, transition);
|
|
409
|
+
this.emit('change', { from: prevKey, to: key });
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Called every frame by GameApplication.
|
|
413
|
+
*/
|
|
414
|
+
update(dt) {
|
|
415
|
+
// Update only the top scene
|
|
416
|
+
this.current?.scene.onUpdate?.(dt);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Called on viewport resize.
|
|
420
|
+
*/
|
|
421
|
+
resize(width, height) {
|
|
422
|
+
this._width = width;
|
|
423
|
+
this._height = height;
|
|
424
|
+
// Notify all scenes in the stack
|
|
425
|
+
for (const entry of this.stack) {
|
|
426
|
+
entry.scene.onResize?.(width, height);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Destroy all scenes and clear the manager.
|
|
431
|
+
*/
|
|
432
|
+
destroy() {
|
|
433
|
+
for (const entry of this.stack) {
|
|
434
|
+
entry.scene.onDestroy?.();
|
|
435
|
+
entry.scene.container.destroy({ children: true });
|
|
436
|
+
}
|
|
437
|
+
this.stack.length = 0;
|
|
438
|
+
this.registry.clear();
|
|
439
|
+
this.removeAllListeners();
|
|
440
|
+
}
|
|
441
|
+
// ─── Internal ──────────────────────────────────────────
|
|
442
|
+
createScene(key) {
|
|
443
|
+
const Ctor = this.registry.get(key);
|
|
444
|
+
if (!Ctor) {
|
|
445
|
+
throw new Error(`[SceneManager] Scene "${key}" is not registered`);
|
|
446
|
+
}
|
|
447
|
+
return new Ctor();
|
|
448
|
+
}
|
|
449
|
+
async pushInternal(key, data, transition) {
|
|
450
|
+
this._transitioning = true;
|
|
451
|
+
const scene = this.createScene(key);
|
|
452
|
+
this.root.addChild(scene.container);
|
|
453
|
+
// Set initial size
|
|
454
|
+
if (this._width && this._height) {
|
|
455
|
+
scene.onResize?.(this._width, this._height);
|
|
456
|
+
}
|
|
457
|
+
// Transition in
|
|
458
|
+
await this.transitionIn(scene.container, transition);
|
|
459
|
+
await scene.onEnter?.(data);
|
|
460
|
+
this.stack.push({ scene, key });
|
|
461
|
+
this._transitioning = false;
|
|
462
|
+
}
|
|
463
|
+
async popInternal(showTransition, transition) {
|
|
464
|
+
const entry = this.stack.pop();
|
|
465
|
+
if (!entry)
|
|
466
|
+
return;
|
|
467
|
+
this._transitioning = true;
|
|
468
|
+
await entry.scene.onExit?.();
|
|
469
|
+
if (showTransition) {
|
|
470
|
+
await this.transitionOut(entry.scene.container, transition);
|
|
471
|
+
}
|
|
472
|
+
entry.scene.onDestroy?.();
|
|
473
|
+
entry.scene.container.destroy({ children: true });
|
|
474
|
+
this._transitioning = false;
|
|
475
|
+
}
|
|
476
|
+
async transitionIn(container, config) {
|
|
477
|
+
const type = config?.type ?? TransitionType.NONE;
|
|
478
|
+
const duration = config?.duration ?? 300;
|
|
479
|
+
if (type === TransitionType.NONE || duration <= 0)
|
|
480
|
+
return;
|
|
481
|
+
if (type === TransitionType.FADE) {
|
|
482
|
+
container.alpha = 0;
|
|
483
|
+
await Tween.to(container, { alpha: 1 }, duration, config?.easing);
|
|
484
|
+
}
|
|
485
|
+
else if (type === TransitionType.SLIDE_LEFT) {
|
|
486
|
+
container.x = this._width;
|
|
487
|
+
await Tween.to(container, { x: 0 }, duration, config?.easing);
|
|
488
|
+
}
|
|
489
|
+
else if (type === TransitionType.SLIDE_RIGHT) {
|
|
490
|
+
container.x = -this._width;
|
|
491
|
+
await Tween.to(container, { x: 0 }, duration, config?.easing);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async transitionOut(container, config) {
|
|
495
|
+
const type = config?.type ?? TransitionType.FADE;
|
|
496
|
+
const duration = config?.duration ?? 300;
|
|
497
|
+
if (type === TransitionType.NONE || duration <= 0)
|
|
498
|
+
return;
|
|
499
|
+
if (type === TransitionType.FADE) {
|
|
500
|
+
await Tween.to(container, { alpha: 0 }, duration, config?.easing);
|
|
501
|
+
}
|
|
502
|
+
else if (type === TransitionType.SLIDE_LEFT) {
|
|
503
|
+
await Tween.to(container, { x: -this._width }, duration, config?.easing);
|
|
504
|
+
}
|
|
505
|
+
else if (type === TransitionType.SLIDE_RIGHT) {
|
|
506
|
+
await Tween.to(container, { x: this._width }, duration, config?.easing);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Manages game asset loading with progress tracking, bundle support, and
|
|
513
|
+
* automatic base path resolution from SDK's assetsUrl.
|
|
514
|
+
*
|
|
515
|
+
* Wraps PixiJS Assets API with a typed, game-oriented interface.
|
|
516
|
+
*
|
|
517
|
+
* @example
|
|
518
|
+
* ```ts
|
|
519
|
+
* const assets = new AssetManager('https://cdn.example.com/game/', manifest);
|
|
520
|
+
* await assets.init();
|
|
521
|
+
* await assets.loadBundle('preload', (p) => console.log(p));
|
|
522
|
+
* const texture = assets.get<Texture>('hero');
|
|
523
|
+
* ```
|
|
524
|
+
*/
|
|
525
|
+
class AssetManager {
|
|
526
|
+
_initialized = false;
|
|
527
|
+
_basePath;
|
|
528
|
+
_manifest;
|
|
529
|
+
_loadedBundles = new Set();
|
|
530
|
+
constructor(basePath = '', manifest) {
|
|
531
|
+
this._basePath = basePath;
|
|
532
|
+
this._manifest = manifest ?? null;
|
|
533
|
+
}
|
|
534
|
+
/** Whether the asset system has been initialized */
|
|
535
|
+
get initialized() {
|
|
536
|
+
return this._initialized;
|
|
537
|
+
}
|
|
538
|
+
/** Base path for all assets (usually from SDK's assetsUrl) */
|
|
539
|
+
get basePath() {
|
|
540
|
+
return this._basePath;
|
|
541
|
+
}
|
|
542
|
+
/** Set of loaded bundle names */
|
|
543
|
+
get loadedBundles() {
|
|
544
|
+
return this._loadedBundles;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Initialize the asset system.
|
|
548
|
+
* Must be called before loading any assets.
|
|
549
|
+
*/
|
|
550
|
+
async init() {
|
|
551
|
+
if (this._initialized)
|
|
552
|
+
return;
|
|
553
|
+
await Assets.init({
|
|
554
|
+
basePath: this._basePath || undefined,
|
|
555
|
+
texturePreference: {
|
|
556
|
+
resolution: Math.min(window.devicePixelRatio, 2),
|
|
557
|
+
format: ['webp', 'png'],
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
// Register bundles from manifest
|
|
561
|
+
if (this._manifest) {
|
|
562
|
+
for (const bundle of this._manifest.bundles) {
|
|
563
|
+
Assets.addBundle(bundle.name, bundle.assets.map((a) => ({
|
|
564
|
+
alias: a.alias,
|
|
565
|
+
src: a.src,
|
|
566
|
+
data: a.data,
|
|
567
|
+
})));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
this._initialized = true;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Load a single bundle by name.
|
|
574
|
+
*
|
|
575
|
+
* @param name - Bundle name (must exist in the manifest)
|
|
576
|
+
* @param onProgress - Progress callback (0..1)
|
|
577
|
+
* @returns Loaded assets map
|
|
578
|
+
*/
|
|
579
|
+
async loadBundle(name, onProgress) {
|
|
580
|
+
this.ensureInitialized();
|
|
581
|
+
const result = await Assets.loadBundle(name, onProgress);
|
|
582
|
+
this._loadedBundles.add(name);
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Load multiple bundles simultaneously.
|
|
587
|
+
* Progress is aggregated across all bundles.
|
|
588
|
+
*
|
|
589
|
+
* @param names - Bundle names
|
|
590
|
+
* @param onProgress - Progress callback (0..1)
|
|
591
|
+
*/
|
|
592
|
+
async loadBundles(names, onProgress) {
|
|
593
|
+
this.ensureInitialized();
|
|
594
|
+
const result = await Assets.loadBundle(names, onProgress);
|
|
595
|
+
for (const name of names) {
|
|
596
|
+
this._loadedBundles.add(name);
|
|
597
|
+
}
|
|
598
|
+
return result;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Load individual assets by URL or alias.
|
|
602
|
+
*
|
|
603
|
+
* @param urls - Asset URLs or aliases
|
|
604
|
+
* @param onProgress - Progress callback (0..1)
|
|
605
|
+
*/
|
|
606
|
+
async load(urls, onProgress) {
|
|
607
|
+
this.ensureInitialized();
|
|
608
|
+
return Assets.load(urls, onProgress);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Get a loaded asset synchronously from cache.
|
|
612
|
+
*
|
|
613
|
+
* @param alias - Asset alias
|
|
614
|
+
* @throws if not loaded
|
|
615
|
+
*/
|
|
616
|
+
get(alias) {
|
|
617
|
+
return Assets.get(alias);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Unload a bundle to free memory.
|
|
621
|
+
*/
|
|
622
|
+
async unloadBundle(name) {
|
|
623
|
+
await Assets.unloadBundle(name);
|
|
624
|
+
this._loadedBundles.delete(name);
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Start background loading a bundle (low-priority preload).
|
|
628
|
+
* Useful for loading bonus round assets while player is in base game.
|
|
629
|
+
*/
|
|
630
|
+
async backgroundLoad(name) {
|
|
631
|
+
this.ensureInitialized();
|
|
632
|
+
await Assets.backgroundLoadBundle(name);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Get all bundle names from the manifest.
|
|
636
|
+
*/
|
|
637
|
+
getBundleNames() {
|
|
638
|
+
return this._manifest?.bundles.map((b) => b.name) ?? [];
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Check if a bundle is loaded.
|
|
642
|
+
*/
|
|
643
|
+
isBundleLoaded(name) {
|
|
644
|
+
return this._loadedBundles.has(name);
|
|
645
|
+
}
|
|
646
|
+
ensureInitialized() {
|
|
647
|
+
if (!this._initialized) {
|
|
648
|
+
throw new Error('[AssetManager] Not initialized. Call init() first.');
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Manages all game audio: music, SFX, UI sounds, ambient.
|
|
655
|
+
*
|
|
656
|
+
* Optional dependency on @pixi/sound — if not installed, AudioManager
|
|
657
|
+
* operates as a silent no-op (graceful degradation).
|
|
658
|
+
*
|
|
659
|
+
* Features:
|
|
660
|
+
* - Per-category volume control (music, sfx, ui, ambient)
|
|
661
|
+
* - Music crossfade and looping
|
|
662
|
+
* - Mobile audio unlock on first interaction
|
|
663
|
+
* - Mute state persistence in localStorage
|
|
664
|
+
* - Global mute/unmute
|
|
665
|
+
*
|
|
666
|
+
* @example
|
|
667
|
+
* ```ts
|
|
668
|
+
* const audio = new AudioManager({ music: 0.5, sfx: 0.8 });
|
|
669
|
+
* await audio.init();
|
|
670
|
+
* audio.playMusic('bg-music');
|
|
671
|
+
* audio.play('spin-click', 'sfx');
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
674
|
+
class AudioManager {
|
|
675
|
+
_soundModule = null;
|
|
676
|
+
_initialized = false;
|
|
677
|
+
_globalMuted = false;
|
|
678
|
+
_persist;
|
|
679
|
+
_storageKey;
|
|
680
|
+
_categories;
|
|
681
|
+
_currentMusic = null;
|
|
682
|
+
_unlocked = false;
|
|
683
|
+
_unlockHandler = null;
|
|
684
|
+
constructor(config) {
|
|
685
|
+
this._persist = config?.persist ?? true;
|
|
686
|
+
this._storageKey = config?.storageKey ?? 'ge_audio';
|
|
687
|
+
this._categories = {
|
|
688
|
+
music: { volume: config?.music ?? 0.7, muted: false },
|
|
689
|
+
sfx: { volume: config?.sfx ?? 1.0, muted: false },
|
|
690
|
+
ui: { volume: config?.ui ?? 0.8, muted: false },
|
|
691
|
+
ambient: { volume: config?.ambient ?? 0.5, muted: false },
|
|
692
|
+
};
|
|
693
|
+
// Restore persisted state
|
|
694
|
+
if (this._persist) {
|
|
695
|
+
this.restoreState();
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/** Whether the audio system is initialized */
|
|
699
|
+
get initialized() {
|
|
700
|
+
return this._initialized;
|
|
701
|
+
}
|
|
702
|
+
/** Whether audio is globally muted */
|
|
703
|
+
get muted() {
|
|
704
|
+
return this._globalMuted;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Initialize the audio system.
|
|
708
|
+
* Dynamically imports @pixi/sound to keep it optional.
|
|
709
|
+
*/
|
|
710
|
+
async init() {
|
|
711
|
+
if (this._initialized)
|
|
712
|
+
return;
|
|
713
|
+
try {
|
|
714
|
+
this._soundModule = await import('@pixi/sound');
|
|
715
|
+
this._initialized = true;
|
|
716
|
+
this.applyVolumes();
|
|
717
|
+
this.setupMobileUnlock();
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
console.warn('[AudioManager] @pixi/sound not available. Audio disabled.');
|
|
721
|
+
this._initialized = false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Play a sound effect.
|
|
726
|
+
*
|
|
727
|
+
* @param alias - Sound alias (must be loaded via AssetManager)
|
|
728
|
+
* @param category - Audio category (default: 'sfx')
|
|
729
|
+
* @param options - Additional play options
|
|
730
|
+
*/
|
|
731
|
+
play(alias, category = 'sfx', options) {
|
|
732
|
+
if (!this._initialized || !this._soundModule)
|
|
733
|
+
return;
|
|
734
|
+
if (this._globalMuted || this._categories[category].muted)
|
|
735
|
+
return;
|
|
736
|
+
const { sound } = this._soundModule;
|
|
737
|
+
const vol = (options?.volume ?? 1) * this._categories[category].volume;
|
|
738
|
+
try {
|
|
739
|
+
sound.play(alias, {
|
|
740
|
+
volume: vol,
|
|
741
|
+
loop: options?.loop ?? false,
|
|
742
|
+
speed: options?.speed ?? 1,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
catch (e) {
|
|
746
|
+
console.warn(`[AudioManager] Failed to play "${alias}":`, e);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Play background music with optional crossfade.
|
|
751
|
+
*
|
|
752
|
+
* @param alias - Music alias
|
|
753
|
+
* @param fadeDuration - Crossfade duration in ms (default: 500)
|
|
754
|
+
*/
|
|
755
|
+
playMusic(alias, fadeDuration = 500) {
|
|
756
|
+
if (!this._initialized || !this._soundModule)
|
|
757
|
+
return;
|
|
758
|
+
const { sound } = this._soundModule;
|
|
759
|
+
// Stop current music
|
|
760
|
+
if (this._currentMusic) {
|
|
761
|
+
try {
|
|
762
|
+
sound.stop(this._currentMusic);
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
// ignore
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
this._currentMusic = alias;
|
|
769
|
+
if (this._globalMuted || this._categories.music.muted)
|
|
770
|
+
return;
|
|
771
|
+
try {
|
|
772
|
+
sound.play(alias, {
|
|
773
|
+
volume: this._categories.music.volume,
|
|
774
|
+
loop: true,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
catch (e) {
|
|
778
|
+
console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Stop current music.
|
|
783
|
+
*/
|
|
784
|
+
stopMusic() {
|
|
785
|
+
if (!this._initialized || !this._soundModule || !this._currentMusic)
|
|
786
|
+
return;
|
|
787
|
+
const { sound } = this._soundModule;
|
|
788
|
+
try {
|
|
789
|
+
sound.stop(this._currentMusic);
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
// ignore
|
|
793
|
+
}
|
|
794
|
+
this._currentMusic = null;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Stop all sounds.
|
|
798
|
+
*/
|
|
799
|
+
stopAll() {
|
|
800
|
+
if (!this._initialized || !this._soundModule)
|
|
801
|
+
return;
|
|
802
|
+
const { sound } = this._soundModule;
|
|
803
|
+
sound.stopAll();
|
|
804
|
+
this._currentMusic = null;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Set volume for a category.
|
|
808
|
+
*/
|
|
809
|
+
setVolume(category, volume) {
|
|
810
|
+
this._categories[category].volume = Math.max(0, Math.min(1, volume));
|
|
811
|
+
this.applyVolumes();
|
|
812
|
+
this.saveState();
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Get volume for a category.
|
|
816
|
+
*/
|
|
817
|
+
getVolume(category) {
|
|
818
|
+
return this._categories[category].volume;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Mute a specific category.
|
|
822
|
+
*/
|
|
823
|
+
muteCategory(category) {
|
|
824
|
+
this._categories[category].muted = true;
|
|
825
|
+
this.applyVolumes();
|
|
826
|
+
this.saveState();
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Unmute a specific category.
|
|
830
|
+
*/
|
|
831
|
+
unmuteCategory(category) {
|
|
832
|
+
this._categories[category].muted = false;
|
|
833
|
+
this.applyVolumes();
|
|
834
|
+
this.saveState();
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Toggle mute for a category.
|
|
838
|
+
*/
|
|
839
|
+
toggleCategory(category) {
|
|
840
|
+
this._categories[category].muted = !this._categories[category].muted;
|
|
841
|
+
this.applyVolumes();
|
|
842
|
+
this.saveState();
|
|
843
|
+
return this._categories[category].muted;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Mute all audio globally.
|
|
847
|
+
*/
|
|
848
|
+
muteAll() {
|
|
849
|
+
this._globalMuted = true;
|
|
850
|
+
if (this._soundModule) {
|
|
851
|
+
this._soundModule.sound.muteAll();
|
|
852
|
+
}
|
|
853
|
+
this.saveState();
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Unmute all audio globally.
|
|
857
|
+
*/
|
|
858
|
+
unmuteAll() {
|
|
859
|
+
this._globalMuted = false;
|
|
860
|
+
if (this._soundModule) {
|
|
861
|
+
this._soundModule.sound.unmuteAll();
|
|
862
|
+
}
|
|
863
|
+
this.saveState();
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Toggle global mute.
|
|
867
|
+
*/
|
|
868
|
+
toggleMute() {
|
|
869
|
+
if (this._globalMuted) {
|
|
870
|
+
this.unmuteAll();
|
|
871
|
+
}
|
|
872
|
+
else {
|
|
873
|
+
this.muteAll();
|
|
874
|
+
}
|
|
875
|
+
return this._globalMuted;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Duck music volume (e.g., during big win presentation).
|
|
879
|
+
*
|
|
880
|
+
* @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
|
|
881
|
+
*/
|
|
882
|
+
duckMusic(factor) {
|
|
883
|
+
if (!this._initialized || !this._soundModule || !this._currentMusic)
|
|
884
|
+
return;
|
|
885
|
+
const { sound } = this._soundModule;
|
|
886
|
+
const vol = this._categories.music.volume * factor;
|
|
887
|
+
try {
|
|
888
|
+
sound.volume(this._currentMusic, vol);
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
// ignore
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Restore music to normal volume after ducking.
|
|
896
|
+
*/
|
|
897
|
+
unduckMusic() {
|
|
898
|
+
if (!this._initialized || !this._soundModule || !this._currentMusic)
|
|
899
|
+
return;
|
|
900
|
+
const { sound } = this._soundModule;
|
|
901
|
+
try {
|
|
902
|
+
sound.volume(this._currentMusic, this._categories.music.volume);
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
// ignore
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Destroy the audio manager and free resources.
|
|
910
|
+
*/
|
|
911
|
+
destroy() {
|
|
912
|
+
this.stopAll();
|
|
913
|
+
this.removeMobileUnlock();
|
|
914
|
+
if (this._soundModule) {
|
|
915
|
+
this._soundModule.sound.removeAll();
|
|
916
|
+
}
|
|
917
|
+
this._initialized = false;
|
|
918
|
+
}
|
|
919
|
+
// ─── Private ───────────────────────────────────────────
|
|
920
|
+
applyVolumes() {
|
|
921
|
+
if (!this._soundModule)
|
|
922
|
+
return;
|
|
923
|
+
const { sound } = this._soundModule;
|
|
924
|
+
sound.volumeAll = this._globalMuted ? 0 : 1;
|
|
925
|
+
}
|
|
926
|
+
setupMobileUnlock() {
|
|
927
|
+
if (this._unlocked)
|
|
928
|
+
return;
|
|
929
|
+
this._unlockHandler = () => {
|
|
930
|
+
if (!this._soundModule)
|
|
931
|
+
return;
|
|
932
|
+
const { sound } = this._soundModule;
|
|
933
|
+
// Resume WebAudio context
|
|
934
|
+
if (sound.context?.audioContext?.state === 'suspended') {
|
|
935
|
+
sound.context.audioContext.resume();
|
|
936
|
+
}
|
|
937
|
+
this._unlocked = true;
|
|
938
|
+
this.removeMobileUnlock();
|
|
939
|
+
};
|
|
940
|
+
const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
|
|
941
|
+
for (const event of events) {
|
|
942
|
+
document.addEventListener(event, this._unlockHandler, { once: true });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
removeMobileUnlock() {
|
|
946
|
+
if (!this._unlockHandler)
|
|
947
|
+
return;
|
|
948
|
+
const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
|
|
949
|
+
for (const event of events) {
|
|
950
|
+
document.removeEventListener(event, this._unlockHandler);
|
|
951
|
+
}
|
|
952
|
+
this._unlockHandler = null;
|
|
953
|
+
}
|
|
954
|
+
saveState() {
|
|
955
|
+
if (!this._persist)
|
|
956
|
+
return;
|
|
957
|
+
try {
|
|
958
|
+
const state = {
|
|
959
|
+
globalMuted: this._globalMuted,
|
|
960
|
+
categories: this._categories,
|
|
961
|
+
};
|
|
962
|
+
localStorage.setItem(this._storageKey, JSON.stringify(state));
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
// localStorage may not be available
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
restoreState() {
|
|
969
|
+
try {
|
|
970
|
+
const raw = localStorage.getItem(this._storageKey);
|
|
971
|
+
if (!raw)
|
|
972
|
+
return;
|
|
973
|
+
const state = JSON.parse(raw);
|
|
974
|
+
if (typeof state.globalMuted === 'boolean') {
|
|
975
|
+
this._globalMuted = state.globalMuted;
|
|
976
|
+
}
|
|
977
|
+
if (state.categories) {
|
|
978
|
+
for (const key of ['music', 'sfx', 'ui', 'ambient']) {
|
|
979
|
+
if (state.categories[key]) {
|
|
980
|
+
this._categories[key] = {
|
|
981
|
+
volume: state.categories[key].volume ?? this._categories[key].volume,
|
|
982
|
+
muted: state.categories[key].muted ?? false,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
// ignore
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Manages responsive scaling of the game canvas to fit its container.
|
|
996
|
+
*
|
|
997
|
+
* Supports three scale modes:
|
|
998
|
+
* - **FIT** — letterbox/pillarbox to maintain aspect ratio (industry standard)
|
|
999
|
+
* - **FILL** — fill container, crop edges
|
|
1000
|
+
* - **STRETCH** — stretch to fill (distorts)
|
|
1001
|
+
*
|
|
1002
|
+
* Also handles:
|
|
1003
|
+
* - Orientation detection (landscape/portrait)
|
|
1004
|
+
* - Safe areas (mobile notch)
|
|
1005
|
+
* - ResizeObserver for smooth container resizing
|
|
1006
|
+
*
|
|
1007
|
+
* @example
|
|
1008
|
+
* ```ts
|
|
1009
|
+
* const viewport = new ViewportManager(app, container, {
|
|
1010
|
+
* designWidth: 1920,
|
|
1011
|
+
* designHeight: 1080,
|
|
1012
|
+
* scaleMode: ScaleMode.FIT,
|
|
1013
|
+
* orientation: Orientation.LANDSCAPE,
|
|
1014
|
+
* });
|
|
1015
|
+
*
|
|
1016
|
+
* viewport.on('resize', ({ width, height, scale }) => {
|
|
1017
|
+
* console.log(`New size: ${width}x${height} @ ${scale}x`);
|
|
1018
|
+
* });
|
|
1019
|
+
* ```
|
|
1020
|
+
*/
|
|
1021
|
+
class ViewportManager extends EventEmitter {
|
|
1022
|
+
_app;
|
|
1023
|
+
_container;
|
|
1024
|
+
_config;
|
|
1025
|
+
_resizeObserver = null;
|
|
1026
|
+
_currentOrientation = Orientation.LANDSCAPE;
|
|
1027
|
+
_currentWidth = 0;
|
|
1028
|
+
_currentHeight = 0;
|
|
1029
|
+
_currentScale = 1;
|
|
1030
|
+
_destroyed = false;
|
|
1031
|
+
_resizeTimeout = null;
|
|
1032
|
+
constructor(app, container, config) {
|
|
1033
|
+
super();
|
|
1034
|
+
this._app = app;
|
|
1035
|
+
this._container = container;
|
|
1036
|
+
this._config = config;
|
|
1037
|
+
this.setupObserver();
|
|
1038
|
+
}
|
|
1039
|
+
/** Current canvas width in game units */
|
|
1040
|
+
get width() {
|
|
1041
|
+
return this._currentWidth;
|
|
1042
|
+
}
|
|
1043
|
+
/** Current canvas height in game units */
|
|
1044
|
+
get height() {
|
|
1045
|
+
return this._currentHeight;
|
|
1046
|
+
}
|
|
1047
|
+
/** Current scale factor */
|
|
1048
|
+
get scale() {
|
|
1049
|
+
return this._currentScale;
|
|
1050
|
+
}
|
|
1051
|
+
/** Current orientation */
|
|
1052
|
+
get orientation() {
|
|
1053
|
+
return this._currentOrientation;
|
|
1054
|
+
}
|
|
1055
|
+
/** Design reference width */
|
|
1056
|
+
get designWidth() {
|
|
1057
|
+
return this._config.designWidth;
|
|
1058
|
+
}
|
|
1059
|
+
/** Design reference height */
|
|
1060
|
+
get designHeight() {
|
|
1061
|
+
return this._config.designHeight;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Force a resize calculation. Called automatically on container size change.
|
|
1065
|
+
*/
|
|
1066
|
+
refresh() {
|
|
1067
|
+
if (this._destroyed)
|
|
1068
|
+
return;
|
|
1069
|
+
const containerWidth = this._container.clientWidth || window.innerWidth;
|
|
1070
|
+
const containerHeight = this._container.clientHeight || window.innerHeight;
|
|
1071
|
+
if (containerWidth === 0 || containerHeight === 0)
|
|
1072
|
+
return;
|
|
1073
|
+
const { designWidth, designHeight, scaleMode } = this._config;
|
|
1074
|
+
const designRatio = designWidth / designHeight;
|
|
1075
|
+
const containerRatio = containerWidth / containerHeight;
|
|
1076
|
+
let gameWidth;
|
|
1077
|
+
let gameHeight;
|
|
1078
|
+
let scale;
|
|
1079
|
+
switch (scaleMode) {
|
|
1080
|
+
case ScaleMode.FIT: {
|
|
1081
|
+
if (containerRatio > designRatio) {
|
|
1082
|
+
// Container is wider → pillarbox
|
|
1083
|
+
scale = containerHeight / designHeight;
|
|
1084
|
+
gameWidth = designWidth;
|
|
1085
|
+
gameHeight = designHeight;
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
// Container is taller → letterbox
|
|
1089
|
+
scale = containerWidth / designWidth;
|
|
1090
|
+
gameWidth = designWidth;
|
|
1091
|
+
gameHeight = designHeight;
|
|
1092
|
+
}
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
case ScaleMode.FILL: {
|
|
1096
|
+
if (containerRatio > designRatio) {
|
|
1097
|
+
// Container is wider → crop top/bottom
|
|
1098
|
+
scale = containerWidth / designWidth;
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
// Container is taller → crop left/right
|
|
1102
|
+
scale = containerHeight / designHeight;
|
|
1103
|
+
}
|
|
1104
|
+
gameWidth = containerWidth / scale;
|
|
1105
|
+
gameHeight = containerHeight / scale;
|
|
1106
|
+
break;
|
|
1107
|
+
}
|
|
1108
|
+
case ScaleMode.STRETCH: {
|
|
1109
|
+
gameWidth = designWidth;
|
|
1110
|
+
gameHeight = designHeight;
|
|
1111
|
+
scale = 1; // stretch is handled by CSS
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
default:
|
|
1115
|
+
gameWidth = designWidth;
|
|
1116
|
+
gameHeight = designHeight;
|
|
1117
|
+
scale = 1;
|
|
1118
|
+
}
|
|
1119
|
+
// Resize the renderer
|
|
1120
|
+
this._app.renderer.resize(Math.round(containerWidth), Math.round(containerHeight));
|
|
1121
|
+
// Scale the stage
|
|
1122
|
+
const stageScale = scaleMode === ScaleMode.STRETCH
|
|
1123
|
+
? Math.min(containerWidth / designWidth, containerHeight / designHeight)
|
|
1124
|
+
: scale;
|
|
1125
|
+
this._app.stage.scale.set(stageScale);
|
|
1126
|
+
// Center the stage for FIT mode
|
|
1127
|
+
if (scaleMode === ScaleMode.FIT) {
|
|
1128
|
+
this._app.stage.x = Math.round((containerWidth - designWidth * stageScale) / 2);
|
|
1129
|
+
this._app.stage.y = Math.round((containerHeight - designHeight * stageScale) / 2);
|
|
1130
|
+
}
|
|
1131
|
+
else if (scaleMode === ScaleMode.FILL) {
|
|
1132
|
+
this._app.stage.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
|
|
1133
|
+
this._app.stage.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
|
|
1134
|
+
}
|
|
1135
|
+
else {
|
|
1136
|
+
this._app.stage.x = 0;
|
|
1137
|
+
this._app.stage.y = 0;
|
|
1138
|
+
}
|
|
1139
|
+
this._currentWidth = gameWidth;
|
|
1140
|
+
this._currentHeight = gameHeight;
|
|
1141
|
+
this._currentScale = stageScale;
|
|
1142
|
+
// Check orientation
|
|
1143
|
+
const newOrientation = containerWidth >= containerHeight ? Orientation.LANDSCAPE : Orientation.PORTRAIT;
|
|
1144
|
+
if (newOrientation !== this._currentOrientation) {
|
|
1145
|
+
this._currentOrientation = newOrientation;
|
|
1146
|
+
this.emit('orientationChange', newOrientation);
|
|
1147
|
+
}
|
|
1148
|
+
this.emit('resize', {
|
|
1149
|
+
width: gameWidth,
|
|
1150
|
+
height: gameHeight,
|
|
1151
|
+
scale: stageScale,
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Destroy the viewport manager.
|
|
1156
|
+
*/
|
|
1157
|
+
destroy() {
|
|
1158
|
+
this._destroyed = true;
|
|
1159
|
+
this._resizeObserver?.disconnect();
|
|
1160
|
+
this._resizeObserver = null;
|
|
1161
|
+
if (this._resizeTimeout !== null) {
|
|
1162
|
+
clearTimeout(this._resizeTimeout);
|
|
1163
|
+
}
|
|
1164
|
+
this.removeAllListeners();
|
|
1165
|
+
}
|
|
1166
|
+
// ─── Private ───────────────────────────────────────────
|
|
1167
|
+
setupObserver() {
|
|
1168
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1169
|
+
this._resizeObserver = new ResizeObserver(() => {
|
|
1170
|
+
this.debouncedRefresh();
|
|
1171
|
+
});
|
|
1172
|
+
this._resizeObserver.observe(this._container);
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
// Fallback for older browsers
|
|
1176
|
+
window.addEventListener('resize', this.onWindowResize);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
onWindowResize = () => {
|
|
1180
|
+
this.debouncedRefresh();
|
|
1181
|
+
};
|
|
1182
|
+
debouncedRefresh() {
|
|
1183
|
+
if (this._resizeTimeout !== null) {
|
|
1184
|
+
clearTimeout(this._resizeTimeout);
|
|
1185
|
+
}
|
|
1186
|
+
this._resizeTimeout = window.setTimeout(() => {
|
|
1187
|
+
this.refresh();
|
|
1188
|
+
this._resizeTimeout = null;
|
|
1189
|
+
}, 16); // ~1 frame
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Base class for all scenes.
|
|
1195
|
+
* Provides a root PixiJS Container and lifecycle hooks.
|
|
1196
|
+
*
|
|
1197
|
+
* @example
|
|
1198
|
+
* ```ts
|
|
1199
|
+
* class MenuScene extends Scene {
|
|
1200
|
+
* async onEnter() {
|
|
1201
|
+
* const bg = Sprite.from('menu-bg');
|
|
1202
|
+
* this.container.addChild(bg);
|
|
1203
|
+
* }
|
|
1204
|
+
*
|
|
1205
|
+
* onUpdate(dt: number) {
|
|
1206
|
+
* // per-frame logic
|
|
1207
|
+
* }
|
|
1208
|
+
*
|
|
1209
|
+
* onResize(width: number, height: number) {
|
|
1210
|
+
* // reposition UI
|
|
1211
|
+
* }
|
|
1212
|
+
* }
|
|
1213
|
+
* ```
|
|
1214
|
+
*/
|
|
1215
|
+
class Scene {
|
|
1216
|
+
container;
|
|
1217
|
+
constructor() {
|
|
1218
|
+
this.container = new Container();
|
|
1219
|
+
this.container.label = this.constructor.name;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Inline SVG logo with a loader bar (clip-animated for progress).
|
|
1225
|
+
* The clipPath rect width is set to 0 initially, expanded as loading progresses.
|
|
1226
|
+
*/
|
|
1227
|
+
function buildLogoSVG() {
|
|
1228
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none" style="width:100%;height:auto;">
|
|
1229
|
+
<path d="m241 81.75h-19.28c-1.77 0-6.73 4.98-7.43 6.99l-4.36 12.22c-0.49 1.37 0.05 2.92 1.06 4.32-2.07 1.19-3.69 3.08-4.36 5.43l-3.25 10.41c-0.86 2.89 2.39 6.63 4.31 6.63h19.28c1.96 0 7.4-5.56 7.96-7.51l2.96-10.22c0.63-2.25 0.1-3.98-1.22-4.99 2.55-1.56 3.86-4.14 4.55-6.31l2.77-9.31c0.74-2.57-1.37-7.66-2.99-7.66zm-13.36 28.31-2.27 7.03h-8.28l2.58-8.28h8.28l-0.31 1.25zm4.06-16.97-2.11 6.7h-7.04l2.25-7.34h7.26l-0.36 0.64z" fill="url(#ls0)"/>
|
|
1230
|
+
<path d="m202.5 81.75-9.31 14.97-2.32-14.97h-11.82l4.32 25.15-0.57 4.91-8.64 26.44 15.31-12.76 5.63-16.48 19.96-27.26h-12.56z" fill="url(#ls1)"/>
|
|
1231
|
+
<path d="m174.2 81.75h-19.78l-5.75 5.16-10.79 33.2c-0.77 2.53 2.48 6.93 4.87 6.93h17.38c2.63 0 7.85-5.34 8.32-6.83l5.37-18.14h-15.17l-2.2 7.64h3.78l-2.25 7.2h-8.01l7.1-25.52h7.58l-1.48 8.4 12.78-5.98c1.28-0.63 1.97-3.99 1.61-6.61-0.36-2.34-1.64-5.45-3.36-5.45z" fill="url(#ls2)"/>
|
|
1232
|
+
<path d="m140.6 81.75h-70.6l-5.36 19.37-4.26-19.37h-46.76l2.95 5.88-10.58 39.28h26.84l2.95-9.52-15.63-0.13 2.55-8.34h8.74l8.47-9.81h-14.61l2.11-7.3h15.47l2.54-8.71 2.58 4.74-11.4 39.07h11.05l6.46-21.49 8.84 36.33 19.18-55.67-1.83-3.36 3.68 4.09-12.07 40.1h28.18l3.39-10.31h-17.01l2.67-8.03h9.98l7.58-9.52h-14.28l1.93-6.6h14.61l3.25-9.73 2.81 5.12-11.3 38.89h11.05l5.23-17.81h1.62l1.48 17.6h10.69l-1.48-16.81c4.75-1.28 7.52-5.9 8.64-9.81l2.95-11.3c0.86-2.73-1.43-6.85-3.3-6.85zm-9.8 17.3h-8.69l2.54-7.84h8.35l-2.2 7.84z" fill="url(#ls3)"/>
|
|
1233
|
+
<path d="m205.9 148.9h-122.6l-2.61-3.12h-32.4l-2.51 3.12h-1.59c-5.34 0-7.94 4.88-7.94 7.65v0.03c0 4.2 3.55 7.6 7.74 7.6h103.6l2.11 3.12h36.09l1.82-3.12h18.3c5.25 0 6.64-5.3 6.64-7.35v-0.25c0-4.23-2.9-7.68-6.64-7.68zm-0.7 12.83h-160.6c-3.69 0-6.11-2.58-6.11-5.47v-0.03c0-2.89 2.1-5.47 5.61-5.47h161.1c3.45 0 4.89 3.12 4.89 5.65v0.17c0 2.57-2.11 5.15-4.89 5.15z" fill="url(#ls4)"/>
|
|
1234
|
+
<clipPath id="ge-canvas-loader-clip">
|
|
1235
|
+
<rect id="ge-loader-rect" x="37" y="148" width="0" height="20"/>
|
|
1236
|
+
</clipPath>
|
|
1237
|
+
<path d="m204.5 152.6h-159.8c-2.78 0-4.45 1.69-4.45 3.99v0.11c0 2.04 1.42 3.43 3.64 3.43h160.6c2.88 0 3.67-2.07 3.67-3.43v-0.25c0-2.04-1.48-3.85-3.67-3.85z" fill="url(#ls5)" clip-path="url(#ge-canvas-loader-clip)"/>
|
|
1238
|
+
<text id="ge-loader-pct" x="125" y="196" text-anchor="middle" fill="rgba(255,255,255,0.7)" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="8" font-weight="600" letter-spacing="1.5">0%</text>
|
|
1239
|
+
<defs>
|
|
1240
|
+
<linearGradient id="ls0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
|
|
1241
|
+
<stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
|
|
1242
|
+
</linearGradient>
|
|
1243
|
+
<linearGradient id="ls1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
|
|
1244
|
+
<stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
|
|
1245
|
+
</linearGradient>
|
|
1246
|
+
<linearGradient id="ls2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
|
|
1247
|
+
<stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
|
|
1248
|
+
</linearGradient>
|
|
1249
|
+
<linearGradient id="ls3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
|
|
1250
|
+
<stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
|
|
1251
|
+
</linearGradient>
|
|
1252
|
+
<linearGradient id="ls4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
|
|
1253
|
+
<stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
|
|
1254
|
+
</linearGradient>
|
|
1255
|
+
<linearGradient id="ls5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
|
|
1256
|
+
<stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
|
|
1257
|
+
</linearGradient>
|
|
1258
|
+
</defs>
|
|
1259
|
+
</svg>`;
|
|
1260
|
+
}
|
|
1261
|
+
/** Max width of the loader bar in SVG units */
|
|
1262
|
+
const LOADER_BAR_MAX_WIDTH = 174;
|
|
1263
|
+
/**
|
|
1264
|
+
* Built-in loading screen using the Energy8 SVG logo with animated loader bar.
|
|
1265
|
+
*
|
|
1266
|
+
* Renders as an HTML overlay on top of the canvas for crisp SVG quality.
|
|
1267
|
+
* The loader bar fill width is driven by asset loading progress.
|
|
1268
|
+
*/
|
|
1269
|
+
class LoadingScene extends Scene {
|
|
1270
|
+
_engine;
|
|
1271
|
+
_targetScene;
|
|
1272
|
+
_targetData;
|
|
1273
|
+
_config;
|
|
1274
|
+
// HTML overlay
|
|
1275
|
+
_overlay = null;
|
|
1276
|
+
_loaderRect = null;
|
|
1277
|
+
_percentEl = null;
|
|
1278
|
+
_tapToStartEl = null;
|
|
1279
|
+
// State
|
|
1280
|
+
_displayedProgress = 0;
|
|
1281
|
+
_targetProgress = 0;
|
|
1282
|
+
_loadingComplete = false;
|
|
1283
|
+
_startTime = 0;
|
|
1284
|
+
async onEnter(data) {
|
|
1285
|
+
const { engine, targetScene, targetData } = data;
|
|
1286
|
+
this._engine = engine;
|
|
1287
|
+
this._targetScene = targetScene;
|
|
1288
|
+
this._targetData = targetData;
|
|
1289
|
+
this._config = engine.config.loading ?? {};
|
|
1290
|
+
this._startTime = Date.now();
|
|
1291
|
+
// Create the HTML overlay with the SVG logo
|
|
1292
|
+
this.createOverlay();
|
|
1293
|
+
// Initialize asset manager
|
|
1294
|
+
await this._engine.assets.init();
|
|
1295
|
+
// Initialize audio manager
|
|
1296
|
+
await this._engine.audio.init();
|
|
1297
|
+
// Phase 1: Load preload bundle
|
|
1298
|
+
const bundles = this._engine.assets.getBundleNames();
|
|
1299
|
+
const hasPreload = bundles.includes('preload');
|
|
1300
|
+
if (hasPreload) {
|
|
1301
|
+
const preloadAssets = this._engine.config.manifest?.bundles?.find((b) => b.name === 'preload')?.assets;
|
|
1302
|
+
if (preloadAssets && preloadAssets.length > 0) {
|
|
1303
|
+
await this._engine.assets.loadBundle('preload', (p) => {
|
|
1304
|
+
this._targetProgress = p * 0.15;
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
this._targetProgress = 0.15;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
// Phase 2: Load remaining bundles
|
|
1312
|
+
const remainingBundles = bundles.filter((b) => b !== 'preload' && !this._engine.assets.isBundleLoaded(b));
|
|
1313
|
+
if (remainingBundles.length > 0) {
|
|
1314
|
+
const hasAssets = remainingBundles.some((name) => {
|
|
1315
|
+
const bundle = this._engine.config.manifest?.bundles?.find((b) => b.name === name);
|
|
1316
|
+
return bundle?.assets && bundle.assets.length > 0;
|
|
1317
|
+
});
|
|
1318
|
+
if (hasAssets) {
|
|
1319
|
+
await this._engine.assets.loadBundles(remainingBundles, (p) => {
|
|
1320
|
+
this._targetProgress = 0.15 + p * 0.85;
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
this._targetProgress = 1;
|
|
1325
|
+
this._loadingComplete = true;
|
|
1326
|
+
// Enforce minimum display time: spread the remaining progress fill
|
|
1327
|
+
// over the remaining time so the bar fills smoothly, not abruptly
|
|
1328
|
+
const minTime = this._config.minDisplayTime ?? 1500;
|
|
1329
|
+
const elapsed = Date.now() - this._startTime;
|
|
1330
|
+
const remaining = Math.max(0, minTime - elapsed);
|
|
1331
|
+
if (remaining > 0) {
|
|
1332
|
+
// Distribute fill animation over the remaining time
|
|
1333
|
+
await this.animateProgressTo(1, remaining);
|
|
1334
|
+
}
|
|
1335
|
+
// Final snap to 100%
|
|
1336
|
+
this._displayedProgress = 1;
|
|
1337
|
+
this.updateLoaderBar(1);
|
|
1338
|
+
// Show "Tap to Start" or transition directly
|
|
1339
|
+
if (this._config.tapToStart !== false) {
|
|
1340
|
+
await this.showTapToStart();
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
await this.transitionToGame();
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
onUpdate(dt) {
|
|
1347
|
+
// Smooth progress bar fill via HTML (during active loading)
|
|
1348
|
+
if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
|
|
1349
|
+
this._displayedProgress = Math.min(this._displayedProgress + dt * 1.5, this._targetProgress);
|
|
1350
|
+
this.updateLoaderBar(this._displayedProgress);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
onResize(_width, _height) {
|
|
1354
|
+
// Overlay is CSS-based, auto-resizes
|
|
1355
|
+
}
|
|
1356
|
+
onDestroy() {
|
|
1357
|
+
this.removeOverlay();
|
|
1358
|
+
}
|
|
1359
|
+
// ─── HTML Overlay ──────────────────────────────────────
|
|
1360
|
+
createOverlay() {
|
|
1361
|
+
const bgColor = typeof this._config.backgroundColor === 'string'
|
|
1362
|
+
? this._config.backgroundColor
|
|
1363
|
+
: typeof this._config.backgroundColor === 'number'
|
|
1364
|
+
? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
|
|
1365
|
+
: '#0a0a1a';
|
|
1366
|
+
const bgGradient = this._config.backgroundGradient ??
|
|
1367
|
+
`linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
|
|
1368
|
+
this._overlay = document.createElement('div');
|
|
1369
|
+
this._overlay.id = '__ge-loading-overlay__';
|
|
1370
|
+
this._overlay.innerHTML = `
|
|
1371
|
+
<div class="ge-loading-content">
|
|
1372
|
+
${buildLogoSVG()}
|
|
1373
|
+
</div>
|
|
1374
|
+
`;
|
|
1375
|
+
const style = document.createElement('style');
|
|
1376
|
+
style.id = '__ge-loading-style__';
|
|
1377
|
+
style.textContent = `
|
|
1378
|
+
#__ge-loading-overlay__ {
|
|
1379
|
+
position: absolute;
|
|
1380
|
+
top: 0; left: 0;
|
|
1381
|
+
width: 100%; height: 100%;
|
|
1382
|
+
background: ${bgGradient};
|
|
1383
|
+
display: flex;
|
|
1384
|
+
align-items: center;
|
|
1385
|
+
justify-content: center;
|
|
1386
|
+
z-index: 9999;
|
|
1387
|
+
transition: opacity 0.5s ease-out;
|
|
1388
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1389
|
+
}
|
|
1390
|
+
#__ge-loading-overlay__.ge-fade-out {
|
|
1391
|
+
opacity: 0;
|
|
1392
|
+
pointer-events: none;
|
|
1393
|
+
}
|
|
1394
|
+
.ge-loading-content {
|
|
1395
|
+
display: flex;
|
|
1396
|
+
flex-direction: column;
|
|
1397
|
+
align-items: center;
|
|
1398
|
+
width: 75%;
|
|
1399
|
+
max-width: 650px;
|
|
1400
|
+
}
|
|
1401
|
+
.ge-loading-content svg {
|
|
1402
|
+
filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
|
|
1403
|
+
cursor: default;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
.ge-svg-pulse {
|
|
1407
|
+
animation: ge-tap-pulse 1.2s ease-in-out infinite;
|
|
1408
|
+
}
|
|
1409
|
+
@keyframes ge-tap-pulse {
|
|
1410
|
+
0%, 100% { opacity: 0.5; }
|
|
1411
|
+
50% { opacity: 1; }
|
|
1412
|
+
}
|
|
1413
|
+
`;
|
|
1414
|
+
// Get the container that holds the canvas
|
|
1415
|
+
const container = this._engine.app?.canvas?.parentElement;
|
|
1416
|
+
if (container) {
|
|
1417
|
+
container.style.position = container.style.position || 'relative';
|
|
1418
|
+
container.appendChild(style);
|
|
1419
|
+
container.appendChild(this._overlay);
|
|
1420
|
+
}
|
|
1421
|
+
// Cache the SVG loader rect for progress updates
|
|
1422
|
+
this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
|
|
1423
|
+
this._percentEl = this._overlay.querySelector('#ge-loader-pct');
|
|
1424
|
+
}
|
|
1425
|
+
removeOverlay() {
|
|
1426
|
+
this._overlay?.remove();
|
|
1427
|
+
document.getElementById('__ge-loading-style__')?.remove();
|
|
1428
|
+
this._overlay = null;
|
|
1429
|
+
this._loaderRect = null;
|
|
1430
|
+
this._percentEl = null;
|
|
1431
|
+
this._tapToStartEl = null;
|
|
1432
|
+
}
|
|
1433
|
+
// ─── Progress ──────────────────────────────────────────
|
|
1434
|
+
updateLoaderBar(progress) {
|
|
1435
|
+
if (this._loaderRect) {
|
|
1436
|
+
this._loaderRect.setAttribute('width', String(LOADER_BAR_MAX_WIDTH * progress));
|
|
1437
|
+
}
|
|
1438
|
+
if (this._percentEl) {
|
|
1439
|
+
const pct = Math.round(progress * 100);
|
|
1440
|
+
this._percentEl.textContent = `${pct}%`;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Smoothly animate the displayed progress from its current value to `target`
|
|
1445
|
+
* over `durationMs` using an easeOutCubic curve.
|
|
1446
|
+
*/
|
|
1447
|
+
async animateProgressTo(target, durationMs) {
|
|
1448
|
+
const startVal = this._displayedProgress;
|
|
1449
|
+
const delta = target - startVal;
|
|
1450
|
+
if (delta <= 0 || durationMs <= 0)
|
|
1451
|
+
return;
|
|
1452
|
+
const startTime = Date.now();
|
|
1453
|
+
return new Promise((resolve) => {
|
|
1454
|
+
const tick = () => {
|
|
1455
|
+
const elapsed = Date.now() - startTime;
|
|
1456
|
+
const t = Math.min(elapsed / durationMs, 1);
|
|
1457
|
+
// easeOutCubic for a natural deceleration feel
|
|
1458
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
1459
|
+
this._displayedProgress = startVal + delta * eased;
|
|
1460
|
+
this.updateLoaderBar(this._displayedProgress);
|
|
1461
|
+
if (t < 1) {
|
|
1462
|
+
requestAnimationFrame(tick);
|
|
1463
|
+
}
|
|
1464
|
+
else {
|
|
1465
|
+
resolve();
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
requestAnimationFrame(tick);
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
// ─── Tap to Start ─────────────────────────────────────
|
|
1472
|
+
async showTapToStart() {
|
|
1473
|
+
const tapText = this._config.tapToStartText ?? 'TAP TO START';
|
|
1474
|
+
// Reuse the same SVG text element — replace percentage with tap text
|
|
1475
|
+
if (this._percentEl) {
|
|
1476
|
+
const el = this._percentEl;
|
|
1477
|
+
el.textContent = tapText;
|
|
1478
|
+
el.setAttribute('fill', '#ffffff');
|
|
1479
|
+
el.classList.add('ge-svg-pulse');
|
|
1480
|
+
this._tapToStartEl = el;
|
|
1481
|
+
}
|
|
1482
|
+
// Make overlay clickable
|
|
1483
|
+
if (this._overlay) {
|
|
1484
|
+
this._overlay.style.cursor = 'pointer';
|
|
1485
|
+
}
|
|
1486
|
+
// Wait for tap
|
|
1487
|
+
return new Promise((resolve) => {
|
|
1488
|
+
const handler = async () => {
|
|
1489
|
+
this._overlay?.removeEventListener('click', handler);
|
|
1490
|
+
await this.transitionToGame();
|
|
1491
|
+
resolve();
|
|
1492
|
+
};
|
|
1493
|
+
// Listen on the full overlay for easier mobile tap
|
|
1494
|
+
this._overlay?.addEventListener('click', handler);
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
// ─── Transition ────────────────────────────────────────
|
|
1498
|
+
async transitionToGame() {
|
|
1499
|
+
// Fade out the HTML overlay
|
|
1500
|
+
if (this._overlay) {
|
|
1501
|
+
this._overlay.classList.add('ge-fade-out');
|
|
1502
|
+
await new Promise((resolve) => {
|
|
1503
|
+
this._overlay.addEventListener('transitionend', () => resolve(), { once: true });
|
|
1504
|
+
// Safety timeout
|
|
1505
|
+
setTimeout(resolve, 600);
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
// Remove overlay
|
|
1509
|
+
this.removeOverlay();
|
|
1510
|
+
// Navigate to the target scene
|
|
1511
|
+
await this._engine.scenes.goto(this._targetScene, this._targetData);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const PRELOADER_ID = '__ge-css-preloader__';
|
|
1516
|
+
/**
|
|
1517
|
+
* Inline SVG logo with animated loader bar.
|
|
1518
|
+
* The `#loader` path acts as the progress fill — animated via clipPath.
|
|
1519
|
+
*/
|
|
1520
|
+
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none" class="ge-logo-svg">
|
|
1521
|
+
<path d="m241 81.75h-19.28c-1.77 0-6.73 4.98-7.43 6.99l-4.36 12.22c-0.49 1.37 0.05 2.92 1.06 4.32-2.07 1.19-3.69 3.08-4.36 5.43l-3.25 10.41c-0.86 2.89 2.39 6.63 4.31 6.63h19.28c1.96 0 7.4-5.56 7.96-7.51l2.96-10.22c0.63-2.25 0.1-3.98-1.22-4.99 2.55-1.56 3.86-4.14 4.55-6.31l2.77-9.31c0.74-2.57-1.37-7.66-2.99-7.66zm-13.36 28.31-2.27 7.03h-8.28l2.58-8.28h8.28l-0.31 1.25zm4.06-16.97-2.11 6.7h-7.04l2.25-7.34h7.26l-0.36 0.64z" fill="url(#pl0)"/>
|
|
1522
|
+
<path d="m202.5 81.75-9.31 14.97-2.32-14.97h-11.82l4.32 25.15-0.57 4.91-8.64 26.44 15.31-12.76 5.63-16.48 19.96-27.26h-12.56z" fill="url(#pl1)"/>
|
|
1523
|
+
<path d="m174.2 81.75h-19.78l-5.75 5.16-10.79 33.2c-0.77 2.53 2.48 6.93 4.87 6.93h17.38c2.63 0 7.85-5.34 8.32-6.83l5.37-18.14h-15.17l-2.2 7.64h3.78l-2.25 7.2h-8.01l7.1-25.52h7.58l-1.48 8.4 12.78-5.98c1.28-0.63 1.97-3.99 1.61-6.61-0.36-2.34-1.64-5.45-3.36-5.45z" fill="url(#pl2)"/>
|
|
1524
|
+
<path d="m140.6 81.75h-70.6l-5.36 19.37-4.26-19.37h-46.76l2.95 5.88-10.58 39.28h26.84l2.95-9.52-15.63-0.13 2.55-8.34h8.74l8.47-9.81h-14.61l2.11-7.3h15.47l2.54-8.71 2.58 4.74-11.4 39.07h11.05l6.46-21.49 8.84 36.33 19.18-55.67-1.83-3.36 3.68 4.09-12.07 40.1h28.18l3.39-10.31h-17.01l2.67-8.03h9.98l7.58-9.52h-14.28l1.93-6.6h14.61l3.25-9.73 2.81 5.12-11.3 38.89h11.05l5.23-17.81h1.62l1.48 17.6h10.69l-1.48-16.81c4.75-1.28 7.52-5.9 8.64-9.81l2.95-11.3c0.86-2.73-1.43-6.85-3.3-6.85zm-9.8 17.3h-8.69l2.54-7.84h8.35l-2.2 7.84z" fill="url(#pl3)"/>
|
|
1525
|
+
<path d="m205.9 148.9h-122.6l-2.61-3.12h-32.4l-2.51 3.12h-1.59c-5.34 0-7.94 4.88-7.94 7.65v0.03c0 4.2 3.55 7.6 7.74 7.6h103.6l2.11 3.12h36.09l1.82-3.12h18.3c5.25 0 6.64-5.3 6.64-7.35v-0.25c0-4.23-2.9-7.68-6.64-7.68zm-0.7 12.83h-160.6c-3.69 0-6.11-2.58-6.11-5.47v-0.03c0-2.89 2.1-5.47 5.61-5.47h161.1c3.45 0 4.89 3.12 4.89 5.65v0.17c0 2.57-2.11 5.15-4.89 5.15z" fill="url(#pl4)"/>
|
|
1526
|
+
<!-- Loader fill with clip for progress animation -->
|
|
1527
|
+
<clipPath id="ge-loader-clip">
|
|
1528
|
+
<rect x="37" y="148" width="0" height="20" class="ge-clip-rect"/>
|
|
1529
|
+
</clipPath>
|
|
1530
|
+
<path d="m204.5 152.6h-159.8c-2.78 0-4.45 1.69-4.45 3.99v0.11c0 2.04 1.42 3.43 3.64 3.43h160.6c2.88 0 3.67-2.07 3.67-3.43v-0.25c0-2.04-1.48-3.85-3.67-3.85z" fill="url(#pl5)" clip-path="url(#ge-loader-clip)"/>
|
|
1531
|
+
<text x="125" y="196" text-anchor="middle" fill="rgba(255,255,255,0.6)" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="8" font-weight="600" letter-spacing="1.5" class="ge-preloader-svg-text">Loading...</text>
|
|
1532
|
+
<defs>
|
|
1533
|
+
<linearGradient id="pl0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
|
|
1534
|
+
<stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
|
|
1535
|
+
</linearGradient>
|
|
1536
|
+
<linearGradient id="pl1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
|
|
1537
|
+
<stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
|
|
1538
|
+
</linearGradient>
|
|
1539
|
+
<linearGradient id="pl2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
|
|
1540
|
+
<stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
|
|
1541
|
+
</linearGradient>
|
|
1542
|
+
<linearGradient id="pl3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
|
|
1543
|
+
<stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
|
|
1544
|
+
</linearGradient>
|
|
1545
|
+
<linearGradient id="pl4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
|
|
1546
|
+
<stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
|
|
1547
|
+
</linearGradient>
|
|
1548
|
+
<linearGradient id="pl5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
|
|
1549
|
+
<stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
|
|
1550
|
+
</linearGradient>
|
|
1551
|
+
</defs>
|
|
1552
|
+
</svg>`;
|
|
1553
|
+
/**
|
|
1554
|
+
* Creates a lightweight CSS-only preloader that appears instantly,
|
|
1555
|
+
* BEFORE PixiJS/WebGL is initialized.
|
|
1556
|
+
*
|
|
1557
|
+
* Displays the Energy8 logo SVG with an animated loader bar.
|
|
1558
|
+
*/
|
|
1559
|
+
function createCSSPreloader(container, config) {
|
|
1560
|
+
if (document.getElementById(PRELOADER_ID))
|
|
1561
|
+
return;
|
|
1562
|
+
const bgColor = typeof config?.backgroundColor === 'string'
|
|
1563
|
+
? config.backgroundColor
|
|
1564
|
+
: typeof config?.backgroundColor === 'number'
|
|
1565
|
+
? `#${config.backgroundColor.toString(16).padStart(6, '0')}`
|
|
1566
|
+
: '#0a0a1a';
|
|
1567
|
+
const bgGradient = config?.backgroundGradient ?? `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
|
|
1568
|
+
const customHTML = config?.cssPreloaderHTML ?? '';
|
|
1569
|
+
const el = document.createElement('div');
|
|
1570
|
+
el.id = PRELOADER_ID;
|
|
1571
|
+
el.innerHTML = customHTML || `
|
|
1572
|
+
<div class="ge-preloader-content">
|
|
1573
|
+
${LOGO_SVG}
|
|
1574
|
+
</div>
|
|
1575
|
+
`;
|
|
1576
|
+
const style = document.createElement('style');
|
|
1577
|
+
style.textContent = `
|
|
1578
|
+
#${PRELOADER_ID} {
|
|
1579
|
+
position: absolute;
|
|
1580
|
+
top: 0; left: 0;
|
|
1581
|
+
width: 100%; height: 100%;
|
|
1582
|
+
background: ${bgGradient};
|
|
1583
|
+
display: flex;
|
|
1584
|
+
align-items: center;
|
|
1585
|
+
justify-content: center;
|
|
1586
|
+
z-index: 10000;
|
|
1587
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1588
|
+
transition: opacity 0.4s ease-out;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
#${PRELOADER_ID}.ge-preloader-hidden {
|
|
1592
|
+
opacity: 0;
|
|
1593
|
+
pointer-events: none;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
.ge-preloader-content {
|
|
1597
|
+
display: flex;
|
|
1598
|
+
flex-direction: column;
|
|
1599
|
+
align-items: center;
|
|
1600
|
+
width: 80%;
|
|
1601
|
+
max-width: 700px;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
.ge-logo-svg {
|
|
1605
|
+
width: 100%;
|
|
1606
|
+
height: auto;
|
|
1607
|
+
filter: drop-shadow(0 0 30px rgba(121, 57, 194, 0.4));
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/* Animate the loader clip-rect to shimmer while waiting */
|
|
1611
|
+
.ge-clip-rect {
|
|
1612
|
+
animation: ge-loader-fill 2s ease-in-out infinite;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
@keyframes ge-loader-fill {
|
|
1616
|
+
0% { width: 0; }
|
|
1617
|
+
50% { width: 174; }
|
|
1618
|
+
100% { width: 0; }
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/* Animate the SVG text opacity */
|
|
1622
|
+
.ge-preloader-svg-text {
|
|
1623
|
+
animation: ge-pulse 1.5s ease-in-out infinite;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
@keyframes ge-pulse {
|
|
1627
|
+
0%, 100% { opacity: 0.4; }
|
|
1628
|
+
50% { opacity: 1; }
|
|
1629
|
+
}
|
|
1630
|
+
`;
|
|
1631
|
+
container.style.position = container.style.position || 'relative';
|
|
1632
|
+
container.appendChild(style);
|
|
1633
|
+
container.appendChild(el);
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Remove the CSS preloader with a smooth fade-out transition.
|
|
1637
|
+
*/
|
|
1638
|
+
function removeCSSPreloader(container) {
|
|
1639
|
+
const el = document.getElementById(PRELOADER_ID);
|
|
1640
|
+
if (!el)
|
|
1641
|
+
return;
|
|
1642
|
+
el.classList.add('ge-preloader-hidden');
|
|
1643
|
+
// Remove after transition
|
|
1644
|
+
el.addEventListener('transitionend', () => {
|
|
1645
|
+
el.remove();
|
|
1646
|
+
// Also remove the style element
|
|
1647
|
+
const styles = container.querySelectorAll('style');
|
|
1648
|
+
for (const style of styles) {
|
|
1649
|
+
if (style.textContent?.includes(PRELOADER_ID)) {
|
|
1650
|
+
style.remove();
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* The main entry point for a game built on @energy8platform/game-engine.
|
|
1658
|
+
*
|
|
1659
|
+
* Orchestrates the full lifecycle:
|
|
1660
|
+
* 1. Create PixiJS Application
|
|
1661
|
+
* 2. Initialize SDK (or run offline)
|
|
1662
|
+
* 3. Show CSS preloader → Canvas loading screen with progress bar
|
|
1663
|
+
* 4. Load asset manifest
|
|
1664
|
+
* 5. Transition to the first game scene
|
|
1665
|
+
*
|
|
1666
|
+
* @example
|
|
1667
|
+
* ```ts
|
|
1668
|
+
* import { GameApplication, ScaleMode } from '@energy8platform/game-engine';
|
|
1669
|
+
* import { GameScene } from './scenes/GameScene';
|
|
1670
|
+
*
|
|
1671
|
+
* const game = new GameApplication({
|
|
1672
|
+
* container: '#game',
|
|
1673
|
+
* designWidth: 1920,
|
|
1674
|
+
* designHeight: 1080,
|
|
1675
|
+
* scaleMode: ScaleMode.FIT,
|
|
1676
|
+
* manifest: { bundles: [
|
|
1677
|
+
* { name: 'preload', assets: [{ alias: 'logo', src: 'logo.png' }] },
|
|
1678
|
+
* { name: 'game', assets: [{ alias: 'bg', src: 'background.png' }] },
|
|
1679
|
+
* ]},
|
|
1680
|
+
* loading: { tapToStart: true },
|
|
1681
|
+
* });
|
|
1682
|
+
*
|
|
1683
|
+
* game.scenes.register('game', GameScene);
|
|
1684
|
+
* await game.start('game');
|
|
1685
|
+
* ```
|
|
1686
|
+
*/
|
|
1687
|
+
class GameApplication extends EventEmitter {
|
|
1688
|
+
// ─── Public references ──────────────────────────────────
|
|
1689
|
+
/** PixiJS Application instance */
|
|
1690
|
+
app;
|
|
1691
|
+
/** Scene manager */
|
|
1692
|
+
scenes;
|
|
1693
|
+
/** Asset manager */
|
|
1694
|
+
assets;
|
|
1695
|
+
/** Audio manager */
|
|
1696
|
+
audio;
|
|
1697
|
+
/** Viewport manager */
|
|
1698
|
+
viewport;
|
|
1699
|
+
/** SDK instance (null in offline mode) */
|
|
1700
|
+
sdk = null;
|
|
1701
|
+
/** Data received from SDK initialization */
|
|
1702
|
+
initData = null;
|
|
1703
|
+
/** Configuration */
|
|
1704
|
+
config;
|
|
1705
|
+
// ─── Private state ──────────────────────────────────────
|
|
1706
|
+
_running = false;
|
|
1707
|
+
_destroyed = false;
|
|
1708
|
+
_container = null;
|
|
1709
|
+
constructor(config = {}) {
|
|
1710
|
+
super();
|
|
1711
|
+
this.config = {
|
|
1712
|
+
designWidth: 1920,
|
|
1713
|
+
designHeight: 1080,
|
|
1714
|
+
scaleMode: ScaleMode.FIT,
|
|
1715
|
+
orientation: Orientation.ANY,
|
|
1716
|
+
debug: false,
|
|
1717
|
+
...config,
|
|
1718
|
+
};
|
|
1719
|
+
// Create SceneManager early so scenes can be registered before start()
|
|
1720
|
+
this.scenes = new SceneManager();
|
|
1721
|
+
}
|
|
1722
|
+
// ─── Public getters ─────────────────────────────────────
|
|
1723
|
+
/** Current game config from SDK (or null in offline mode) */
|
|
1724
|
+
get gameConfig() {
|
|
1725
|
+
return this.initData?.config ?? null;
|
|
1726
|
+
}
|
|
1727
|
+
/** Current session data */
|
|
1728
|
+
get session() {
|
|
1729
|
+
return this.initData?.session ?? null;
|
|
1730
|
+
}
|
|
1731
|
+
/** Current balance */
|
|
1732
|
+
get balance() {
|
|
1733
|
+
return this.sdk?.balance ?? 0;
|
|
1734
|
+
}
|
|
1735
|
+
/** Current currency */
|
|
1736
|
+
get currency() {
|
|
1737
|
+
return this.sdk?.currency ?? 'USD';
|
|
1738
|
+
}
|
|
1739
|
+
/** Whether the engine is running */
|
|
1740
|
+
get isRunning() {
|
|
1741
|
+
return this._running;
|
|
1742
|
+
}
|
|
1743
|
+
// ─── Lifecycle ──────────────────────────────────────────
|
|
1744
|
+
/**
|
|
1745
|
+
* Start the game engine. This is the main entry point.
|
|
1746
|
+
*
|
|
1747
|
+
* @param firstScene - Key of the first scene to show after loading (must be registered)
|
|
1748
|
+
* @param sceneData - Optional data to pass to the first scene's onEnter
|
|
1749
|
+
*/
|
|
1750
|
+
async start(firstScene, sceneData) {
|
|
1751
|
+
if (this._running) {
|
|
1752
|
+
console.warn('[GameEngine] Already running');
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
try {
|
|
1756
|
+
// 1. Resolve container element
|
|
1757
|
+
this._container = this.resolveContainer();
|
|
1758
|
+
// 2. Show CSS preloader immediately (before PixiJS)
|
|
1759
|
+
createCSSPreloader(this._container, this.config.loading);
|
|
1760
|
+
// 3. Initialize PixiJS
|
|
1761
|
+
await this.initPixi();
|
|
1762
|
+
// 4. Initialize SDK (if enabled)
|
|
1763
|
+
await this.initSDK();
|
|
1764
|
+
// 5. Merge design dimensions from SDK config
|
|
1765
|
+
this.applySDKConfig();
|
|
1766
|
+
// 6. Initialize sub-systems
|
|
1767
|
+
this.initSubSystems();
|
|
1768
|
+
this.emit('initialized', undefined);
|
|
1769
|
+
// 7. Remove CSS preloader, show Canvas loading screen
|
|
1770
|
+
removeCSSPreloader(this._container);
|
|
1771
|
+
// 8. Load assets with loading screen
|
|
1772
|
+
await this.loadAssets(firstScene, sceneData);
|
|
1773
|
+
// 9. Start the game loop
|
|
1774
|
+
this._running = true;
|
|
1775
|
+
this.emit('started', undefined);
|
|
1776
|
+
}
|
|
1777
|
+
catch (err) {
|
|
1778
|
+
console.error('[GameEngine] Failed to start:', err);
|
|
1779
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
1780
|
+
throw err;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Destroy the engine and free all resources.
|
|
1785
|
+
*/
|
|
1786
|
+
destroy() {
|
|
1787
|
+
if (this._destroyed)
|
|
1788
|
+
return;
|
|
1789
|
+
this._destroyed = true;
|
|
1790
|
+
this._running = false;
|
|
1791
|
+
this.scenes?.destroy();
|
|
1792
|
+
this.audio?.destroy();
|
|
1793
|
+
this.viewport?.destroy();
|
|
1794
|
+
this.sdk?.destroy();
|
|
1795
|
+
this.app?.destroy(true, { children: true, texture: true });
|
|
1796
|
+
this.removeAllListeners();
|
|
1797
|
+
this.emit('destroyed', undefined);
|
|
1798
|
+
}
|
|
1799
|
+
// ─── Private initialization steps ──────────────────────
|
|
1800
|
+
resolveContainer() {
|
|
1801
|
+
if (typeof this.config.container === 'string') {
|
|
1802
|
+
const el = document.querySelector(this.config.container);
|
|
1803
|
+
if (!el)
|
|
1804
|
+
throw new Error(`[GameEngine] Container "${this.config.container}" not found`);
|
|
1805
|
+
return el;
|
|
1806
|
+
}
|
|
1807
|
+
return this.config.container ?? document.body;
|
|
1808
|
+
}
|
|
1809
|
+
async initPixi() {
|
|
1810
|
+
this.app = new Application();
|
|
1811
|
+
const pixiOpts = {
|
|
1812
|
+
background: typeof this.config.loading?.backgroundColor === 'number'
|
|
1813
|
+
? this.config.loading.backgroundColor
|
|
1814
|
+
: 0x000000,
|
|
1815
|
+
antialias: true,
|
|
1816
|
+
resolution: Math.min(window.devicePixelRatio, 2),
|
|
1817
|
+
autoDensity: true,
|
|
1818
|
+
...this.config.pixi,
|
|
1819
|
+
};
|
|
1820
|
+
await this.app.init(pixiOpts);
|
|
1821
|
+
// Append canvas to container
|
|
1822
|
+
this._container.appendChild(this.app.canvas);
|
|
1823
|
+
// Set canvas style
|
|
1824
|
+
this.app.canvas.style.display = 'block';
|
|
1825
|
+
this.app.canvas.style.width = '100%';
|
|
1826
|
+
this.app.canvas.style.height = '100%';
|
|
1827
|
+
}
|
|
1828
|
+
async initSDK() {
|
|
1829
|
+
if (this.config.sdk === false) {
|
|
1830
|
+
// Offline / development mode — no SDK
|
|
1831
|
+
this.initData = null;
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const sdkOpts = typeof this.config.sdk === 'object' ? this.config.sdk : {};
|
|
1835
|
+
this.sdk = new CasinoGameSDK(sdkOpts);
|
|
1836
|
+
// Perform the handshake
|
|
1837
|
+
this.initData = await this.sdk.ready();
|
|
1838
|
+
// Forward SDK events
|
|
1839
|
+
this.sdk.on('error', (err) => {
|
|
1840
|
+
this.emit('error', err);
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
applySDKConfig() {
|
|
1844
|
+
// If SDK provides viewport dimensions, use them as design reference
|
|
1845
|
+
if (this.initData?.config?.viewport) {
|
|
1846
|
+
const vp = this.initData.config.viewport;
|
|
1847
|
+
if (!this.config.designWidth)
|
|
1848
|
+
this.config.designWidth = vp.width;
|
|
1849
|
+
if (!this.config.designHeight)
|
|
1850
|
+
this.config.designHeight = vp.height;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
initSubSystems() {
|
|
1854
|
+
// Asset Manager
|
|
1855
|
+
const basePath = this.initData?.assetsUrl ?? '';
|
|
1856
|
+
this.assets = new AssetManager(basePath, this.config.manifest);
|
|
1857
|
+
// Audio Manager
|
|
1858
|
+
this.audio = new AudioManager(this.config.audio);
|
|
1859
|
+
// Viewport Manager
|
|
1860
|
+
this.viewport = new ViewportManager(this.app, this._container, {
|
|
1861
|
+
designWidth: this.config.designWidth,
|
|
1862
|
+
designHeight: this.config.designHeight,
|
|
1863
|
+
scaleMode: this.config.scaleMode,
|
|
1864
|
+
orientation: this.config.orientation,
|
|
1865
|
+
});
|
|
1866
|
+
// Wire SceneManager to the PixiJS stage
|
|
1867
|
+
this.scenes.setRoot(this.app.stage);
|
|
1868
|
+
// Wire viewport resize → scene manager
|
|
1869
|
+
this.viewport.on('resize', ({ width, height }) => {
|
|
1870
|
+
this.scenes.resize(width, height);
|
|
1871
|
+
this.emit('resize', { width, height });
|
|
1872
|
+
});
|
|
1873
|
+
this.viewport.on('orientationChange', (orientation) => {
|
|
1874
|
+
this.emit('orientationChange', orientation);
|
|
1875
|
+
});
|
|
1876
|
+
// Connect ticker → scene updates
|
|
1877
|
+
this.app.ticker.add((ticker) => {
|
|
1878
|
+
// Always update scenes (loading screen needs onUpdate before _running=true)
|
|
1879
|
+
this.scenes.update(ticker.deltaTime / 60); // convert to seconds
|
|
1880
|
+
});
|
|
1881
|
+
// Trigger initial resize
|
|
1882
|
+
this.viewport.refresh();
|
|
1883
|
+
}
|
|
1884
|
+
async loadAssets(firstScene, sceneData) {
|
|
1885
|
+
// Register built-in loading scene
|
|
1886
|
+
this.scenes.register('__loading__', LoadingScene);
|
|
1887
|
+
// Enter loading scene
|
|
1888
|
+
await this.scenes.goto('__loading__', {
|
|
1889
|
+
engine: this,
|
|
1890
|
+
targetScene: firstScene,
|
|
1891
|
+
targetData: sceneData,
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* Generic finite state machine for game flow management.
|
|
1898
|
+
*
|
|
1899
|
+
* Supports:
|
|
1900
|
+
* - Typed context object shared across all states
|
|
1901
|
+
* - Async enter/exit hooks
|
|
1902
|
+
* - Per-frame update per state
|
|
1903
|
+
* - Transition guards
|
|
1904
|
+
* - Event emission on state change
|
|
1905
|
+
*
|
|
1906
|
+
* @example
|
|
1907
|
+
* ```ts
|
|
1908
|
+
* interface GameContext {
|
|
1909
|
+
* balance: number;
|
|
1910
|
+
* bet: number;
|
|
1911
|
+
* lastWin: number;
|
|
1912
|
+
* }
|
|
1913
|
+
*
|
|
1914
|
+
* const fsm = new StateMachine<GameContext>({ balance: 1000, bet: 10, lastWin: 0 });
|
|
1915
|
+
*
|
|
1916
|
+
* fsm.addState('idle', {
|
|
1917
|
+
* enter: (ctx) => console.log('Waiting for spin...'),
|
|
1918
|
+
* update: (ctx, dt) => { // optional per-frame },
|
|
1919
|
+
* });
|
|
1920
|
+
*
|
|
1921
|
+
* fsm.addState('spinning', {
|
|
1922
|
+
* enter: async (ctx) => {
|
|
1923
|
+
* const result = await sdk.play({ action: 'spin', bet: ctx.bet });
|
|
1924
|
+
* ctx.lastWin = result.totalWin;
|
|
1925
|
+
* await fsm.transition('presenting');
|
|
1926
|
+
* },
|
|
1927
|
+
* });
|
|
1928
|
+
*
|
|
1929
|
+
* fsm.addState('presenting', {
|
|
1930
|
+
* enter: async (ctx) => {
|
|
1931
|
+
* await showWinPresentation(ctx.lastWin);
|
|
1932
|
+
* await fsm.transition('idle');
|
|
1933
|
+
* },
|
|
1934
|
+
* });
|
|
1935
|
+
*
|
|
1936
|
+
* // Optional guard
|
|
1937
|
+
* fsm.addGuard('idle', 'spinning', (ctx) => ctx.balance >= ctx.bet);
|
|
1938
|
+
*
|
|
1939
|
+
* await fsm.start('idle');
|
|
1940
|
+
* ```
|
|
1941
|
+
*/
|
|
1942
|
+
class StateMachine extends EventEmitter {
|
|
1943
|
+
_states = new Map();
|
|
1944
|
+
_guards = new Map();
|
|
1945
|
+
_current = null;
|
|
1946
|
+
_transitioning = false;
|
|
1947
|
+
_context;
|
|
1948
|
+
constructor(context) {
|
|
1949
|
+
super();
|
|
1950
|
+
this._context = context;
|
|
1951
|
+
}
|
|
1952
|
+
/** Current state name */
|
|
1953
|
+
get current() {
|
|
1954
|
+
return this._current;
|
|
1955
|
+
}
|
|
1956
|
+
/** Whether a transition is in progress */
|
|
1957
|
+
get isTransitioning() {
|
|
1958
|
+
return this._transitioning;
|
|
1959
|
+
}
|
|
1960
|
+
/** State machine context (shared data) */
|
|
1961
|
+
get context() {
|
|
1962
|
+
return this._context;
|
|
1963
|
+
}
|
|
1964
|
+
/**
|
|
1965
|
+
* Register a state with optional enter/exit/update hooks.
|
|
1966
|
+
*/
|
|
1967
|
+
addState(name, config) {
|
|
1968
|
+
this._states.set(name, config);
|
|
1969
|
+
return this;
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Add a transition guard.
|
|
1973
|
+
* The guard function must return true to allow the transition.
|
|
1974
|
+
*
|
|
1975
|
+
* @param from - Source state
|
|
1976
|
+
* @param to - Target state
|
|
1977
|
+
* @param guard - Guard function
|
|
1978
|
+
*/
|
|
1979
|
+
addGuard(from, to, guard) {
|
|
1980
|
+
this._guards.set(`${from}->${to}`, guard);
|
|
1981
|
+
return this;
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Start the state machine in the given initial state.
|
|
1985
|
+
*/
|
|
1986
|
+
async start(initialState, data) {
|
|
1987
|
+
if (this._current !== null) {
|
|
1988
|
+
throw new Error('[StateMachine] Already started. Use transition() to change states.');
|
|
1989
|
+
}
|
|
1990
|
+
const state = this._states.get(initialState);
|
|
1991
|
+
if (!state) {
|
|
1992
|
+
throw new Error(`[StateMachine] State "${initialState}" not registered.`);
|
|
1993
|
+
}
|
|
1994
|
+
this._current = initialState;
|
|
1995
|
+
await state.enter?.(this._context, data);
|
|
1996
|
+
this.emit('transition', { from: null, to: initialState });
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Transition to a new state.
|
|
2000
|
+
*
|
|
2001
|
+
* @param to - Target state name
|
|
2002
|
+
* @param data - Optional data passed to the new state's enter hook
|
|
2003
|
+
* @returns true if the transition succeeded, false if blocked by a guard
|
|
2004
|
+
*/
|
|
2005
|
+
async transition(to, data) {
|
|
2006
|
+
if (this._transitioning) {
|
|
2007
|
+
console.warn('[StateMachine] Transition already in progress');
|
|
2008
|
+
return false;
|
|
2009
|
+
}
|
|
2010
|
+
const from = this._current;
|
|
2011
|
+
// Check guard
|
|
2012
|
+
if (from !== null) {
|
|
2013
|
+
const guardKey = `${from}->${to}`;
|
|
2014
|
+
const guard = this._guards.get(guardKey);
|
|
2015
|
+
if (guard && !guard(this._context)) {
|
|
2016
|
+
return false;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
const toState = this._states.get(to);
|
|
2020
|
+
if (!toState) {
|
|
2021
|
+
throw new Error(`[StateMachine] State "${to}" not registered.`);
|
|
2022
|
+
}
|
|
2023
|
+
this._transitioning = true;
|
|
2024
|
+
try {
|
|
2025
|
+
// Exit current state
|
|
2026
|
+
if (from !== null) {
|
|
2027
|
+
const fromState = this._states.get(from);
|
|
2028
|
+
await fromState?.exit?.(this._context);
|
|
2029
|
+
}
|
|
2030
|
+
// Enter new state
|
|
2031
|
+
this._current = to;
|
|
2032
|
+
await toState.enter?.(this._context, data);
|
|
2033
|
+
this.emit('transition', { from, to });
|
|
2034
|
+
}
|
|
2035
|
+
catch (err) {
|
|
2036
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
2037
|
+
throw err;
|
|
2038
|
+
}
|
|
2039
|
+
finally {
|
|
2040
|
+
this._transitioning = false;
|
|
2041
|
+
}
|
|
2042
|
+
return true;
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Call the current state's update function.
|
|
2046
|
+
* Should be called from the game loop.
|
|
2047
|
+
*/
|
|
2048
|
+
update(dt) {
|
|
2049
|
+
if (this._current === null)
|
|
2050
|
+
return;
|
|
2051
|
+
const state = this._states.get(this._current);
|
|
2052
|
+
state?.update?.(this._context, dt);
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Check if a state is registered.
|
|
2056
|
+
*/
|
|
2057
|
+
hasState(name) {
|
|
2058
|
+
return this._states.has(name);
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Check if a transition is allowed (guard passes).
|
|
2062
|
+
*/
|
|
2063
|
+
canTransition(to) {
|
|
2064
|
+
if (this._current === null)
|
|
2065
|
+
return false;
|
|
2066
|
+
const guardKey = `${this._current}->${to}`;
|
|
2067
|
+
const guard = this._guards.get(guardKey);
|
|
2068
|
+
if (!guard)
|
|
2069
|
+
return true;
|
|
2070
|
+
return guard(this._context);
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* Reset the state machine (exit current state, clear current).
|
|
2074
|
+
*/
|
|
2075
|
+
async reset() {
|
|
2076
|
+
if (this._current !== null) {
|
|
2077
|
+
const state = this._states.get(this._current);
|
|
2078
|
+
await state?.exit?.(this._context);
|
|
2079
|
+
}
|
|
2080
|
+
this._current = null;
|
|
2081
|
+
this._transitioning = false;
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Destroy the state machine.
|
|
2085
|
+
*/
|
|
2086
|
+
async destroy() {
|
|
2087
|
+
await this.reset();
|
|
2088
|
+
this._states.clear();
|
|
2089
|
+
this._guards.clear();
|
|
2090
|
+
this.removeAllListeners();
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* Sequential/parallel animation timeline built on top of Tween.
|
|
2096
|
+
*
|
|
2097
|
+
* @example
|
|
2098
|
+
* ```ts
|
|
2099
|
+
* const timeline = new Timeline();
|
|
2100
|
+
*
|
|
2101
|
+
* // Sequential: one after another
|
|
2102
|
+
* timeline
|
|
2103
|
+
* .to(sprite1, { alpha: 1 }, 300)
|
|
2104
|
+
* .to(sprite2, { x: 200 }, 500)
|
|
2105
|
+
* .delay(200)
|
|
2106
|
+
* .call(() => console.log('done'));
|
|
2107
|
+
*
|
|
2108
|
+
* // Parallel: all at once
|
|
2109
|
+
* timeline.parallel(
|
|
2110
|
+
* () => Tween.to(sprite1, { x: 100 }, 300),
|
|
2111
|
+
* () => Tween.to(sprite2, { y: 200 }, 300),
|
|
2112
|
+
* );
|
|
2113
|
+
*
|
|
2114
|
+
* await timeline.play();
|
|
2115
|
+
* ```
|
|
2116
|
+
*/
|
|
2117
|
+
class Timeline {
|
|
2118
|
+
_steps = [];
|
|
2119
|
+
_playing = false;
|
|
2120
|
+
_cancelled = false;
|
|
2121
|
+
/**
|
|
2122
|
+
* Add a tween step (animate to target values).
|
|
2123
|
+
*/
|
|
2124
|
+
to(target, props, duration, easing) {
|
|
2125
|
+
this._steps.push({
|
|
2126
|
+
fn: () => Tween.to(target, props, duration, easing),
|
|
2127
|
+
});
|
|
2128
|
+
return this;
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Add a tween step (animate from given values).
|
|
2132
|
+
*/
|
|
2133
|
+
from(target, props, duration, easing) {
|
|
2134
|
+
this._steps.push({
|
|
2135
|
+
fn: () => Tween.from(target, props, duration, easing),
|
|
2136
|
+
});
|
|
2137
|
+
return this;
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Add a delay step.
|
|
2141
|
+
*/
|
|
2142
|
+
delay(ms) {
|
|
2143
|
+
this._steps.push({
|
|
2144
|
+
fn: () => Tween.delay(ms),
|
|
2145
|
+
});
|
|
2146
|
+
return this;
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* Add a callback step.
|
|
2150
|
+
*/
|
|
2151
|
+
call(fn) {
|
|
2152
|
+
this._steps.push({
|
|
2153
|
+
fn: async () => {
|
|
2154
|
+
await fn();
|
|
2155
|
+
},
|
|
2156
|
+
});
|
|
2157
|
+
return this;
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Add a parallel step — all functions run simultaneously,
|
|
2161
|
+
* step completes when all are done.
|
|
2162
|
+
*/
|
|
2163
|
+
parallel(...fns) {
|
|
2164
|
+
this._steps.push({
|
|
2165
|
+
fn: () => Promise.all(fns.map((f) => f())).then(() => { }),
|
|
2166
|
+
});
|
|
2167
|
+
return this;
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Play the timeline from start.
|
|
2171
|
+
* Returns a promise that resolves when all steps complete.
|
|
2172
|
+
*/
|
|
2173
|
+
async play() {
|
|
2174
|
+
if (this._playing)
|
|
2175
|
+
return;
|
|
2176
|
+
this._playing = true;
|
|
2177
|
+
this._cancelled = false;
|
|
2178
|
+
for (const step of this._steps) {
|
|
2179
|
+
if (this._cancelled)
|
|
2180
|
+
break;
|
|
2181
|
+
await step.fn();
|
|
2182
|
+
}
|
|
2183
|
+
this._playing = false;
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Cancel the timeline.
|
|
2187
|
+
*/
|
|
2188
|
+
cancel() {
|
|
2189
|
+
this._cancelled = true;
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Clear all steps.
|
|
2193
|
+
*/
|
|
2194
|
+
clear() {
|
|
2195
|
+
this._steps.length = 0;
|
|
2196
|
+
this._cancelled = false;
|
|
2197
|
+
this._playing = false;
|
|
2198
|
+
return this;
|
|
2199
|
+
}
|
|
2200
|
+
/** Whether the timeline is currently playing */
|
|
2201
|
+
get isPlaying() {
|
|
2202
|
+
return this._playing;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
/**
|
|
2207
|
+
* Helper for working with Spine animations in PixiJS v8.
|
|
2208
|
+
*
|
|
2209
|
+
* Optional — only works if @esotericsoftware/spine-pixi-v8 is installed.
|
|
2210
|
+
* Provides convenience methods for creating and playing Spine animations.
|
|
2211
|
+
*
|
|
2212
|
+
* @example
|
|
2213
|
+
* ```ts
|
|
2214
|
+
* // Load spine assets via AssetManager first
|
|
2215
|
+
* const spine = SpineHelper.create('char-data', 'char-atlas');
|
|
2216
|
+
* app.stage.addChild(spine);
|
|
2217
|
+
* await SpineHelper.playAnimation(spine, 'idle', true);
|
|
2218
|
+
* await SpineHelper.playAnimation(spine, 'win');
|
|
2219
|
+
* ```
|
|
2220
|
+
*/
|
|
2221
|
+
class SpineHelper {
|
|
2222
|
+
static _SpineClass = null;
|
|
2223
|
+
static _loaded = false;
|
|
2224
|
+
/**
|
|
2225
|
+
* Ensure the Spine module is loaded.
|
|
2226
|
+
* Called automatically by create/playAnimation.
|
|
2227
|
+
*/
|
|
2228
|
+
static async ensureLoaded() {
|
|
2229
|
+
if (SpineHelper._loaded)
|
|
2230
|
+
return !!SpineHelper._SpineClass;
|
|
2231
|
+
try {
|
|
2232
|
+
const spineModule = await import('@esotericsoftware/spine-pixi-v8');
|
|
2233
|
+
SpineHelper._SpineClass = spineModule.Spine;
|
|
2234
|
+
SpineHelper._loaded = true;
|
|
2235
|
+
return true;
|
|
2236
|
+
}
|
|
2237
|
+
catch {
|
|
2238
|
+
console.warn('[SpineHelper] @esotericsoftware/spine-pixi-v8 not available.');
|
|
2239
|
+
SpineHelper._loaded = true;
|
|
2240
|
+
return false;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Create a Spine display object.
|
|
2245
|
+
*
|
|
2246
|
+
* @param skeletonAlias - Alias of the loaded .skel/.json asset
|
|
2247
|
+
* @param atlasAlias - Alias of the loaded .atlas asset
|
|
2248
|
+
* @param options - Additional Spine options
|
|
2249
|
+
* @returns Spine container (or null if Spine is not available)
|
|
2250
|
+
*/
|
|
2251
|
+
static async create(skeletonAlias, atlasAlias, options) {
|
|
2252
|
+
const available = await SpineHelper.ensureLoaded();
|
|
2253
|
+
if (!available || !SpineHelper._SpineClass)
|
|
2254
|
+
return null;
|
|
2255
|
+
const SpineClass = SpineHelper._SpineClass;
|
|
2256
|
+
// Use Spine.from for v4.2, constructor for v4.3+
|
|
2257
|
+
if (typeof SpineClass.from === 'function') {
|
|
2258
|
+
return SpineClass.from({
|
|
2259
|
+
skeleton: skeletonAlias,
|
|
2260
|
+
atlas: atlasAlias,
|
|
2261
|
+
scale: options?.scale,
|
|
2262
|
+
autoUpdate: options?.autoUpdate ?? true,
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
return new SpineClass({
|
|
2266
|
+
skeleton: skeletonAlias,
|
|
2267
|
+
atlas: atlasAlias,
|
|
2268
|
+
scale: options?.scale,
|
|
2269
|
+
autoUpdate: options?.autoUpdate ?? true,
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Play a named animation on a Spine object.
|
|
2274
|
+
* Returns a promise that resolves when the animation completes.
|
|
2275
|
+
* For looping animations, the promise never resolves.
|
|
2276
|
+
*
|
|
2277
|
+
* @param spine - Spine display object
|
|
2278
|
+
* @param animationName - Name of the animation
|
|
2279
|
+
* @param loop - Whether to loop (default: false)
|
|
2280
|
+
* @param track - Animation track (default: 0)
|
|
2281
|
+
*/
|
|
2282
|
+
static playAnimation(spine, animationName, loop = false, track = 0) {
|
|
2283
|
+
return new Promise((resolve) => {
|
|
2284
|
+
if (!spine?.state) {
|
|
2285
|
+
resolve();
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
const entry = spine.state.setAnimation(track, animationName, loop);
|
|
2289
|
+
if (loop) {
|
|
2290
|
+
// Looping animations never complete — resolve immediately
|
|
2291
|
+
// so callers can fire-and-forget
|
|
2292
|
+
resolve();
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
// Wait for the animation to complete
|
|
2296
|
+
spine.state.addListener({
|
|
2297
|
+
complete: (completedEntry) => {
|
|
2298
|
+
if (completedEntry === entry) {
|
|
2299
|
+
resolve();
|
|
2300
|
+
}
|
|
2301
|
+
},
|
|
2302
|
+
});
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Queue an animation after the current one finishes.
|
|
2307
|
+
*
|
|
2308
|
+
* @param spine - Spine display object
|
|
2309
|
+
* @param animationName - Animation name
|
|
2310
|
+
* @param delay - Mix duration / delay before starting
|
|
2311
|
+
* @param loop - Loop the queued animation
|
|
2312
|
+
* @param track - Animation track
|
|
2313
|
+
*/
|
|
2314
|
+
static addAnimation(spine, animationName, delay = 0, loop = false, track = 0) {
|
|
2315
|
+
spine?.state?.addAnimation(track, animationName, loop, delay);
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Get all animation names available on a Spine skeleton.
|
|
2319
|
+
*/
|
|
2320
|
+
static getAnimationNames(spine) {
|
|
2321
|
+
if (!spine?.skeleton?.data?.animations)
|
|
2322
|
+
return [];
|
|
2323
|
+
return spine.skeleton.data.animations.map((a) => a.name);
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Get all skin names available on a Spine skeleton.
|
|
2327
|
+
*/
|
|
2328
|
+
static getSkinNames(spine) {
|
|
2329
|
+
if (!spine?.skeleton?.data?.skins)
|
|
2330
|
+
return [];
|
|
2331
|
+
return spine.skeleton.data.skins.map((s) => s.name);
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Set the active skin on a Spine skeleton.
|
|
2335
|
+
*/
|
|
2336
|
+
static setSkin(spine, skinName) {
|
|
2337
|
+
spine?.skeleton?.setSkinByName(skinName);
|
|
2338
|
+
spine?.skeleton?.setSlotsToSetupPose();
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* Unified input manager for touch, mouse, and keyboard.
|
|
2344
|
+
*
|
|
2345
|
+
* Features:
|
|
2346
|
+
* - Unified pointer events (works with touch + mouse)
|
|
2347
|
+
* - Swipe gesture detection
|
|
2348
|
+
* - Keyboard input with isKeyDown state
|
|
2349
|
+
* - Input locking (block input during animations)
|
|
2350
|
+
*
|
|
2351
|
+
* @example
|
|
2352
|
+
* ```ts
|
|
2353
|
+
* const input = new InputManager(app.canvas);
|
|
2354
|
+
*
|
|
2355
|
+
* input.on('tap', ({ x, y }) => console.log('Tapped at', x, y));
|
|
2356
|
+
* input.on('swipe', ({ direction }) => console.log('Swiped', direction));
|
|
2357
|
+
* input.on('keydown', ({ key }) => {
|
|
2358
|
+
* if (key === ' ') spin();
|
|
2359
|
+
* });
|
|
2360
|
+
*
|
|
2361
|
+
* // Block input during animations
|
|
2362
|
+
* input.lock();
|
|
2363
|
+
* await playAnimation();
|
|
2364
|
+
* input.unlock();
|
|
2365
|
+
* ```
|
|
2366
|
+
*/
|
|
2367
|
+
class InputManager extends EventEmitter {
|
|
2368
|
+
_canvas;
|
|
2369
|
+
_locked = false;
|
|
2370
|
+
_keysDown = new Set();
|
|
2371
|
+
_destroyed = false;
|
|
2372
|
+
// Gesture tracking
|
|
2373
|
+
_pointerStart = null;
|
|
2374
|
+
_swipeThreshold = 50; // minimum distance in px
|
|
2375
|
+
_swipeMaxTime = 300; // max ms for swipe gesture
|
|
2376
|
+
constructor(canvas) {
|
|
2377
|
+
super();
|
|
2378
|
+
this._canvas = canvas;
|
|
2379
|
+
this.setupPointerEvents();
|
|
2380
|
+
this.setupKeyboardEvents();
|
|
2381
|
+
}
|
|
2382
|
+
/** Whether input is currently locked */
|
|
2383
|
+
get locked() {
|
|
2384
|
+
return this._locked;
|
|
2385
|
+
}
|
|
2386
|
+
/** Lock all input (e.g., during animations) */
|
|
2387
|
+
lock() {
|
|
2388
|
+
this._locked = true;
|
|
2389
|
+
}
|
|
2390
|
+
/** Unlock input */
|
|
2391
|
+
unlock() {
|
|
2392
|
+
this._locked = false;
|
|
2393
|
+
}
|
|
2394
|
+
/** Check if a key is currently pressed */
|
|
2395
|
+
isKeyDown(key) {
|
|
2396
|
+
return this._keysDown.has(key.toLowerCase());
|
|
2397
|
+
}
|
|
2398
|
+
/** Destroy the input manager */
|
|
2399
|
+
destroy() {
|
|
2400
|
+
this._destroyed = true;
|
|
2401
|
+
this._canvas.removeEventListener('pointerdown', this.onPointerDown);
|
|
2402
|
+
this._canvas.removeEventListener('pointerup', this.onPointerUp);
|
|
2403
|
+
this._canvas.removeEventListener('pointermove', this.onPointerMove);
|
|
2404
|
+
document.removeEventListener('keydown', this.onKeyDown);
|
|
2405
|
+
document.removeEventListener('keyup', this.onKeyUp);
|
|
2406
|
+
this._keysDown.clear();
|
|
2407
|
+
this.removeAllListeners();
|
|
2408
|
+
}
|
|
2409
|
+
// ─── Private: Pointer ──────────────────────────────────
|
|
2410
|
+
setupPointerEvents() {
|
|
2411
|
+
this._canvas.addEventListener('pointerdown', this.onPointerDown);
|
|
2412
|
+
this._canvas.addEventListener('pointerup', this.onPointerUp);
|
|
2413
|
+
this._canvas.addEventListener('pointermove', this.onPointerMove);
|
|
2414
|
+
}
|
|
2415
|
+
onPointerDown = (e) => {
|
|
2416
|
+
if (this._locked || this._destroyed)
|
|
2417
|
+
return;
|
|
2418
|
+
const pos = this.getCanvasPosition(e);
|
|
2419
|
+
this._pointerStart = { ...pos, time: Date.now() };
|
|
2420
|
+
this.emit('press', pos);
|
|
2421
|
+
};
|
|
2422
|
+
onPointerUp = (e) => {
|
|
2423
|
+
if (this._locked || this._destroyed)
|
|
2424
|
+
return;
|
|
2425
|
+
const pos = this.getCanvasPosition(e);
|
|
2426
|
+
this.emit('release', pos);
|
|
2427
|
+
// Check for tap vs swipe
|
|
2428
|
+
if (this._pointerStart) {
|
|
2429
|
+
const dx = pos.x - this._pointerStart.x;
|
|
2430
|
+
const dy = pos.y - this._pointerStart.y;
|
|
2431
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
2432
|
+
const elapsed = Date.now() - this._pointerStart.time;
|
|
2433
|
+
if (dist > this._swipeThreshold && elapsed < this._swipeMaxTime) {
|
|
2434
|
+
// Swipe detected
|
|
2435
|
+
const absDx = Math.abs(dx);
|
|
2436
|
+
const absDy = Math.abs(dy);
|
|
2437
|
+
let direction;
|
|
2438
|
+
if (absDx > absDy) {
|
|
2439
|
+
direction = dx > 0 ? 'right' : 'left';
|
|
2440
|
+
}
|
|
2441
|
+
else {
|
|
2442
|
+
direction = dy > 0 ? 'down' : 'up';
|
|
2443
|
+
}
|
|
2444
|
+
this.emit('swipe', { direction, velocity: dist / elapsed });
|
|
2445
|
+
}
|
|
2446
|
+
else if (dist < 10) {
|
|
2447
|
+
// Tap (minimal movement)
|
|
2448
|
+
this.emit('tap', pos);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
this._pointerStart = null;
|
|
2452
|
+
};
|
|
2453
|
+
onPointerMove = (e) => {
|
|
2454
|
+
if (this._locked || this._destroyed)
|
|
2455
|
+
return;
|
|
2456
|
+
this.emit('move', this.getCanvasPosition(e));
|
|
2457
|
+
};
|
|
2458
|
+
getCanvasPosition(e) {
|
|
2459
|
+
const rect = this._canvas.getBoundingClientRect();
|
|
2460
|
+
return {
|
|
2461
|
+
x: e.clientX - rect.left,
|
|
2462
|
+
y: e.clientY - rect.top,
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
// ─── Private: Keyboard ─────────────────────────────────
|
|
2466
|
+
setupKeyboardEvents() {
|
|
2467
|
+
document.addEventListener('keydown', this.onKeyDown);
|
|
2468
|
+
document.addEventListener('keyup', this.onKeyUp);
|
|
2469
|
+
}
|
|
2470
|
+
onKeyDown = (e) => {
|
|
2471
|
+
if (this._locked || this._destroyed)
|
|
2472
|
+
return;
|
|
2473
|
+
this._keysDown.add(e.key.toLowerCase());
|
|
2474
|
+
this.emit('keydown', { key: e.key, code: e.code });
|
|
2475
|
+
};
|
|
2476
|
+
onKeyUp = (e) => {
|
|
2477
|
+
if (this._destroyed)
|
|
2478
|
+
return;
|
|
2479
|
+
this._keysDown.delete(e.key.toLowerCase());
|
|
2480
|
+
if (this._locked)
|
|
2481
|
+
return;
|
|
2482
|
+
this.emit('keyup', { key: e.key, code: e.code });
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
const DEFAULT_COLORS = {
|
|
2487
|
+
normal: 0xffd700,
|
|
2488
|
+
hover: 0xffe44d,
|
|
2489
|
+
pressed: 0xccac00,
|
|
2490
|
+
disabled: 0x666666,
|
|
2491
|
+
};
|
|
2492
|
+
/**
|
|
2493
|
+
* Interactive button component with state management and animation.
|
|
2494
|
+
*
|
|
2495
|
+
* Supports both texture-based and Graphics-based rendering.
|
|
2496
|
+
*
|
|
2497
|
+
* @example
|
|
2498
|
+
* ```ts
|
|
2499
|
+
* const btn = new Button({
|
|
2500
|
+
* width: 200, height: 60, borderRadius: 12,
|
|
2501
|
+
* colors: { normal: 0x22aa22, hover: 0x33cc33 },
|
|
2502
|
+
* });
|
|
2503
|
+
*
|
|
2504
|
+
* btn.onTap = () => console.log('Clicked!');
|
|
2505
|
+
* scene.container.addChild(btn);
|
|
2506
|
+
* ```
|
|
2507
|
+
*/
|
|
2508
|
+
class Button extends Container {
|
|
2509
|
+
_state = 'normal';
|
|
2510
|
+
_bg;
|
|
2511
|
+
_sprites = {};
|
|
2512
|
+
_config;
|
|
2513
|
+
/** Called when the button is tapped/clicked */
|
|
2514
|
+
onTap;
|
|
2515
|
+
/** Called when the button state changes */
|
|
2516
|
+
onStateChange;
|
|
2517
|
+
constructor(config = {}) {
|
|
2518
|
+
super();
|
|
2519
|
+
this._config = {
|
|
2520
|
+
width: 200,
|
|
2521
|
+
height: 60,
|
|
2522
|
+
borderRadius: 8,
|
|
2523
|
+
pressScale: 0.95,
|
|
2524
|
+
animationDuration: 100,
|
|
2525
|
+
...config,
|
|
2526
|
+
};
|
|
2527
|
+
// Create Graphics background
|
|
2528
|
+
this._bg = new Graphics();
|
|
2529
|
+
this.addChild(this._bg);
|
|
2530
|
+
// Create texture sprites if provided
|
|
2531
|
+
if (config.textures) {
|
|
2532
|
+
for (const [state, tex] of Object.entries(config.textures)) {
|
|
2533
|
+
const texture = typeof tex === 'string' ? Texture.from(tex) : tex;
|
|
2534
|
+
const sprite = new Sprite(texture);
|
|
2535
|
+
sprite.anchor.set(0.5);
|
|
2536
|
+
sprite.visible = state === 'normal';
|
|
2537
|
+
this._sprites[state] = sprite;
|
|
2538
|
+
this.addChild(sprite);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
// Make interactive
|
|
2542
|
+
this.eventMode = 'static';
|
|
2543
|
+
this.cursor = 'pointer';
|
|
2544
|
+
// Set up hit area for Graphics-based
|
|
2545
|
+
this.pivot.set(this._config.width / 2, this._config.height / 2);
|
|
2546
|
+
// Bind events
|
|
2547
|
+
this.on('pointerover', this.onPointerOver);
|
|
2548
|
+
this.on('pointerout', this.onPointerOut);
|
|
2549
|
+
this.on('pointerdown', this.onPointerDown);
|
|
2550
|
+
this.on('pointerup', this.onPointerUp);
|
|
2551
|
+
this.on('pointertap', this.onPointerTap);
|
|
2552
|
+
// Initial render
|
|
2553
|
+
this.setState('normal');
|
|
2554
|
+
if (config.disabled) {
|
|
2555
|
+
this.disable();
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
/** Current button state */
|
|
2559
|
+
get state() {
|
|
2560
|
+
return this._state;
|
|
2561
|
+
}
|
|
2562
|
+
/** Enable the button */
|
|
2563
|
+
enable() {
|
|
2564
|
+
if (this._state === 'disabled') {
|
|
2565
|
+
this.setState('normal');
|
|
2566
|
+
this.eventMode = 'static';
|
|
2567
|
+
this.cursor = 'pointer';
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
/** Disable the button */
|
|
2571
|
+
disable() {
|
|
2572
|
+
this.setState('disabled');
|
|
2573
|
+
this.eventMode = 'none';
|
|
2574
|
+
this.cursor = 'default';
|
|
2575
|
+
}
|
|
2576
|
+
/** Whether the button is disabled */
|
|
2577
|
+
get disabled() {
|
|
2578
|
+
return this._state === 'disabled';
|
|
2579
|
+
}
|
|
2580
|
+
setState(state) {
|
|
2581
|
+
if (this._state === state)
|
|
2582
|
+
return;
|
|
2583
|
+
this._state = state;
|
|
2584
|
+
this.render();
|
|
2585
|
+
this.onStateChange?.(state);
|
|
2586
|
+
}
|
|
2587
|
+
render() {
|
|
2588
|
+
const { width, height, borderRadius, colors } = this._config;
|
|
2589
|
+
const colorMap = { ...DEFAULT_COLORS, ...colors };
|
|
2590
|
+
// Update Graphics
|
|
2591
|
+
this._bg.clear();
|
|
2592
|
+
this._bg.roundRect(0, 0, width, height, borderRadius).fill(colorMap[this._state]);
|
|
2593
|
+
// Add highlight for normal/hover
|
|
2594
|
+
if (this._state === 'normal' || this._state === 'hover') {
|
|
2595
|
+
this._bg
|
|
2596
|
+
.roundRect(2, 2, width - 4, height * 0.45, borderRadius)
|
|
2597
|
+
.fill({ color: 0xffffff, alpha: 0.1 });
|
|
2598
|
+
}
|
|
2599
|
+
// Update sprite visibility
|
|
2600
|
+
for (const [state, sprite] of Object.entries(this._sprites)) {
|
|
2601
|
+
if (sprite)
|
|
2602
|
+
sprite.visible = state === this._state;
|
|
2603
|
+
}
|
|
2604
|
+
// Fall back to normal sprite if state sprite doesn't exist
|
|
2605
|
+
if (!this._sprites[this._state] && this._sprites.normal) {
|
|
2606
|
+
this._sprites.normal.visible = true;
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
onPointerOver = () => {
|
|
2610
|
+
if (this._state === 'disabled')
|
|
2611
|
+
return;
|
|
2612
|
+
this.setState('hover');
|
|
2613
|
+
};
|
|
2614
|
+
onPointerOut = () => {
|
|
2615
|
+
if (this._state === 'disabled')
|
|
2616
|
+
return;
|
|
2617
|
+
this.setState('normal');
|
|
2618
|
+
Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration);
|
|
2619
|
+
};
|
|
2620
|
+
onPointerDown = () => {
|
|
2621
|
+
if (this._state === 'disabled')
|
|
2622
|
+
return;
|
|
2623
|
+
this.setState('pressed');
|
|
2624
|
+
const s = this._config.pressScale;
|
|
2625
|
+
Tween.to(this.scale, { x: s, y: s }, this._config.animationDuration, Easing.easeOutQuad);
|
|
2626
|
+
};
|
|
2627
|
+
onPointerUp = () => {
|
|
2628
|
+
if (this._state === 'disabled')
|
|
2629
|
+
return;
|
|
2630
|
+
this.setState('hover');
|
|
2631
|
+
Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration, Easing.easeOutBack);
|
|
2632
|
+
};
|
|
2633
|
+
onPointerTap = () => {
|
|
2634
|
+
if (this._state === 'disabled')
|
|
2635
|
+
return;
|
|
2636
|
+
this.onTap?.();
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
/**
|
|
2641
|
+
* Horizontal progress bar with optional smooth fill animation.
|
|
2642
|
+
*
|
|
2643
|
+
* @example
|
|
2644
|
+
* ```ts
|
|
2645
|
+
* const bar = new ProgressBar({ width: 300, height: 20, fillColor: 0x22cc22 });
|
|
2646
|
+
* scene.container.addChild(bar);
|
|
2647
|
+
* bar.progress = 0.5; // 50%
|
|
2648
|
+
* ```
|
|
2649
|
+
*/
|
|
2650
|
+
class ProgressBar extends Container {
|
|
2651
|
+
_track;
|
|
2652
|
+
_fill;
|
|
2653
|
+
_border;
|
|
2654
|
+
_config;
|
|
2655
|
+
_progress = 0;
|
|
2656
|
+
_displayedProgress = 0;
|
|
2657
|
+
constructor(config = {}) {
|
|
2658
|
+
super();
|
|
2659
|
+
this._config = {
|
|
2660
|
+
width: 300,
|
|
2661
|
+
height: 16,
|
|
2662
|
+
borderRadius: 8,
|
|
2663
|
+
fillColor: 0xffd700,
|
|
2664
|
+
trackColor: 0x333333,
|
|
2665
|
+
borderColor: 0x555555,
|
|
2666
|
+
borderWidth: 1,
|
|
2667
|
+
animated: true,
|
|
2668
|
+
animationSpeed: 0.1,
|
|
2669
|
+
...config,
|
|
2670
|
+
};
|
|
2671
|
+
this._track = new Graphics();
|
|
2672
|
+
this._fill = new Graphics();
|
|
2673
|
+
this._border = new Graphics();
|
|
2674
|
+
this.addChild(this._track, this._fill, this._border);
|
|
2675
|
+
this.drawTrack();
|
|
2676
|
+
this.drawBorder();
|
|
2677
|
+
this.drawFill(0);
|
|
2678
|
+
}
|
|
2679
|
+
/** Get/set progress (0..1) */
|
|
2680
|
+
get progress() {
|
|
2681
|
+
return this._progress;
|
|
2682
|
+
}
|
|
2683
|
+
set progress(value) {
|
|
2684
|
+
this._progress = Math.max(0, Math.min(1, value));
|
|
2685
|
+
if (!this._config.animated) {
|
|
2686
|
+
this._displayedProgress = this._progress;
|
|
2687
|
+
this.drawFill(this._displayedProgress);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Call each frame if animated is true.
|
|
2692
|
+
*/
|
|
2693
|
+
update(dt) {
|
|
2694
|
+
if (!this._config.animated)
|
|
2695
|
+
return;
|
|
2696
|
+
if (Math.abs(this._displayedProgress - this._progress) < 0.001) {
|
|
2697
|
+
this._displayedProgress = this._progress;
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
this._displayedProgress +=
|
|
2701
|
+
(this._progress - this._displayedProgress) * this._config.animationSpeed;
|
|
2702
|
+
this.drawFill(this._displayedProgress);
|
|
2703
|
+
}
|
|
2704
|
+
drawTrack() {
|
|
2705
|
+
const { width, height, borderRadius, trackColor } = this._config;
|
|
2706
|
+
this._track.clear();
|
|
2707
|
+
this._track.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
|
|
2708
|
+
}
|
|
2709
|
+
drawBorder() {
|
|
2710
|
+
const { width, height, borderRadius, borderColor, borderWidth } = this._config;
|
|
2711
|
+
this._border.clear();
|
|
2712
|
+
this._border
|
|
2713
|
+
.roundRect(0, 0, width, height, borderRadius)
|
|
2714
|
+
.stroke({ color: borderColor, width: borderWidth });
|
|
2715
|
+
}
|
|
2716
|
+
drawFill(progress) {
|
|
2717
|
+
const { width, height, borderRadius, fillColor, borderWidth } = this._config;
|
|
2718
|
+
const innerWidth = width - borderWidth * 2;
|
|
2719
|
+
const innerHeight = height - borderWidth * 2;
|
|
2720
|
+
const fillWidth = Math.max(0, innerWidth * progress);
|
|
2721
|
+
this._fill.clear();
|
|
2722
|
+
if (fillWidth > 0) {
|
|
2723
|
+
this._fill.x = borderWidth;
|
|
2724
|
+
this._fill.y = borderWidth;
|
|
2725
|
+
this._fill.roundRect(0, 0, fillWidth, innerHeight, borderRadius - 1).fill(fillColor);
|
|
2726
|
+
// Highlight
|
|
2727
|
+
this._fill
|
|
2728
|
+
.roundRect(0, 0, fillWidth, innerHeight * 0.4, borderRadius - 1)
|
|
2729
|
+
.fill({ color: 0xffffff, alpha: 0.15 });
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
/**
|
|
2735
|
+
* Enhanced text label with auto-fit scaling and currency formatting.
|
|
2736
|
+
*
|
|
2737
|
+
* @example
|
|
2738
|
+
* ```ts
|
|
2739
|
+
* const label = new Label({
|
|
2740
|
+
* text: 'BALANCE',
|
|
2741
|
+
* style: { fontSize: 24, fill: 0xffd700 },
|
|
2742
|
+
* maxWidth: 200,
|
|
2743
|
+
* autoFit: true,
|
|
2744
|
+
* });
|
|
2745
|
+
* ```
|
|
2746
|
+
*/
|
|
2747
|
+
class Label extends Container {
|
|
2748
|
+
_text;
|
|
2749
|
+
_maxWidth;
|
|
2750
|
+
_autoFit;
|
|
2751
|
+
constructor(config = {}) {
|
|
2752
|
+
super();
|
|
2753
|
+
this._maxWidth = config.maxWidth ?? Infinity;
|
|
2754
|
+
this._autoFit = config.autoFit ?? false;
|
|
2755
|
+
this._text = new Text({
|
|
2756
|
+
text: config.text ?? '',
|
|
2757
|
+
style: {
|
|
2758
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
2759
|
+
fontSize: 24,
|
|
2760
|
+
fill: 0xffffff,
|
|
2761
|
+
...config.style,
|
|
2762
|
+
},
|
|
2763
|
+
});
|
|
2764
|
+
this._text.anchor.set(0.5);
|
|
2765
|
+
this.addChild(this._text);
|
|
2766
|
+
this.fitText();
|
|
2767
|
+
}
|
|
2768
|
+
/** Get/set the displayed text */
|
|
2769
|
+
get text() {
|
|
2770
|
+
return this._text.text;
|
|
2771
|
+
}
|
|
2772
|
+
set text(value) {
|
|
2773
|
+
this._text.text = value;
|
|
2774
|
+
this.fitText();
|
|
2775
|
+
}
|
|
2776
|
+
/** Get/set the text style */
|
|
2777
|
+
get style() {
|
|
2778
|
+
return this._text.style;
|
|
2779
|
+
}
|
|
2780
|
+
/** Set max width constraint */
|
|
2781
|
+
set maxWidth(value) {
|
|
2782
|
+
this._maxWidth = value;
|
|
2783
|
+
this.fitText();
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* Format and display a number as currency.
|
|
2787
|
+
*
|
|
2788
|
+
* @param amount - The numeric amount
|
|
2789
|
+
* @param currency - Currency code (e.g., 'USD', 'EUR')
|
|
2790
|
+
* @param locale - Locale string (default: 'en-US')
|
|
2791
|
+
*/
|
|
2792
|
+
setCurrency(amount, currency, locale = 'en-US') {
|
|
2793
|
+
try {
|
|
2794
|
+
this.text = new Intl.NumberFormat(locale, {
|
|
2795
|
+
style: 'currency',
|
|
2796
|
+
currency,
|
|
2797
|
+
minimumFractionDigits: 2,
|
|
2798
|
+
maximumFractionDigits: 2,
|
|
2799
|
+
}).format(amount);
|
|
2800
|
+
}
|
|
2801
|
+
catch {
|
|
2802
|
+
this.text = `${amount.toFixed(2)} ${currency}`;
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Format a number with thousands separators.
|
|
2807
|
+
*/
|
|
2808
|
+
setNumber(value, decimals = 0, locale = 'en-US') {
|
|
2809
|
+
this.text = new Intl.NumberFormat(locale, {
|
|
2810
|
+
minimumFractionDigits: decimals,
|
|
2811
|
+
maximumFractionDigits: decimals,
|
|
2812
|
+
}).format(value);
|
|
2813
|
+
}
|
|
2814
|
+
fitText() {
|
|
2815
|
+
if (!this._autoFit || this._maxWidth === Infinity)
|
|
2816
|
+
return;
|
|
2817
|
+
this._text.scale.set(1);
|
|
2818
|
+
if (this._text.width > this._maxWidth) {
|
|
2819
|
+
const scale = this._maxWidth / this._text.width;
|
|
2820
|
+
this._text.scale.set(scale);
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
/**
|
|
2826
|
+
* Background panel that can use either Graphics or 9-slice sprite.
|
|
2827
|
+
*
|
|
2828
|
+
* @example
|
|
2829
|
+
* ```ts
|
|
2830
|
+
* // Simple colored panel
|
|
2831
|
+
* const panel = new Panel({ width: 400, height: 300, backgroundColor: 0x222222, borderRadius: 12 });
|
|
2832
|
+
*
|
|
2833
|
+
* // 9-slice panel (texture-based)
|
|
2834
|
+
* const panel = new Panel({
|
|
2835
|
+
* nineSliceTexture: 'panel-bg',
|
|
2836
|
+
* nineSliceBorders: [20, 20, 20, 20],
|
|
2837
|
+
* width: 400, height: 300,
|
|
2838
|
+
* });
|
|
2839
|
+
* ```
|
|
2840
|
+
*/
|
|
2841
|
+
class Panel extends Container {
|
|
2842
|
+
_bg;
|
|
2843
|
+
_content;
|
|
2844
|
+
_config;
|
|
2845
|
+
constructor(config = {}) {
|
|
2846
|
+
super();
|
|
2847
|
+
this._config = {
|
|
2848
|
+
width: 400,
|
|
2849
|
+
height: 300,
|
|
2850
|
+
padding: 16,
|
|
2851
|
+
backgroundAlpha: 1,
|
|
2852
|
+
...config,
|
|
2853
|
+
};
|
|
2854
|
+
// Create background
|
|
2855
|
+
if (config.nineSliceTexture) {
|
|
2856
|
+
const texture = typeof config.nineSliceTexture === 'string'
|
|
2857
|
+
? Texture.from(config.nineSliceTexture)
|
|
2858
|
+
: config.nineSliceTexture;
|
|
2859
|
+
const [left, top, right, bottom] = config.nineSliceBorders ?? [10, 10, 10, 10];
|
|
2860
|
+
this._bg = new NineSliceSprite({
|
|
2861
|
+
texture,
|
|
2862
|
+
leftWidth: left,
|
|
2863
|
+
topHeight: top,
|
|
2864
|
+
rightWidth: right,
|
|
2865
|
+
bottomHeight: bottom,
|
|
2866
|
+
});
|
|
2867
|
+
this._bg.width = this._config.width;
|
|
2868
|
+
this._bg.height = this._config.height;
|
|
2869
|
+
}
|
|
2870
|
+
else {
|
|
2871
|
+
this._bg = new Graphics();
|
|
2872
|
+
this.drawGraphicsBg();
|
|
2873
|
+
}
|
|
2874
|
+
this._bg.alpha = this._config.backgroundAlpha;
|
|
2875
|
+
this.addChild(this._bg);
|
|
2876
|
+
// Content container with padding
|
|
2877
|
+
this._content = new Container();
|
|
2878
|
+
this._content.x = this._config.padding;
|
|
2879
|
+
this._content.y = this._config.padding;
|
|
2880
|
+
this.addChild(this._content);
|
|
2881
|
+
}
|
|
2882
|
+
/** Content container — add children here */
|
|
2883
|
+
get content() {
|
|
2884
|
+
return this._content;
|
|
2885
|
+
}
|
|
2886
|
+
/** Resize the panel */
|
|
2887
|
+
setSize(width, height) {
|
|
2888
|
+
this._config.width = width;
|
|
2889
|
+
this._config.height = height;
|
|
2890
|
+
if (this._bg instanceof Graphics) {
|
|
2891
|
+
this.drawGraphicsBg();
|
|
2892
|
+
}
|
|
2893
|
+
else {
|
|
2894
|
+
this._bg.width = width;
|
|
2895
|
+
this._bg.height = height;
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
drawGraphicsBg() {
|
|
2899
|
+
const bg = this._bg;
|
|
2900
|
+
const { width, height, backgroundColor, borderRadius, borderColor, borderWidth, } = this._config;
|
|
2901
|
+
bg.clear();
|
|
2902
|
+
bg.roundRect(0, 0, width, height, borderRadius ?? 0).fill(backgroundColor ?? 0x1a1a2e);
|
|
2903
|
+
if (borderColor !== undefined && borderWidth) {
|
|
2904
|
+
bg.roundRect(0, 0, width, height, borderRadius ?? 0)
|
|
2905
|
+
.stroke({ color: borderColor, width: borderWidth });
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
/**
|
|
2911
|
+
* Reactive balance display component.
|
|
2912
|
+
*
|
|
2913
|
+
* Automatically formats currency and can animate value changes
|
|
2914
|
+
* with a smooth countup/countdown effect.
|
|
2915
|
+
*
|
|
2916
|
+
* @example
|
|
2917
|
+
* ```ts
|
|
2918
|
+
* const balance = new BalanceDisplay({ currency: 'USD', animated: true });
|
|
2919
|
+
* balance.setValue(1000);
|
|
2920
|
+
*
|
|
2921
|
+
* // Wire to SDK
|
|
2922
|
+
* sdk.on('balanceUpdate', ({ balance: val }) => balance.setValue(val));
|
|
2923
|
+
* ```
|
|
2924
|
+
*/
|
|
2925
|
+
class BalanceDisplay extends Container {
|
|
2926
|
+
_prefixLabel = null;
|
|
2927
|
+
_valueLabel;
|
|
2928
|
+
_config;
|
|
2929
|
+
_currentValue = 0;
|
|
2930
|
+
_displayedValue = 0;
|
|
2931
|
+
_animating = false;
|
|
2932
|
+
constructor(config = {}) {
|
|
2933
|
+
super();
|
|
2934
|
+
this._config = {
|
|
2935
|
+
currency: config.currency ?? 'USD',
|
|
2936
|
+
locale: config.locale ?? 'en-US',
|
|
2937
|
+
animated: config.animated ?? true,
|
|
2938
|
+
animationDuration: config.animationDuration ?? 500,
|
|
2939
|
+
};
|
|
2940
|
+
// Prefix label
|
|
2941
|
+
if (config.prefix) {
|
|
2942
|
+
this._prefixLabel = new Label({
|
|
2943
|
+
text: config.prefix,
|
|
2944
|
+
style: {
|
|
2945
|
+
fontSize: 16,
|
|
2946
|
+
fill: 0xaaaaaa,
|
|
2947
|
+
...config.style,
|
|
2948
|
+
},
|
|
2949
|
+
});
|
|
2950
|
+
this.addChild(this._prefixLabel);
|
|
2951
|
+
}
|
|
2952
|
+
// Value label
|
|
2953
|
+
this._valueLabel = new Label({
|
|
2954
|
+
text: '0.00',
|
|
2955
|
+
style: {
|
|
2956
|
+
fontSize: 28,
|
|
2957
|
+
fontWeight: 'bold',
|
|
2958
|
+
fill: 0xffffff,
|
|
2959
|
+
...config.style,
|
|
2960
|
+
},
|
|
2961
|
+
maxWidth: config.maxWidth,
|
|
2962
|
+
autoFit: !!config.maxWidth,
|
|
2963
|
+
});
|
|
2964
|
+
this.addChild(this._valueLabel);
|
|
2965
|
+
this.layoutLabels();
|
|
2966
|
+
}
|
|
2967
|
+
/** Current displayed value */
|
|
2968
|
+
get value() {
|
|
2969
|
+
return this._currentValue;
|
|
2970
|
+
}
|
|
2971
|
+
/**
|
|
2972
|
+
* Set the balance value. If animated, smoothly counts to the new value.
|
|
2973
|
+
*/
|
|
2974
|
+
setValue(value) {
|
|
2975
|
+
const oldValue = this._currentValue;
|
|
2976
|
+
this._currentValue = value;
|
|
2977
|
+
if (this._config.animated && oldValue !== value) {
|
|
2978
|
+
this.animateValue(oldValue, value);
|
|
2979
|
+
}
|
|
2980
|
+
else {
|
|
2981
|
+
this._displayedValue = value;
|
|
2982
|
+
this.updateDisplay();
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Set the currency code.
|
|
2987
|
+
*/
|
|
2988
|
+
setCurrency(currency) {
|
|
2989
|
+
this._config.currency = currency;
|
|
2990
|
+
this.updateDisplay();
|
|
2991
|
+
}
|
|
2992
|
+
async animateValue(from, to) {
|
|
2993
|
+
this._animating = true;
|
|
2994
|
+
const duration = this._config.animationDuration;
|
|
2995
|
+
const startTime = Date.now();
|
|
2996
|
+
return new Promise((resolve) => {
|
|
2997
|
+
const tick = () => {
|
|
2998
|
+
const elapsed = Date.now() - startTime;
|
|
2999
|
+
const t = Math.min(elapsed / duration, 1);
|
|
3000
|
+
const eased = Easing.easeOutCubic(t);
|
|
3001
|
+
this._displayedValue = from + (to - from) * eased;
|
|
3002
|
+
this.updateDisplay();
|
|
3003
|
+
if (t < 1) {
|
|
3004
|
+
requestAnimationFrame(tick);
|
|
3005
|
+
}
|
|
3006
|
+
else {
|
|
3007
|
+
this._displayedValue = to;
|
|
3008
|
+
this.updateDisplay();
|
|
3009
|
+
this._animating = false;
|
|
3010
|
+
resolve();
|
|
3011
|
+
}
|
|
3012
|
+
};
|
|
3013
|
+
requestAnimationFrame(tick);
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
updateDisplay() {
|
|
3017
|
+
this._valueLabel.setCurrency(this._displayedValue, this._config.currency, this._config.locale);
|
|
3018
|
+
}
|
|
3019
|
+
layoutLabels() {
|
|
3020
|
+
if (this._prefixLabel) {
|
|
3021
|
+
this._prefixLabel.y = -14;
|
|
3022
|
+
this._valueLabel.y = 14;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
/**
|
|
3028
|
+
* Win amount display with countup animation.
|
|
3029
|
+
*
|
|
3030
|
+
* Shows a dramatic countup from 0 to the win amount, with optional
|
|
3031
|
+
* scale pop effect — typical of slot games.
|
|
3032
|
+
*
|
|
3033
|
+
* @example
|
|
3034
|
+
* ```ts
|
|
3035
|
+
* const winDisplay = new WinDisplay({ currency: 'USD' });
|
|
3036
|
+
* scene.container.addChild(winDisplay);
|
|
3037
|
+
* await winDisplay.showWin(150.50); // countup animation
|
|
3038
|
+
* winDisplay.hide();
|
|
3039
|
+
* ```
|
|
3040
|
+
*/
|
|
3041
|
+
class WinDisplay extends Container {
|
|
3042
|
+
_label;
|
|
3043
|
+
_config;
|
|
3044
|
+
_cancelCountup = false;
|
|
3045
|
+
constructor(config = {}) {
|
|
3046
|
+
super();
|
|
3047
|
+
this._config = {
|
|
3048
|
+
currency: config.currency ?? 'USD',
|
|
3049
|
+
locale: config.locale ?? 'en-US',
|
|
3050
|
+
countupDuration: config.countupDuration ?? 1500,
|
|
3051
|
+
popScale: config.popScale ?? 1.2,
|
|
3052
|
+
};
|
|
3053
|
+
this._label = new Label({
|
|
3054
|
+
text: '',
|
|
3055
|
+
style: {
|
|
3056
|
+
fontSize: 48,
|
|
3057
|
+
fontWeight: 'bold',
|
|
3058
|
+
fill: 0xffd700,
|
|
3059
|
+
stroke: { color: 0x000000, width: 3 },
|
|
3060
|
+
...config.style,
|
|
3061
|
+
},
|
|
3062
|
+
});
|
|
3063
|
+
this.addChild(this._label);
|
|
3064
|
+
this.visible = false;
|
|
3065
|
+
}
|
|
3066
|
+
/**
|
|
3067
|
+
* Show a win with countup animation.
|
|
3068
|
+
*
|
|
3069
|
+
* @param amount - Win amount
|
|
3070
|
+
* @returns Promise that resolves when the animation completes
|
|
3071
|
+
*/
|
|
3072
|
+
async showWin(amount) {
|
|
3073
|
+
this.visible = true;
|
|
3074
|
+
this._cancelCountup = false;
|
|
3075
|
+
this.alpha = 1;
|
|
3076
|
+
const duration = this._config.countupDuration;
|
|
3077
|
+
const startTime = Date.now();
|
|
3078
|
+
// Scale pop
|
|
3079
|
+
this.scale.set(0.5);
|
|
3080
|
+
return new Promise((resolve) => {
|
|
3081
|
+
const tick = () => {
|
|
3082
|
+
if (this._cancelCountup) {
|
|
3083
|
+
this.displayAmount(amount);
|
|
3084
|
+
resolve();
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
const elapsed = Date.now() - startTime;
|
|
3088
|
+
const t = Math.min(elapsed / duration, 1);
|
|
3089
|
+
const eased = Easing.easeOutCubic(t);
|
|
3090
|
+
// Countup
|
|
3091
|
+
const current = amount * eased;
|
|
3092
|
+
this.displayAmount(current);
|
|
3093
|
+
// Scale animation
|
|
3094
|
+
const scaleT = Math.min(elapsed / 300, 1);
|
|
3095
|
+
const scaleEased = Easing.easeOutBack(scaleT);
|
|
3096
|
+
const targetScale = 1;
|
|
3097
|
+
this.scale.set(0.5 + (targetScale - 0.5) * scaleEased);
|
|
3098
|
+
if (t < 1) {
|
|
3099
|
+
requestAnimationFrame(tick);
|
|
3100
|
+
}
|
|
3101
|
+
else {
|
|
3102
|
+
this.displayAmount(amount);
|
|
3103
|
+
this.scale.set(1);
|
|
3104
|
+
resolve();
|
|
3105
|
+
}
|
|
3106
|
+
};
|
|
3107
|
+
requestAnimationFrame(tick);
|
|
3108
|
+
});
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Skip the countup animation and show the final amount immediately.
|
|
3112
|
+
*/
|
|
3113
|
+
skipCountup(amount) {
|
|
3114
|
+
this._cancelCountup = true;
|
|
3115
|
+
this.displayAmount(amount);
|
|
3116
|
+
this.scale.set(1);
|
|
3117
|
+
}
|
|
3118
|
+
/**
|
|
3119
|
+
* Hide the win display.
|
|
3120
|
+
*/
|
|
3121
|
+
hide() {
|
|
3122
|
+
this.visible = false;
|
|
3123
|
+
this._label.text = '';
|
|
3124
|
+
}
|
|
3125
|
+
displayAmount(amount) {
|
|
3126
|
+
this._label.setCurrency(amount, this._config.currency, this._config.locale);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
/**
|
|
3131
|
+
* Modal overlay component.
|
|
3132
|
+
* Shows content on top of a dark overlay with enter/exit animations.
|
|
3133
|
+
*
|
|
3134
|
+
* @example
|
|
3135
|
+
* ```ts
|
|
3136
|
+
* const modal = new Modal({ closeOnOverlay: true });
|
|
3137
|
+
* modal.content.addChild(settingsPanel);
|
|
3138
|
+
* modal.onClose = () => console.log('Closed');
|
|
3139
|
+
* await modal.show(1920, 1080);
|
|
3140
|
+
* ```
|
|
3141
|
+
*/
|
|
3142
|
+
class Modal extends Container {
|
|
3143
|
+
_overlay;
|
|
3144
|
+
_contentContainer;
|
|
3145
|
+
_config;
|
|
3146
|
+
_showing = false;
|
|
3147
|
+
/** Called when the modal is closed */
|
|
3148
|
+
onClose;
|
|
3149
|
+
constructor(config = {}) {
|
|
3150
|
+
super();
|
|
3151
|
+
this._config = {
|
|
3152
|
+
overlayColor: 0x000000,
|
|
3153
|
+
overlayAlpha: 0.7,
|
|
3154
|
+
closeOnOverlay: true,
|
|
3155
|
+
animationDuration: 300,
|
|
3156
|
+
...config,
|
|
3157
|
+
};
|
|
3158
|
+
// Overlay
|
|
3159
|
+
this._overlay = new Graphics();
|
|
3160
|
+
this._overlay.eventMode = 'static';
|
|
3161
|
+
this.addChild(this._overlay);
|
|
3162
|
+
if (this._config.closeOnOverlay) {
|
|
3163
|
+
this._overlay.on('pointertap', () => this.hide());
|
|
3164
|
+
}
|
|
3165
|
+
// Content container
|
|
3166
|
+
this._contentContainer = new Container();
|
|
3167
|
+
this.addChild(this._contentContainer);
|
|
3168
|
+
this.visible = false;
|
|
3169
|
+
}
|
|
3170
|
+
/** Content container — add your UI here */
|
|
3171
|
+
get content() {
|
|
3172
|
+
return this._contentContainer;
|
|
3173
|
+
}
|
|
3174
|
+
/** Whether the modal is currently showing */
|
|
3175
|
+
get isShowing() {
|
|
3176
|
+
return this._showing;
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* Show the modal with animation.
|
|
3180
|
+
*/
|
|
3181
|
+
async show(viewWidth, viewHeight) {
|
|
3182
|
+
this._showing = true;
|
|
3183
|
+
this.visible = true;
|
|
3184
|
+
// Draw overlay to cover full screen
|
|
3185
|
+
this._overlay.clear();
|
|
3186
|
+
this._overlay.rect(0, 0, viewWidth, viewHeight).fill(this._config.overlayColor);
|
|
3187
|
+
this._overlay.alpha = 0;
|
|
3188
|
+
// Center content
|
|
3189
|
+
this._contentContainer.x = viewWidth / 2;
|
|
3190
|
+
this._contentContainer.y = viewHeight / 2;
|
|
3191
|
+
this._contentContainer.alpha = 0;
|
|
3192
|
+
this._contentContainer.scale.set(0.8);
|
|
3193
|
+
// Animate in
|
|
3194
|
+
await Promise.all([
|
|
3195
|
+
Tween.to(this._overlay, { alpha: this._config.overlayAlpha }, this._config.animationDuration, Easing.easeOutCubic),
|
|
3196
|
+
Tween.to(this._contentContainer, { alpha: 1, 'scale.x': 1, 'scale.y': 1 }, this._config.animationDuration, Easing.easeOutBack),
|
|
3197
|
+
]);
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* Hide the modal with animation.
|
|
3201
|
+
*/
|
|
3202
|
+
async hide() {
|
|
3203
|
+
if (!this._showing)
|
|
3204
|
+
return;
|
|
3205
|
+
await Promise.all([
|
|
3206
|
+
Tween.to(this._overlay, { alpha: 0 }, this._config.animationDuration * 0.7, Easing.easeInCubic),
|
|
3207
|
+
Tween.to(this._contentContainer, { alpha: 0, 'scale.x': 0.8, 'scale.y': 0.8 }, this._config.animationDuration * 0.7, Easing.easeInCubic),
|
|
3208
|
+
]);
|
|
3209
|
+
this.visible = false;
|
|
3210
|
+
this._showing = false;
|
|
3211
|
+
this.onClose?.();
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
const TOAST_COLORS = {
|
|
3216
|
+
info: 0x3498db,
|
|
3217
|
+
success: 0x27ae60,
|
|
3218
|
+
warning: 0xf39c12,
|
|
3219
|
+
error: 0xe74c3c,
|
|
3220
|
+
};
|
|
3221
|
+
/**
|
|
3222
|
+
* Toast notification component for displaying transient messages.
|
|
3223
|
+
*
|
|
3224
|
+
* @example
|
|
3225
|
+
* ```ts
|
|
3226
|
+
* const toast = new Toast();
|
|
3227
|
+
* scene.container.addChild(toast);
|
|
3228
|
+
* await toast.show('Connection lost', 'error', 1920, 1080);
|
|
3229
|
+
* ```
|
|
3230
|
+
*/
|
|
3231
|
+
class Toast extends Container {
|
|
3232
|
+
_bg;
|
|
3233
|
+
_text;
|
|
3234
|
+
_config;
|
|
3235
|
+
_dismissTimeout = null;
|
|
3236
|
+
constructor(config = {}) {
|
|
3237
|
+
super();
|
|
3238
|
+
this._config = {
|
|
3239
|
+
duration: 3000,
|
|
3240
|
+
bottomOffset: 60,
|
|
3241
|
+
...config,
|
|
3242
|
+
};
|
|
3243
|
+
this._bg = new Graphics();
|
|
3244
|
+
this.addChild(this._bg);
|
|
3245
|
+
this._text = new Text({
|
|
3246
|
+
text: '',
|
|
3247
|
+
style: {
|
|
3248
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
3249
|
+
fontSize: 16,
|
|
3250
|
+
fill: 0xffffff,
|
|
3251
|
+
},
|
|
3252
|
+
});
|
|
3253
|
+
this._text.anchor.set(0.5);
|
|
3254
|
+
this.addChild(this._text);
|
|
3255
|
+
this.visible = false;
|
|
3256
|
+
}
|
|
3257
|
+
/**
|
|
3258
|
+
* Show a toast message.
|
|
3259
|
+
*/
|
|
3260
|
+
async show(message, type = 'info', viewWidth, viewHeight) {
|
|
3261
|
+
// Clear previous dismiss
|
|
3262
|
+
if (this._dismissTimeout) {
|
|
3263
|
+
clearTimeout(this._dismissTimeout);
|
|
3264
|
+
}
|
|
3265
|
+
this._text.text = message;
|
|
3266
|
+
const padding = 20;
|
|
3267
|
+
const width = Math.max(200, this._text.width + padding * 2);
|
|
3268
|
+
const height = 44;
|
|
3269
|
+
const radius = 8;
|
|
3270
|
+
this._bg.clear();
|
|
3271
|
+
this._bg.roundRect(-width / 2, -height / 2, width, height, radius).fill(TOAST_COLORS[type]);
|
|
3272
|
+
this._bg.roundRect(-width / 2, -height / 2, width, height, radius)
|
|
3273
|
+
.fill({ color: 0x000000, alpha: 0.2 });
|
|
3274
|
+
// Position
|
|
3275
|
+
if (viewWidth && viewHeight) {
|
|
3276
|
+
this.x = viewWidth / 2;
|
|
3277
|
+
this.y = viewHeight - this._config.bottomOffset;
|
|
3278
|
+
}
|
|
3279
|
+
this.visible = true;
|
|
3280
|
+
this.alpha = 0;
|
|
3281
|
+
this.y += 20;
|
|
3282
|
+
// Animate in
|
|
3283
|
+
await Tween.to(this, { alpha: 1, y: this.y - 20 }, 300, Easing.easeOutCubic);
|
|
3284
|
+
// Auto-dismiss
|
|
3285
|
+
if (this._config.duration > 0) {
|
|
3286
|
+
this._dismissTimeout = setTimeout(() => {
|
|
3287
|
+
this.dismiss();
|
|
3288
|
+
}, this._config.duration);
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
/**
|
|
3292
|
+
* Dismiss the toast.
|
|
3293
|
+
*/
|
|
3294
|
+
async dismiss() {
|
|
3295
|
+
if (!this.visible)
|
|
3296
|
+
return;
|
|
3297
|
+
if (this._dismissTimeout) {
|
|
3298
|
+
clearTimeout(this._dismissTimeout);
|
|
3299
|
+
this._dismissTimeout = null;
|
|
3300
|
+
}
|
|
3301
|
+
await Tween.to(this, { alpha: 0, y: this.y + 20 }, 200, Easing.easeInCubic);
|
|
3302
|
+
this.visible = false;
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
const DEFAULT_CONFIG = {
|
|
3307
|
+
balance: 10000,
|
|
3308
|
+
currency: 'USD',
|
|
3309
|
+
gameConfig: {
|
|
3310
|
+
id: 'dev-game',
|
|
3311
|
+
type: 'slot',
|
|
3312
|
+
version: '1.0.0',
|
|
3313
|
+
viewport: { width: 1920, height: 1080 },
|
|
3314
|
+
betLevels: [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50],
|
|
3315
|
+
},
|
|
3316
|
+
assetsUrl: '/assets/',
|
|
3317
|
+
session: null,
|
|
3318
|
+
onPlay: () => ({}),
|
|
3319
|
+
networkDelay: 200,
|
|
3320
|
+
debug: true,
|
|
3321
|
+
};
|
|
3322
|
+
/**
|
|
3323
|
+
* Mock host bridge for local development.
|
|
3324
|
+
*
|
|
3325
|
+
* Intercepts postMessage communication from the SDK and responds
|
|
3326
|
+
* with mock data, simulating a real casino host environment.
|
|
3327
|
+
*
|
|
3328
|
+
* This allows games to be developed and tested without a real backend.
|
|
3329
|
+
*
|
|
3330
|
+
* @example
|
|
3331
|
+
* ```ts
|
|
3332
|
+
* // In your dev entry point or vite plugin
|
|
3333
|
+
* import { DevBridge } from '@energy8platform/game-engine/debug';
|
|
3334
|
+
*
|
|
3335
|
+
* const devBridge = new DevBridge({
|
|
3336
|
+
* balance: 5000,
|
|
3337
|
+
* currency: 'EUR',
|
|
3338
|
+
* gameConfig: { id: 'my-slot', type: 'slot', betLevels: [0.2, 0.5, 1, 2] },
|
|
3339
|
+
* onPlay: ({ action, bet }) => ({
|
|
3340
|
+
* totalWin: Math.random() > 0.5 ? bet * (Math.random() * 20) : 0,
|
|
3341
|
+
* data: {
|
|
3342
|
+
* matrix: generateRandomMatrix(5, 3, 10),
|
|
3343
|
+
* win_lines: [],
|
|
3344
|
+
* },
|
|
3345
|
+
* }),
|
|
3346
|
+
* });
|
|
3347
|
+
* devBridge.start();
|
|
3348
|
+
* ```
|
|
3349
|
+
*/
|
|
3350
|
+
class DevBridge {
|
|
3351
|
+
_config;
|
|
3352
|
+
_balance;
|
|
3353
|
+
_roundCounter = 0;
|
|
3354
|
+
_listening = false;
|
|
3355
|
+
_handler = null;
|
|
3356
|
+
constructor(config = {}) {
|
|
3357
|
+
this._config = { ...DEFAULT_CONFIG, ...config };
|
|
3358
|
+
this._balance = this._config.balance;
|
|
3359
|
+
}
|
|
3360
|
+
/** Current mock balance */
|
|
3361
|
+
get balance() {
|
|
3362
|
+
return this._balance;
|
|
3363
|
+
}
|
|
3364
|
+
/** Start listening for SDK messages */
|
|
3365
|
+
start() {
|
|
3366
|
+
if (this._listening)
|
|
3367
|
+
return;
|
|
3368
|
+
this._handler = (e) => {
|
|
3369
|
+
this.handleMessage(e);
|
|
3370
|
+
};
|
|
3371
|
+
window.addEventListener('message', this._handler);
|
|
3372
|
+
this._listening = true;
|
|
3373
|
+
if (this._config.debug) {
|
|
3374
|
+
console.log('[DevBridge] Started — listening for SDK messages');
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
/** Stop listening */
|
|
3378
|
+
stop() {
|
|
3379
|
+
if (this._handler) {
|
|
3380
|
+
window.removeEventListener('message', this._handler);
|
|
3381
|
+
this._handler = null;
|
|
3382
|
+
}
|
|
3383
|
+
this._listening = false;
|
|
3384
|
+
if (this._config.debug) {
|
|
3385
|
+
console.log('[DevBridge] Stopped');
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
/** Set mock balance */
|
|
3389
|
+
setBalance(balance) {
|
|
3390
|
+
this._balance = balance;
|
|
3391
|
+
// Send balance update
|
|
3392
|
+
this.sendMessage('BALANCE_UPDATE', { balance: this._balance });
|
|
3393
|
+
}
|
|
3394
|
+
/** Destroy the dev bridge */
|
|
3395
|
+
destroy() {
|
|
3396
|
+
this.stop();
|
|
3397
|
+
}
|
|
3398
|
+
// ─── Message Handling ──────────────────────────────────
|
|
3399
|
+
handleMessage(e) {
|
|
3400
|
+
const data = e.data;
|
|
3401
|
+
// Only process bridge messages
|
|
3402
|
+
if (!data || data.__casino_bridge !== true)
|
|
3403
|
+
return;
|
|
3404
|
+
if (this._config.debug) {
|
|
3405
|
+
console.log('[DevBridge] ←', data.type, data.payload);
|
|
3406
|
+
}
|
|
3407
|
+
switch (data.type) {
|
|
3408
|
+
case 'GAME_READY':
|
|
3409
|
+
this.handleGameReady(data.id);
|
|
3410
|
+
break;
|
|
3411
|
+
case 'PLAY_REQUEST':
|
|
3412
|
+
this.handlePlayRequest(data.payload, data.id);
|
|
3413
|
+
break;
|
|
3414
|
+
case 'PLAY_RESULT_ACK':
|
|
3415
|
+
this.handlePlayAck(data.payload, data.id);
|
|
3416
|
+
break;
|
|
3417
|
+
case 'GET_BALANCE':
|
|
3418
|
+
this.handleGetBalance(data.id);
|
|
3419
|
+
break;
|
|
3420
|
+
case 'GET_STATE':
|
|
3421
|
+
this.handleGetState(data.id);
|
|
3422
|
+
break;
|
|
3423
|
+
case 'OPEN_DEPOSIT':
|
|
3424
|
+
this.handleOpenDeposit();
|
|
3425
|
+
break;
|
|
3426
|
+
default:
|
|
3427
|
+
if (this._config.debug) {
|
|
3428
|
+
console.log('[DevBridge] Unknown message type:', data.type);
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
handleGameReady(id) {
|
|
3433
|
+
const initData = {
|
|
3434
|
+
balance: this._balance,
|
|
3435
|
+
currency: this._config.currency,
|
|
3436
|
+
config: this._config.gameConfig,
|
|
3437
|
+
session: this._config.session,
|
|
3438
|
+
assetsUrl: this._config.assetsUrl,
|
|
3439
|
+
};
|
|
3440
|
+
this.delayedSend('INIT', initData, id);
|
|
3441
|
+
}
|
|
3442
|
+
handlePlayRequest(payload, id) {
|
|
3443
|
+
const { action, bet, roundId } = payload;
|
|
3444
|
+
// Deduct bet
|
|
3445
|
+
this._balance -= bet;
|
|
3446
|
+
this._roundCounter++;
|
|
3447
|
+
// Generate result
|
|
3448
|
+
const customResult = this._config.onPlay({ action, bet, roundId });
|
|
3449
|
+
const totalWin = customResult.totalWin ?? (Math.random() > 0.6 ? bet * (1 + Math.random() * 10) : 0);
|
|
3450
|
+
// Credit win
|
|
3451
|
+
this._balance += totalWin;
|
|
3452
|
+
const result = {
|
|
3453
|
+
roundId: roundId ?? `dev-round-${this._roundCounter}`,
|
|
3454
|
+
action,
|
|
3455
|
+
balanceAfter: this._balance,
|
|
3456
|
+
totalWin: Math.round(totalWin * 100) / 100,
|
|
3457
|
+
data: customResult.data ?? {},
|
|
3458
|
+
nextActions: customResult.nextActions ?? ['spin'],
|
|
3459
|
+
session: customResult.session ?? null,
|
|
3460
|
+
creditPending: false,
|
|
3461
|
+
};
|
|
3462
|
+
this.delayedSend('PLAY_RESULT', result, id);
|
|
3463
|
+
}
|
|
3464
|
+
handlePlayAck(_payload, _id) {
|
|
3465
|
+
if (this._config.debug) {
|
|
3466
|
+
console.log('[DevBridge] Play acknowledged');
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
handleGetBalance(id) {
|
|
3470
|
+
this.delayedSend('BALANCE_RESPONSE', { balance: this._balance }, id);
|
|
3471
|
+
}
|
|
3472
|
+
handleGetState(id) {
|
|
3473
|
+
this.delayedSend('STATE_RESPONSE', this._config.session, id);
|
|
3474
|
+
}
|
|
3475
|
+
handleOpenDeposit() {
|
|
3476
|
+
if (this._config.debug) {
|
|
3477
|
+
console.log('[DevBridge] 💰 Open deposit requested (mock: adding 1000)');
|
|
3478
|
+
}
|
|
3479
|
+
this._balance += 1000;
|
|
3480
|
+
this.sendMessage('BALANCE_UPDATE', { balance: this._balance });
|
|
3481
|
+
}
|
|
3482
|
+
// ─── Communication ─────────────────────────────────────
|
|
3483
|
+
delayedSend(type, payload, id) {
|
|
3484
|
+
const delay = this._config.networkDelay;
|
|
3485
|
+
if (delay > 0) {
|
|
3486
|
+
setTimeout(() => this.sendMessage(type, payload, id), delay);
|
|
3487
|
+
}
|
|
3488
|
+
else {
|
|
3489
|
+
this.sendMessage(type, payload, id);
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
sendMessage(type, payload, id) {
|
|
3493
|
+
const message = {
|
|
3494
|
+
__casino_bridge: true,
|
|
3495
|
+
type,
|
|
3496
|
+
payload,
|
|
3497
|
+
id,
|
|
3498
|
+
};
|
|
3499
|
+
if (this._config.debug) {
|
|
3500
|
+
console.log('[DevBridge] →', type, payload);
|
|
3501
|
+
}
|
|
3502
|
+
// Post to the same window (SDK listens on window)
|
|
3503
|
+
window.postMessage(message, '*');
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
/**
|
|
3508
|
+
* FPS overlay for debugging performance.
|
|
3509
|
+
*
|
|
3510
|
+
* Shows FPS, frame time, and draw call count in the corner of the screen.
|
|
3511
|
+
*
|
|
3512
|
+
* @example
|
|
3513
|
+
* ```ts
|
|
3514
|
+
* const fps = new FPSOverlay(app);
|
|
3515
|
+
* fps.show();
|
|
3516
|
+
* ```
|
|
3517
|
+
*/
|
|
3518
|
+
class FPSOverlay {
|
|
3519
|
+
_app;
|
|
3520
|
+
_container;
|
|
3521
|
+
_fpsText;
|
|
3522
|
+
_visible = false;
|
|
3523
|
+
_samples = [];
|
|
3524
|
+
_maxSamples = 60;
|
|
3525
|
+
_lastUpdate = 0;
|
|
3526
|
+
_tickFn = null;
|
|
3527
|
+
constructor(app) {
|
|
3528
|
+
this._app = app;
|
|
3529
|
+
this._container = new Container();
|
|
3530
|
+
this._container.label = 'FPSOverlay';
|
|
3531
|
+
this._container.zIndex = 99999;
|
|
3532
|
+
this._fpsText = new Text({
|
|
3533
|
+
text: 'FPS: --',
|
|
3534
|
+
style: {
|
|
3535
|
+
fontFamily: 'monospace',
|
|
3536
|
+
fontSize: 14,
|
|
3537
|
+
fill: 0x00ff00,
|
|
3538
|
+
stroke: { color: 0x000000, width: 2 },
|
|
3539
|
+
},
|
|
3540
|
+
});
|
|
3541
|
+
this._fpsText.x = 8;
|
|
3542
|
+
this._fpsText.y = 8;
|
|
3543
|
+
this._container.addChild(this._fpsText);
|
|
3544
|
+
}
|
|
3545
|
+
/** Show the FPS overlay */
|
|
3546
|
+
show() {
|
|
3547
|
+
if (this._visible)
|
|
3548
|
+
return;
|
|
3549
|
+
this._visible = true;
|
|
3550
|
+
this._app.stage.addChild(this._container);
|
|
3551
|
+
this._tickFn = (ticker) => {
|
|
3552
|
+
this._samples.push(ticker.FPS);
|
|
3553
|
+
if (this._samples.length > this._maxSamples) {
|
|
3554
|
+
this._samples.shift();
|
|
3555
|
+
}
|
|
3556
|
+
// Update display every ~500ms
|
|
3557
|
+
const now = Date.now();
|
|
3558
|
+
if (now - this._lastUpdate > 500) {
|
|
3559
|
+
const avg = this._samples.reduce((a, b) => a + b, 0) / this._samples.length;
|
|
3560
|
+
const min = Math.min(...this._samples);
|
|
3561
|
+
this._fpsText.text = [
|
|
3562
|
+
`FPS: ${Math.round(avg)} (min: ${Math.round(min)})`,
|
|
3563
|
+
`Frame: ${ticker.deltaMS.toFixed(1)}ms`,
|
|
3564
|
+
].join('\n');
|
|
3565
|
+
this._lastUpdate = now;
|
|
3566
|
+
}
|
|
3567
|
+
};
|
|
3568
|
+
this._app.ticker.add(this._tickFn);
|
|
3569
|
+
}
|
|
3570
|
+
/** Hide the FPS overlay */
|
|
3571
|
+
hide() {
|
|
3572
|
+
if (!this._visible)
|
|
3573
|
+
return;
|
|
3574
|
+
this._visible = false;
|
|
3575
|
+
this._container.removeFromParent();
|
|
3576
|
+
if (this._tickFn) {
|
|
3577
|
+
this._app.ticker.remove(this._tickFn);
|
|
3578
|
+
this._tickFn = null;
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
/** Toggle visibility */
|
|
3582
|
+
toggle() {
|
|
3583
|
+
if (this._visible) {
|
|
3584
|
+
this.hide();
|
|
3585
|
+
}
|
|
3586
|
+
else {
|
|
3587
|
+
this.show();
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
/** Destroy the overlay */
|
|
3591
|
+
destroy() {
|
|
3592
|
+
this.hide();
|
|
3593
|
+
this._container.destroy({ children: true });
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
export { AssetManager, AudioManager, BalanceDisplay, Button, DevBridge, Easing, EventEmitter, FPSOverlay, GameApplication, InputManager, Label, LoadingScene, Modal, Orientation, Panel, ProgressBar, ScaleMode, Scene, SceneManager, SpineHelper, StateMachine, Timeline, Toast, TransitionType, Tween, ViewportManager, WinDisplay };
|
|
3598
|
+
//# sourceMappingURL=index.esm.js.map
|