@energy8platform/game-engine 0.16.0 → 0.18.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/dist/core.cjs.js +62 -1
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +33 -2
- package/dist/core.esm.js +63 -3
- package/dist/core.esm.js.map +1 -1
- package/dist/debug.d.ts +1 -1
- package/dist/game-spec.cjs.js +13 -0
- package/dist/game-spec.cjs.js.map +1 -0
- package/dist/game-spec.d.ts +1 -0
- package/dist/game-spec.esm.js +2 -0
- package/dist/game-spec.esm.js.map +1 -0
- package/dist/host.cjs.js +3346 -0
- package/dist/host.cjs.js.map +1 -0
- package/dist/host.d.ts +915 -0
- package/dist/host.esm.js +3337 -0
- package/dist/host.esm.js.map +1 -0
- package/dist/index.cjs.js +13 -1
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.esm.js +13 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +6 -1
- package/dist/react.esm.js.map +1 -1
- package/dist/shell.cjs.js +19 -0
- package/dist/shell.cjs.js.map +1 -0
- package/dist/shell.d.ts +1 -0
- package/dist/shell.esm.js +2 -0
- package/dist/shell.esm.js.map +1 -0
- package/dist/slot.cjs.js +998 -0
- package/dist/slot.cjs.js.map +1 -0
- package/dist/slot.d.ts +333 -0
- package/dist/slot.esm.js +985 -0
- package/dist/slot.esm.js.map +1 -0
- package/package.json +26 -1
- package/src/core/GameApplication.ts +16 -1
- package/src/core/index.ts +2 -0
- package/src/debug/index.ts +1 -1
- package/src/game-spec/index.ts +1 -0
- package/src/host/autoplay.ts +78 -0
- package/src/host/balanceGate.ts +46 -0
- package/src/host/buildConfig.ts +28 -0
- package/src/host/createSlotGame.ts +423 -0
- package/src/host/fatalError.ts +104 -0
- package/src/host/freeSpinsCounter.ts +44 -0
- package/src/host/index.ts +18 -0
- package/src/host/playError.ts +64 -0
- package/src/host/preboot.ts +25 -0
- package/src/host/replay.ts +9 -0
- package/src/host/runRound.ts +63 -0
- package/src/host/sceneController.ts +31 -0
- package/src/host/sceneStart.ts +25 -0
- package/src/host/shellConfig.ts +379 -0
- package/src/host/slotPlay.ts +62 -0
- package/src/host/types.ts +71 -0
- package/src/index.ts +1 -1
- package/src/loading/index.ts +2 -0
- package/src/scenes/IntroScene.ts +66 -0
- package/src/shell/index.ts +20 -0
- package/src/slot/anim/CascadeController.ts +102 -0
- package/src/slot/anim/ReelSpinController.ts +81 -0
- package/src/slot/anim/easing-map.ts +14 -0
- package/src/slot/freeSpins/FreeSpinsSession.ts +40 -0
- package/src/slot/grid/AnimatedSymbol.ts +68 -0
- package/src/slot/grid/ReelGrid.ts +92 -0
- package/src/slot/grid/SymbolCell.ts +127 -0
- package/src/slot/grid/SymbolView.ts +13 -0
- package/src/slot/index.ts +21 -0
- package/src/slot/multiplier/MultiplierAccumulator.ts +29 -0
- package/src/slot/overlay/BigWinOverlay.ts +89 -0
- package/src/slot/overlay/CountUpDisplay.ts +56 -0
- package/src/slot/overlay/tiers.ts +29 -0
- package/src/types.ts +3 -0
package/dist/host.cjs.js
ADDED
|
@@ -0,0 +1,3346 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var pixi_js = require('pixi.js');
|
|
4
|
+
var platformCore = require('@energy8platform/platform-core');
|
|
5
|
+
var loading = require('@energy8platform/platform-core/loading');
|
|
6
|
+
var shell = require('@energy8platform/platform-core/shell');
|
|
7
|
+
|
|
8
|
+
// ─── Scale Modes ───────────────────────────────────────────
|
|
9
|
+
var ScaleMode;
|
|
10
|
+
(function (ScaleMode) {
|
|
11
|
+
/** Fit inside container, maintain aspect ratio (letterbox/pillarbox) */
|
|
12
|
+
ScaleMode["FIT"] = "FIT";
|
|
13
|
+
/** Fill container, maintain aspect ratio (crop edges) */
|
|
14
|
+
ScaleMode["FILL"] = "FILL";
|
|
15
|
+
/** Stretch to fill (distorts) */
|
|
16
|
+
ScaleMode["STRETCH"] = "STRETCH";
|
|
17
|
+
})(ScaleMode || (ScaleMode = {}));
|
|
18
|
+
// ─── Orientation ───────────────────────────────────────────
|
|
19
|
+
var Orientation;
|
|
20
|
+
(function (Orientation) {
|
|
21
|
+
Orientation["LANDSCAPE"] = "landscape";
|
|
22
|
+
Orientation["PORTRAIT"] = "portrait";
|
|
23
|
+
Orientation["ANY"] = "any";
|
|
24
|
+
})(Orientation || (Orientation = {}));
|
|
25
|
+
// ─── Transition Types ──────────────────────────────────────
|
|
26
|
+
var TransitionType;
|
|
27
|
+
(function (TransitionType) {
|
|
28
|
+
TransitionType["NONE"] = "none";
|
|
29
|
+
TransitionType["FADE"] = "fade";
|
|
30
|
+
TransitionType["SLIDE_LEFT"] = "slide-left";
|
|
31
|
+
TransitionType["SLIDE_RIGHT"] = "slide-right";
|
|
32
|
+
})(TransitionType || (TransitionType = {}));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Minimal typed event emitter.
|
|
36
|
+
* Used internally by GameApplication, SceneManager, AudioManager, etc.
|
|
37
|
+
*
|
|
38
|
+
* Supports `void` event types — events that carry no data can be emitted
|
|
39
|
+
* without arguments: `emitter.emit('eventName')`.
|
|
40
|
+
*/
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
42
|
+
class EventEmitter {
|
|
43
|
+
listeners = new Map();
|
|
44
|
+
on(event, handler) {
|
|
45
|
+
if (!this.listeners.has(event)) {
|
|
46
|
+
this.listeners.set(event, new Set());
|
|
47
|
+
}
|
|
48
|
+
this.listeners.get(event).add(handler);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
once(event, handler) {
|
|
52
|
+
const wrapper = (data) => {
|
|
53
|
+
this.off(event, wrapper);
|
|
54
|
+
handler(data);
|
|
55
|
+
};
|
|
56
|
+
return this.on(event, wrapper);
|
|
57
|
+
}
|
|
58
|
+
off(event, handler) {
|
|
59
|
+
this.listeners.get(event)?.delete(handler);
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
emit(...args) {
|
|
63
|
+
const [event, data] = args;
|
|
64
|
+
const handlers = this.listeners.get(event);
|
|
65
|
+
if (handlers) {
|
|
66
|
+
for (const handler of handlers) {
|
|
67
|
+
handler(data);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
removeAllListeners(event) {
|
|
72
|
+
if (event) {
|
|
73
|
+
this.listeners.delete(event);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
this.listeners.clear();
|
|
77
|
+
}
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Collection of easing functions for use with Tween and Timeline.
|
|
84
|
+
*
|
|
85
|
+
* All functions take a progress value t (0..1) and return the eased value.
|
|
86
|
+
*/
|
|
87
|
+
const Easing = {
|
|
88
|
+
linear: (t) => t,
|
|
89
|
+
easeInQuad: (t) => t * t,
|
|
90
|
+
easeOutQuad: (t) => t * (2 - t),
|
|
91
|
+
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
|
92
|
+
easeInCubic: (t) => t * t * t,
|
|
93
|
+
easeOutCubic: (t) => --t * t * t + 1,
|
|
94
|
+
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
|
|
95
|
+
easeInQuart: (t) => t * t * t * t,
|
|
96
|
+
easeOutQuart: (t) => 1 - --t * t * t * t,
|
|
97
|
+
easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
|
|
98
|
+
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
99
|
+
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
|
|
100
|
+
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
|
101
|
+
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
|
|
102
|
+
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
|
103
|
+
easeInOutExpo: (t) => t === 0
|
|
104
|
+
? 0
|
|
105
|
+
: t === 1
|
|
106
|
+
? 1
|
|
107
|
+
: t < 0.5
|
|
108
|
+
? Math.pow(2, 20 * t - 10) / 2
|
|
109
|
+
: (2 - Math.pow(2, -20 * t + 10)) / 2,
|
|
110
|
+
easeInBack: (t) => {
|
|
111
|
+
const c1 = 1.70158;
|
|
112
|
+
const c3 = c1 + 1;
|
|
113
|
+
return c3 * t * t * t - c1 * t * t;
|
|
114
|
+
},
|
|
115
|
+
easeOutBack: (t) => {
|
|
116
|
+
const c1 = 1.70158;
|
|
117
|
+
const c3 = c1 + 1;
|
|
118
|
+
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
119
|
+
},
|
|
120
|
+
easeInOutBack: (t) => {
|
|
121
|
+
const c1 = 1.70158;
|
|
122
|
+
const c2 = c1 * 1.525;
|
|
123
|
+
return t < 0.5
|
|
124
|
+
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
|
125
|
+
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
|
|
126
|
+
},
|
|
127
|
+
easeOutBounce: (t) => {
|
|
128
|
+
const n1 = 7.5625;
|
|
129
|
+
const d1 = 2.75;
|
|
130
|
+
if (t < 1 / d1)
|
|
131
|
+
return n1 * t * t;
|
|
132
|
+
if (t < 2 / d1)
|
|
133
|
+
return n1 * (t -= 1.5 / d1) * t + 0.75;
|
|
134
|
+
if (t < 2.5 / d1)
|
|
135
|
+
return n1 * (t -= 2.25 / d1) * t + 0.9375;
|
|
136
|
+
return n1 * (t -= 2.625 / d1) * t + 0.984375;
|
|
137
|
+
},
|
|
138
|
+
easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
|
|
139
|
+
easeInOutBounce: (t) => t < 0.5
|
|
140
|
+
? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
|
|
141
|
+
: (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
|
|
142
|
+
easeOutElastic: (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) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
|
149
|
+
},
|
|
150
|
+
easeInElastic: (t) => {
|
|
151
|
+
const c4 = (2 * Math.PI) / 3;
|
|
152
|
+
return t === 0
|
|
153
|
+
? 0
|
|
154
|
+
: t === 1
|
|
155
|
+
? 1
|
|
156
|
+
: -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Lightweight tween system integrated with PixiJS Ticker.
|
|
162
|
+
* Zero external dependencies — no GSAP required.
|
|
163
|
+
*
|
|
164
|
+
* All tweens return a Promise that resolves on completion.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* // Fade in a sprite
|
|
169
|
+
* await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
|
|
170
|
+
*
|
|
171
|
+
* // Move and wait
|
|
172
|
+
* await Tween.to(sprite, { x: 500 }, 300);
|
|
173
|
+
*
|
|
174
|
+
* // From a starting value
|
|
175
|
+
* await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
class Tween {
|
|
179
|
+
static _tweens = [];
|
|
180
|
+
static _tickerAdded = false;
|
|
181
|
+
/**
|
|
182
|
+
* Animate properties from current values to target values.
|
|
183
|
+
*
|
|
184
|
+
* @param target - Object to animate (Sprite, Container, etc.)
|
|
185
|
+
* @param props - Target property values
|
|
186
|
+
* @param duration - Duration in milliseconds
|
|
187
|
+
* @param easing - Easing function (default: easeOutQuad)
|
|
188
|
+
* @param onUpdate - Progress callback (0..1)
|
|
189
|
+
*/
|
|
190
|
+
static to(target, props, duration, easing, onUpdate) {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
// Capture starting values
|
|
193
|
+
const from = {};
|
|
194
|
+
for (const key of Object.keys(props)) {
|
|
195
|
+
from[key] = Tween.getProperty(target, key);
|
|
196
|
+
}
|
|
197
|
+
const tween = {
|
|
198
|
+
target,
|
|
199
|
+
from,
|
|
200
|
+
to: { ...props },
|
|
201
|
+
duration: Math.max(1, duration),
|
|
202
|
+
easing: easing ?? Easing.easeOutQuad,
|
|
203
|
+
elapsed: 0,
|
|
204
|
+
delay: 0,
|
|
205
|
+
resolve,
|
|
206
|
+
onUpdate,
|
|
207
|
+
};
|
|
208
|
+
Tween._tweens.push(tween);
|
|
209
|
+
Tween.ensureTicker();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Animate properties from given values to current values.
|
|
214
|
+
*/
|
|
215
|
+
static from(target, props, duration, easing, onUpdate) {
|
|
216
|
+
// Capture current values as "to"
|
|
217
|
+
const to = {};
|
|
218
|
+
for (const key of Object.keys(props)) {
|
|
219
|
+
to[key] = Tween.getProperty(target, key);
|
|
220
|
+
Tween.setProperty(target, key, props[key]);
|
|
221
|
+
}
|
|
222
|
+
return Tween.to(target, to, duration, easing, onUpdate);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Animate from one set of values to another.
|
|
226
|
+
*/
|
|
227
|
+
static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
|
|
228
|
+
// Set starting values
|
|
229
|
+
for (const key of Object.keys(fromProps)) {
|
|
230
|
+
Tween.setProperty(target, key, fromProps[key]);
|
|
231
|
+
}
|
|
232
|
+
return Tween.to(target, toProps, duration, easing, onUpdate);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Wait for a given duration (useful in timelines).
|
|
236
|
+
* Uses PixiJS Ticker for consistent timing with other tweens.
|
|
237
|
+
*/
|
|
238
|
+
static delay(ms) {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
let elapsed = 0;
|
|
241
|
+
const onTick = (ticker) => {
|
|
242
|
+
elapsed += ticker.deltaMS;
|
|
243
|
+
if (elapsed >= ms) {
|
|
244
|
+
pixi_js.Ticker.shared.remove(onTick);
|
|
245
|
+
resolve();
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
pixi_js.Ticker.shared.add(onTick);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Kill all tweens on a target.
|
|
253
|
+
*/
|
|
254
|
+
static killTweensOf(target) {
|
|
255
|
+
Tween._tweens = Tween._tweens.filter((tw) => {
|
|
256
|
+
if (tw.target === target) {
|
|
257
|
+
tw.resolve();
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Kill all active tweens.
|
|
265
|
+
*/
|
|
266
|
+
static killAll() {
|
|
267
|
+
for (const tw of Tween._tweens) {
|
|
268
|
+
tw.resolve();
|
|
269
|
+
}
|
|
270
|
+
Tween._tweens.length = 0;
|
|
271
|
+
}
|
|
272
|
+
/** Number of active tweens */
|
|
273
|
+
static get activeTweens() {
|
|
274
|
+
return Tween._tweens.length;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Reset the tween system — kill all tweens and remove the ticker.
|
|
278
|
+
* Useful for cleanup between game instances, tests, or hot-reload.
|
|
279
|
+
*/
|
|
280
|
+
static reset() {
|
|
281
|
+
for (const tw of Tween._tweens) {
|
|
282
|
+
tw.resolve();
|
|
283
|
+
}
|
|
284
|
+
Tween._tweens.length = 0;
|
|
285
|
+
if (Tween._tickerAdded) {
|
|
286
|
+
pixi_js.Ticker.shared.remove(Tween.tick);
|
|
287
|
+
Tween._tickerAdded = false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// ─── Internal ──────────────────────────────────────────
|
|
291
|
+
static ensureTicker() {
|
|
292
|
+
if (Tween._tickerAdded)
|
|
293
|
+
return;
|
|
294
|
+
Tween._tickerAdded = true;
|
|
295
|
+
pixi_js.Ticker.shared.add(Tween.tick);
|
|
296
|
+
}
|
|
297
|
+
static tick = (ticker) => {
|
|
298
|
+
const dt = ticker.deltaMS;
|
|
299
|
+
const completed = [];
|
|
300
|
+
for (const tw of Tween._tweens) {
|
|
301
|
+
tw.elapsed += dt;
|
|
302
|
+
if (tw.elapsed < tw.delay)
|
|
303
|
+
continue;
|
|
304
|
+
const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
|
|
305
|
+
const t = tw.easing(raw);
|
|
306
|
+
// Interpolate each property
|
|
307
|
+
for (const key of Object.keys(tw.to)) {
|
|
308
|
+
const start = tw.from[key];
|
|
309
|
+
const end = tw.to[key];
|
|
310
|
+
const value = start + (end - start) * t;
|
|
311
|
+
Tween.setProperty(tw.target, key, value);
|
|
312
|
+
}
|
|
313
|
+
tw.onUpdate?.(raw);
|
|
314
|
+
if (raw >= 1) {
|
|
315
|
+
completed.push(tw);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Remove completed tweens
|
|
319
|
+
for (const tw of completed) {
|
|
320
|
+
const idx = Tween._tweens.indexOf(tw);
|
|
321
|
+
if (idx !== -1)
|
|
322
|
+
Tween._tweens.splice(idx, 1);
|
|
323
|
+
tw.resolve();
|
|
324
|
+
}
|
|
325
|
+
// Remove ticker when no active tweens
|
|
326
|
+
if (Tween._tweens.length === 0 && Tween._tickerAdded) {
|
|
327
|
+
pixi_js.Ticker.shared.remove(Tween.tick);
|
|
328
|
+
Tween._tickerAdded = false;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
|
|
333
|
+
*/
|
|
334
|
+
static getProperty(target, key) {
|
|
335
|
+
const parts = key.split('.');
|
|
336
|
+
let obj = target;
|
|
337
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
338
|
+
obj = obj[parts[i]];
|
|
339
|
+
}
|
|
340
|
+
return obj[parts[parts.length - 1]] ?? 0;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Set a potentially nested property.
|
|
344
|
+
*/
|
|
345
|
+
static setProperty(target, key, value) {
|
|
346
|
+
const parts = key.split('.');
|
|
347
|
+
let obj = target;
|
|
348
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
349
|
+
obj = obj[parts[i]];
|
|
350
|
+
}
|
|
351
|
+
obj[parts[parts.length - 1]] = value;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Manages the scene stack and transitions between scenes.
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* ```ts
|
|
360
|
+
* const scenes = new SceneManager(app.stage);
|
|
361
|
+
* scenes.register('loading', LoadingScene);
|
|
362
|
+
* scenes.register('game', GameScene);
|
|
363
|
+
* await scenes.goto('loading');
|
|
364
|
+
* ```
|
|
365
|
+
*/
|
|
366
|
+
class SceneManager extends EventEmitter {
|
|
367
|
+
static MAX_TRANSITION_DEPTH = 10;
|
|
368
|
+
/** Root container that scenes are added to */
|
|
369
|
+
root;
|
|
370
|
+
registry = new Map();
|
|
371
|
+
stack = [];
|
|
372
|
+
_transitionDepth = 0;
|
|
373
|
+
/** Current viewport dimensions — set by ViewportManager */
|
|
374
|
+
_width = 0;
|
|
375
|
+
_height = 0;
|
|
376
|
+
/** @internal GameApplication reference — passed to scenes */
|
|
377
|
+
_app;
|
|
378
|
+
constructor(root) {
|
|
379
|
+
super();
|
|
380
|
+
if (root)
|
|
381
|
+
this.root = root;
|
|
382
|
+
}
|
|
383
|
+
/** @internal Set the root container (called by GameApplication after PixiJS init) */
|
|
384
|
+
setRoot(root) {
|
|
385
|
+
this.root = root;
|
|
386
|
+
}
|
|
387
|
+
/** @internal Set the app reference (called by GameApplication) */
|
|
388
|
+
setApp(app) {
|
|
389
|
+
this._app = app;
|
|
390
|
+
}
|
|
391
|
+
/** Register a scene class by key */
|
|
392
|
+
register(key, ctor) {
|
|
393
|
+
this.registry.set(key, ctor);
|
|
394
|
+
return this;
|
|
395
|
+
}
|
|
396
|
+
/** Get the current (topmost) scene entry */
|
|
397
|
+
get current() {
|
|
398
|
+
return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null;
|
|
399
|
+
}
|
|
400
|
+
/** Get the current scene key */
|
|
401
|
+
get currentKey() {
|
|
402
|
+
return this.current?.key ?? null;
|
|
403
|
+
}
|
|
404
|
+
/** Whether a scene transition is in progress */
|
|
405
|
+
get isTransitioning() {
|
|
406
|
+
return this._transitionDepth > 0;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Navigate to a scene, replacing the entire stack.
|
|
410
|
+
*/
|
|
411
|
+
async goto(key, data, transition) {
|
|
412
|
+
if (this._transitionDepth >= SceneManager.MAX_TRANSITION_DEPTH) {
|
|
413
|
+
throw new Error('[SceneManager] Max transition depth exceeded — possible infinite loop');
|
|
414
|
+
}
|
|
415
|
+
const prevKey = this.currentKey;
|
|
416
|
+
// Exit all current scenes
|
|
417
|
+
while (this.stack.length > 0) {
|
|
418
|
+
await this.popInternal(false);
|
|
419
|
+
}
|
|
420
|
+
// Enter new scene
|
|
421
|
+
await this.pushInternal(key, data, transition);
|
|
422
|
+
this.emit('change', { from: prevKey, to: key });
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Push a scene onto the stack (the previous scene stays underneath).
|
|
426
|
+
* Useful for overlays, modals, pause screens.
|
|
427
|
+
*/
|
|
428
|
+
async push(key, data, transition) {
|
|
429
|
+
if (this._transitionDepth >= SceneManager.MAX_TRANSITION_DEPTH) {
|
|
430
|
+
throw new Error('[SceneManager] Max transition depth exceeded — possible infinite loop');
|
|
431
|
+
}
|
|
432
|
+
const prevKey = this.currentKey;
|
|
433
|
+
await this.pushInternal(key, data, transition);
|
|
434
|
+
this.emit('change', { from: prevKey, to: key });
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Pop the top scene from the stack.
|
|
438
|
+
*/
|
|
439
|
+
async pop(transition) {
|
|
440
|
+
if (this.stack.length <= 1) {
|
|
441
|
+
console.warn('[SceneManager] Cannot pop the last scene');
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (this._transitionDepth >= SceneManager.MAX_TRANSITION_DEPTH) {
|
|
445
|
+
throw new Error('[SceneManager] Max transition depth exceeded — possible infinite loop');
|
|
446
|
+
}
|
|
447
|
+
const prevKey = this.currentKey;
|
|
448
|
+
await this.popInternal(true, transition);
|
|
449
|
+
this.emit('change', { from: prevKey, to: this.currentKey });
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Replace the top scene with a new one.
|
|
453
|
+
*/
|
|
454
|
+
async replace(key, data, transition) {
|
|
455
|
+
if (this._transitionDepth >= SceneManager.MAX_TRANSITION_DEPTH) {
|
|
456
|
+
throw new Error('[SceneManager] Max transition depth exceeded — possible infinite loop');
|
|
457
|
+
}
|
|
458
|
+
const prevKey = this.currentKey;
|
|
459
|
+
await this.popInternal(false);
|
|
460
|
+
await this.pushInternal(key, data, transition);
|
|
461
|
+
this.emit('change', { from: prevKey, to: key });
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Called every frame by GameApplication.
|
|
465
|
+
*/
|
|
466
|
+
update(dt) {
|
|
467
|
+
// Update only the top scene
|
|
468
|
+
this.current?.scene.onUpdate?.(dt);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Called on viewport resize.
|
|
472
|
+
*/
|
|
473
|
+
resize(width, height) {
|
|
474
|
+
this._width = width;
|
|
475
|
+
this._height = height;
|
|
476
|
+
// Notify all scenes in the stack
|
|
477
|
+
for (const entry of this.stack) {
|
|
478
|
+
entry.scene.onResize?.(width, height);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Destroy all scenes and clear the manager.
|
|
483
|
+
*/
|
|
484
|
+
destroy() {
|
|
485
|
+
for (const entry of this.stack) {
|
|
486
|
+
entry.scene.onDestroy?.();
|
|
487
|
+
entry.scene.container.destroy({ children: true });
|
|
488
|
+
}
|
|
489
|
+
this.stack.length = 0;
|
|
490
|
+
this.registry.clear();
|
|
491
|
+
this.removeAllListeners();
|
|
492
|
+
}
|
|
493
|
+
// ─── Internal ──────────────────────────────────────────
|
|
494
|
+
createScene(key) {
|
|
495
|
+
const Ctor = this.registry.get(key);
|
|
496
|
+
if (!Ctor) {
|
|
497
|
+
throw new Error(`[SceneManager] Scene "${key}" is not registered`);
|
|
498
|
+
}
|
|
499
|
+
const scene = new Ctor();
|
|
500
|
+
if (this._app) {
|
|
501
|
+
scene.__engineApp = this._app;
|
|
502
|
+
}
|
|
503
|
+
return scene;
|
|
504
|
+
}
|
|
505
|
+
async pushInternal(key, data, transition) {
|
|
506
|
+
this._transitionDepth++;
|
|
507
|
+
const scene = this.createScene(key);
|
|
508
|
+
this.root.addChild(scene.container);
|
|
509
|
+
// Set initial size
|
|
510
|
+
if (this._width && this._height) {
|
|
511
|
+
scene.onResize?.(this._width, this._height);
|
|
512
|
+
}
|
|
513
|
+
// Transition in
|
|
514
|
+
await this.transitionIn(scene.container, transition);
|
|
515
|
+
// Push to stack BEFORE onEnter so currentKey is correct during initialization
|
|
516
|
+
this.stack.push({ scene, key });
|
|
517
|
+
await scene.onEnter?.(data);
|
|
518
|
+
this._transitionDepth--;
|
|
519
|
+
}
|
|
520
|
+
async popInternal(showTransition, transition) {
|
|
521
|
+
const entry = this.stack.pop();
|
|
522
|
+
if (!entry)
|
|
523
|
+
return;
|
|
524
|
+
this._transitionDepth++;
|
|
525
|
+
await entry.scene.onExit?.();
|
|
526
|
+
if (showTransition) {
|
|
527
|
+
await this.transitionOut(entry.scene.container, transition);
|
|
528
|
+
}
|
|
529
|
+
entry.scene.onDestroy?.();
|
|
530
|
+
entry.scene.container.destroy({ children: true });
|
|
531
|
+
this._transitionDepth--;
|
|
532
|
+
}
|
|
533
|
+
async transitionIn(container, config) {
|
|
534
|
+
const type = config?.type ?? TransitionType.NONE;
|
|
535
|
+
const duration = config?.duration ?? 300;
|
|
536
|
+
if (type === TransitionType.NONE || duration <= 0)
|
|
537
|
+
return;
|
|
538
|
+
if (type === TransitionType.FADE) {
|
|
539
|
+
container.alpha = 0;
|
|
540
|
+
await Tween.to(container, { alpha: 1 }, duration, config?.easing);
|
|
541
|
+
}
|
|
542
|
+
else if (type === TransitionType.SLIDE_LEFT) {
|
|
543
|
+
container.x = this._width;
|
|
544
|
+
await Tween.to(container, { x: 0 }, duration, config?.easing);
|
|
545
|
+
}
|
|
546
|
+
else if (type === TransitionType.SLIDE_RIGHT) {
|
|
547
|
+
container.x = -this._width;
|
|
548
|
+
await Tween.to(container, { x: 0 }, duration, config?.easing);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async transitionOut(container, config) {
|
|
552
|
+
const type = config?.type ?? TransitionType.FADE;
|
|
553
|
+
const duration = config?.duration ?? 300;
|
|
554
|
+
if (type === TransitionType.NONE || duration <= 0)
|
|
555
|
+
return;
|
|
556
|
+
if (type === TransitionType.FADE) {
|
|
557
|
+
await Tween.to(container, { alpha: 0 }, duration, config?.easing);
|
|
558
|
+
}
|
|
559
|
+
else if (type === TransitionType.SLIDE_LEFT) {
|
|
560
|
+
await Tween.to(container, { x: -this._width }, duration, config?.easing);
|
|
561
|
+
}
|
|
562
|
+
else if (type === TransitionType.SLIDE_RIGHT) {
|
|
563
|
+
await Tween.to(container, { x: this._width }, duration, config?.easing);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Manages game asset loading with progress tracking, bundle support, and
|
|
570
|
+
* automatic base path resolution from SDK's assetsUrl.
|
|
571
|
+
*
|
|
572
|
+
* Wraps PixiJS Assets API with a typed, game-oriented interface.
|
|
573
|
+
*
|
|
574
|
+
* @example
|
|
575
|
+
* ```ts
|
|
576
|
+
* const assets = new AssetManager('https://cdn.example.com/game/', manifest);
|
|
577
|
+
* await assets.init();
|
|
578
|
+
* await assets.loadBundle('preload', (p) => console.log(p));
|
|
579
|
+
* const texture = assets.get<Texture>('hero');
|
|
580
|
+
* ```
|
|
581
|
+
*/
|
|
582
|
+
class AssetManager {
|
|
583
|
+
_initialized = false;
|
|
584
|
+
_basePath;
|
|
585
|
+
_manifest;
|
|
586
|
+
_loadedBundles = new Set();
|
|
587
|
+
constructor(basePath = '', manifest) {
|
|
588
|
+
this._basePath = basePath;
|
|
589
|
+
this._manifest = manifest ?? null;
|
|
590
|
+
}
|
|
591
|
+
/** Whether the asset system has been initialized */
|
|
592
|
+
get initialized() {
|
|
593
|
+
return this._initialized;
|
|
594
|
+
}
|
|
595
|
+
/** Base path for all assets (usually from SDK's assetsUrl) */
|
|
596
|
+
get basePath() {
|
|
597
|
+
return this._basePath;
|
|
598
|
+
}
|
|
599
|
+
/** Set of loaded bundle names */
|
|
600
|
+
get loadedBundles() {
|
|
601
|
+
return this._loadedBundles;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Initialize the asset system.
|
|
605
|
+
* Must be called before loading any assets.
|
|
606
|
+
*/
|
|
607
|
+
async init() {
|
|
608
|
+
if (this._initialized)
|
|
609
|
+
return;
|
|
610
|
+
await pixi_js.Assets.init({
|
|
611
|
+
basePath: this._basePath || undefined,
|
|
612
|
+
texturePreference: {
|
|
613
|
+
resolution: Math.min(window.devicePixelRatio, 2),
|
|
614
|
+
format: ['webp', 'png'],
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
// Register bundles from manifest
|
|
618
|
+
if (this._manifest) {
|
|
619
|
+
for (const bundle of this._manifest.bundles) {
|
|
620
|
+
pixi_js.Assets.addBundle(bundle.name, bundle.assets.map((a) => ({
|
|
621
|
+
alias: a.alias,
|
|
622
|
+
src: a.src,
|
|
623
|
+
data: a.data,
|
|
624
|
+
})));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
this._initialized = true;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Load a single bundle by name.
|
|
631
|
+
*
|
|
632
|
+
* @param name - Bundle name (must exist in the manifest)
|
|
633
|
+
* @param onProgress - Progress callback (0..1)
|
|
634
|
+
* @returns Loaded assets map
|
|
635
|
+
*/
|
|
636
|
+
async loadBundle(name, onProgress) {
|
|
637
|
+
this.ensureInitialized();
|
|
638
|
+
const result = await pixi_js.Assets.loadBundle(name, onProgress);
|
|
639
|
+
this._loadedBundles.add(name);
|
|
640
|
+
return result;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Load multiple bundles simultaneously.
|
|
644
|
+
* Progress is aggregated across all bundles.
|
|
645
|
+
*
|
|
646
|
+
* @param names - Bundle names
|
|
647
|
+
* @param onProgress - Progress callback (0..1)
|
|
648
|
+
*/
|
|
649
|
+
async loadBundles(names, onProgress) {
|
|
650
|
+
this.ensureInitialized();
|
|
651
|
+
const result = await pixi_js.Assets.loadBundle(names, onProgress);
|
|
652
|
+
for (const name of names) {
|
|
653
|
+
this._loadedBundles.add(name);
|
|
654
|
+
}
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Load individual assets by URL or alias.
|
|
659
|
+
*
|
|
660
|
+
* @param urls - Asset URLs or aliases
|
|
661
|
+
* @param onProgress - Progress callback (0..1)
|
|
662
|
+
*/
|
|
663
|
+
async load(urls, onProgress) {
|
|
664
|
+
this.ensureInitialized();
|
|
665
|
+
return pixi_js.Assets.load(urls, onProgress);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Get a loaded asset synchronously from cache.
|
|
669
|
+
*
|
|
670
|
+
* @param alias - Asset alias
|
|
671
|
+
* @throws if not loaded
|
|
672
|
+
*/
|
|
673
|
+
get(alias) {
|
|
674
|
+
return pixi_js.Assets.get(alias);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Unload a bundle to free memory.
|
|
678
|
+
*/
|
|
679
|
+
async unloadBundle(name) {
|
|
680
|
+
await pixi_js.Assets.unloadBundle(name);
|
|
681
|
+
this._loadedBundles.delete(name);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Start background loading a bundle (low-priority preload).
|
|
685
|
+
* Useful for loading bonus round assets while player is in base game.
|
|
686
|
+
*/
|
|
687
|
+
async backgroundLoad(name) {
|
|
688
|
+
this.ensureInitialized();
|
|
689
|
+
await pixi_js.Assets.backgroundLoadBundle(name);
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Get all bundle names from the manifest.
|
|
693
|
+
*/
|
|
694
|
+
getBundleNames() {
|
|
695
|
+
return this._manifest?.bundles.map((b) => b.name) ?? [];
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Check if a bundle is loaded.
|
|
699
|
+
*/
|
|
700
|
+
isBundleLoaded(name) {
|
|
701
|
+
return this._loadedBundles.has(name);
|
|
702
|
+
}
|
|
703
|
+
ensureInitialized() {
|
|
704
|
+
if (!this._initialized) {
|
|
705
|
+
throw new Error('[AssetManager] Not initialized. Call init() first.');
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Manages all game audio: music, SFX, UI sounds, ambient.
|
|
712
|
+
*
|
|
713
|
+
* Optional dependency on @pixi/sound — if not installed, AudioManager
|
|
714
|
+
* operates as a silent no-op (graceful degradation).
|
|
715
|
+
*
|
|
716
|
+
* Features:
|
|
717
|
+
* - Per-category volume control (music, sfx, ui, ambient)
|
|
718
|
+
* - Music crossfade and looping
|
|
719
|
+
* - Mobile audio unlock on first interaction
|
|
720
|
+
* - Mute state persistence in localStorage
|
|
721
|
+
* - Global mute/unmute
|
|
722
|
+
*
|
|
723
|
+
* @example
|
|
724
|
+
* ```ts
|
|
725
|
+
* const audio = new AudioManager({ music: 0.5, sfx: 0.8 });
|
|
726
|
+
* await audio.init();
|
|
727
|
+
* audio.playMusic('bg-music');
|
|
728
|
+
* audio.play('spin-click', 'sfx');
|
|
729
|
+
* ```
|
|
730
|
+
*/
|
|
731
|
+
class AudioManager {
|
|
732
|
+
_soundModule = null;
|
|
733
|
+
_initialized = false;
|
|
734
|
+
_globalMuted = false;
|
|
735
|
+
_persist;
|
|
736
|
+
_storageKey;
|
|
737
|
+
_categories;
|
|
738
|
+
_currentMusic = null;
|
|
739
|
+
_unlocked = false;
|
|
740
|
+
_unlockHandler = null;
|
|
741
|
+
constructor(config) {
|
|
742
|
+
this._persist = config?.persist ?? true;
|
|
743
|
+
this._storageKey = config?.storageKey ?? 'ge_audio';
|
|
744
|
+
this._categories = {
|
|
745
|
+
music: { volume: config?.music ?? 0.7, muted: false },
|
|
746
|
+
sfx: { volume: config?.sfx ?? 1.0, muted: false },
|
|
747
|
+
ui: { volume: config?.ui ?? 0.8, muted: false },
|
|
748
|
+
ambient: { volume: config?.ambient ?? 0.5, muted: false },
|
|
749
|
+
};
|
|
750
|
+
// Restore persisted state
|
|
751
|
+
if (this._persist) {
|
|
752
|
+
this.restoreState();
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/** Whether the audio system is initialized */
|
|
756
|
+
get initialized() {
|
|
757
|
+
return this._initialized;
|
|
758
|
+
}
|
|
759
|
+
/** Whether audio is globally muted */
|
|
760
|
+
get muted() {
|
|
761
|
+
return this._globalMuted;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Initialize the audio system.
|
|
765
|
+
* Dynamically imports @pixi/sound to keep it optional.
|
|
766
|
+
*/
|
|
767
|
+
async init() {
|
|
768
|
+
if (this._initialized)
|
|
769
|
+
return;
|
|
770
|
+
try {
|
|
771
|
+
this._soundModule = await import('@pixi/sound');
|
|
772
|
+
this._initialized = true;
|
|
773
|
+
this.applyVolumes();
|
|
774
|
+
if (this._globalMuted) {
|
|
775
|
+
this._soundModule.sound.muteAll();
|
|
776
|
+
}
|
|
777
|
+
this.setupMobileUnlock();
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
console.warn('[AudioManager] @pixi/sound not available. Audio disabled.');
|
|
781
|
+
this._initialized = false;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Play a sound effect.
|
|
786
|
+
*
|
|
787
|
+
* @param alias - Sound alias (must be loaded via AssetManager)
|
|
788
|
+
* @param category - Audio category (default: 'sfx')
|
|
789
|
+
* @param options - Additional play options
|
|
790
|
+
*/
|
|
791
|
+
play(alias, category = 'sfx', options) {
|
|
792
|
+
if (!this._initialized || !this._soundModule)
|
|
793
|
+
return;
|
|
794
|
+
if (this._globalMuted || this._categories[category].muted)
|
|
795
|
+
return;
|
|
796
|
+
const { sound } = this._soundModule;
|
|
797
|
+
const vol = (options?.volume ?? 1) * this._categories[category].volume;
|
|
798
|
+
try {
|
|
799
|
+
sound.play(alias, {
|
|
800
|
+
volume: vol,
|
|
801
|
+
loop: options?.loop ?? false,
|
|
802
|
+
speed: options?.speed ?? 1,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
catch (e) {
|
|
806
|
+
console.warn(`[AudioManager] Failed to play "${alias}":`, e);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Play background music with optional crossfade.
|
|
811
|
+
*
|
|
812
|
+
* @param alias - Music alias
|
|
813
|
+
* @param fadeDuration - Crossfade duration in ms (default: 500)
|
|
814
|
+
*/
|
|
815
|
+
playMusic(alias, fadeDuration = 500) {
|
|
816
|
+
if (!this._initialized || !this._soundModule)
|
|
817
|
+
return;
|
|
818
|
+
const { sound } = this._soundModule;
|
|
819
|
+
// Stop current music with fade-out, start new music with fade-in
|
|
820
|
+
if (this._currentMusic && fadeDuration > 0) {
|
|
821
|
+
const prevAlias = this._currentMusic;
|
|
822
|
+
this._currentMusic = alias;
|
|
823
|
+
if (this._globalMuted || this._categories.music.muted)
|
|
824
|
+
return;
|
|
825
|
+
// Fade out the previous track
|
|
826
|
+
this.fadeVolume(prevAlias, this._categories.music.volume, 0, fadeDuration, () => {
|
|
827
|
+
try {
|
|
828
|
+
sound.stop(prevAlias);
|
|
829
|
+
}
|
|
830
|
+
catch { /* ignore */ }
|
|
831
|
+
});
|
|
832
|
+
// Start new track at zero volume, fade in
|
|
833
|
+
try {
|
|
834
|
+
sound.play(alias, {
|
|
835
|
+
volume: 0,
|
|
836
|
+
loop: true,
|
|
837
|
+
});
|
|
838
|
+
this.fadeVolume(alias, 0, this._categories.music.volume, fadeDuration);
|
|
839
|
+
}
|
|
840
|
+
catch (e) {
|
|
841
|
+
console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
// No crossfade — instant switch
|
|
846
|
+
if (this._currentMusic) {
|
|
847
|
+
try {
|
|
848
|
+
sound.stop(this._currentMusic);
|
|
849
|
+
}
|
|
850
|
+
catch { /* ignore */ }
|
|
851
|
+
}
|
|
852
|
+
this._currentMusic = alias;
|
|
853
|
+
if (this._globalMuted || this._categories.music.muted)
|
|
854
|
+
return;
|
|
855
|
+
try {
|
|
856
|
+
sound.play(alias, {
|
|
857
|
+
volume: this._categories.music.volume,
|
|
858
|
+
loop: true,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
catch (e) {
|
|
862
|
+
console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Stop current music.
|
|
868
|
+
*/
|
|
869
|
+
stopMusic() {
|
|
870
|
+
if (!this._initialized || !this._soundModule || !this._currentMusic)
|
|
871
|
+
return;
|
|
872
|
+
const { sound } = this._soundModule;
|
|
873
|
+
try {
|
|
874
|
+
sound.stop(this._currentMusic);
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
// ignore
|
|
878
|
+
}
|
|
879
|
+
this._currentMusic = null;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Stop all sounds.
|
|
883
|
+
*/
|
|
884
|
+
stopAll() {
|
|
885
|
+
if (!this._initialized || !this._soundModule)
|
|
886
|
+
return;
|
|
887
|
+
const { sound } = this._soundModule;
|
|
888
|
+
sound.stopAll();
|
|
889
|
+
this._currentMusic = null;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Set volume for a category.
|
|
893
|
+
*/
|
|
894
|
+
setVolume(category, volume) {
|
|
895
|
+
this._categories[category].volume = Math.max(0, Math.min(1, volume));
|
|
896
|
+
this.applyVolumes();
|
|
897
|
+
this.saveState();
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Get volume for a category.
|
|
901
|
+
*/
|
|
902
|
+
getVolume(category) {
|
|
903
|
+
return this._categories[category].volume;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Mute a specific category.
|
|
907
|
+
*/
|
|
908
|
+
muteCategory(category) {
|
|
909
|
+
this._categories[category].muted = true;
|
|
910
|
+
this.applyVolumes();
|
|
911
|
+
this.saveState();
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Unmute a specific category.
|
|
915
|
+
*/
|
|
916
|
+
unmuteCategory(category) {
|
|
917
|
+
this._categories[category].muted = false;
|
|
918
|
+
this.applyVolumes();
|
|
919
|
+
this.saveState();
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Toggle mute for a category.
|
|
923
|
+
*/
|
|
924
|
+
toggleCategory(category) {
|
|
925
|
+
this._categories[category].muted = !this._categories[category].muted;
|
|
926
|
+
this.applyVolumes();
|
|
927
|
+
this.saveState();
|
|
928
|
+
return this._categories[category].muted;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Mute all audio globally.
|
|
932
|
+
*/
|
|
933
|
+
muteAll() {
|
|
934
|
+
this._globalMuted = true;
|
|
935
|
+
if (this._soundModule) {
|
|
936
|
+
this._soundModule.sound.muteAll();
|
|
937
|
+
}
|
|
938
|
+
this.saveState();
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Unmute all audio globally.
|
|
942
|
+
*/
|
|
943
|
+
unmuteAll() {
|
|
944
|
+
this._globalMuted = false;
|
|
945
|
+
if (this._soundModule) {
|
|
946
|
+
this._soundModule.sound.unmuteAll();
|
|
947
|
+
}
|
|
948
|
+
this.saveState();
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Toggle global mute.
|
|
952
|
+
*/
|
|
953
|
+
toggleMute() {
|
|
954
|
+
if (this._globalMuted) {
|
|
955
|
+
this.unmuteAll();
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
this.muteAll();
|
|
959
|
+
}
|
|
960
|
+
return this._globalMuted;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Duck music volume (e.g., during big win presentation).
|
|
964
|
+
*
|
|
965
|
+
* @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
|
|
966
|
+
*/
|
|
967
|
+
duckMusic(factor) {
|
|
968
|
+
if (!this._initialized || !this._soundModule || !this._currentMusic)
|
|
969
|
+
return;
|
|
970
|
+
const { sound } = this._soundModule;
|
|
971
|
+
const vol = this._categories.music.volume * factor;
|
|
972
|
+
try {
|
|
973
|
+
sound.volume(this._currentMusic, vol);
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
// ignore
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Restore music to normal volume after ducking.
|
|
981
|
+
*/
|
|
982
|
+
unduckMusic() {
|
|
983
|
+
if (!this._initialized || !this._soundModule || !this._currentMusic)
|
|
984
|
+
return;
|
|
985
|
+
const { sound } = this._soundModule;
|
|
986
|
+
try {
|
|
987
|
+
sound.volume(this._currentMusic, this._categories.music.volume);
|
|
988
|
+
}
|
|
989
|
+
catch {
|
|
990
|
+
// ignore
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Destroy the audio manager and free resources.
|
|
995
|
+
*/
|
|
996
|
+
destroy() {
|
|
997
|
+
this.stopAll();
|
|
998
|
+
this.removeMobileUnlock();
|
|
999
|
+
if (this._soundModule) {
|
|
1000
|
+
this._soundModule.sound.removeAll();
|
|
1001
|
+
}
|
|
1002
|
+
this._initialized = false;
|
|
1003
|
+
}
|
|
1004
|
+
// ─── Private ───────────────────────────────────────────
|
|
1005
|
+
/**
|
|
1006
|
+
* Smoothly fade a sound's volume from `fromVol` to `toVol` over `durationMs`.
|
|
1007
|
+
*/
|
|
1008
|
+
fadeVolume(alias, fromVol, toVol, durationMs, onComplete) {
|
|
1009
|
+
if (!this._soundModule)
|
|
1010
|
+
return;
|
|
1011
|
+
const { sound } = this._soundModule;
|
|
1012
|
+
const startTime = Date.now();
|
|
1013
|
+
const tick = () => {
|
|
1014
|
+
const elapsed = Date.now() - startTime;
|
|
1015
|
+
const t = Math.min(elapsed / durationMs, 1);
|
|
1016
|
+
const vol = fromVol + (toVol - fromVol) * t;
|
|
1017
|
+
try {
|
|
1018
|
+
sound.volume(alias, vol);
|
|
1019
|
+
}
|
|
1020
|
+
catch { /* ignore */ }
|
|
1021
|
+
if (t < 1) {
|
|
1022
|
+
requestAnimationFrame(tick);
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
onComplete?.();
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
requestAnimationFrame(tick);
|
|
1029
|
+
}
|
|
1030
|
+
applyVolumes() {
|
|
1031
|
+
if (!this._soundModule)
|
|
1032
|
+
return;
|
|
1033
|
+
const { sound } = this._soundModule;
|
|
1034
|
+
// Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
|
|
1035
|
+
// not by volumeAll — mixing both leaves mute un-undoable after reload.
|
|
1036
|
+
sound.volumeAll = 1;
|
|
1037
|
+
}
|
|
1038
|
+
setupMobileUnlock() {
|
|
1039
|
+
if (this._unlocked)
|
|
1040
|
+
return;
|
|
1041
|
+
this._unlockHandler = () => {
|
|
1042
|
+
if (!this._soundModule)
|
|
1043
|
+
return;
|
|
1044
|
+
const { sound } = this._soundModule;
|
|
1045
|
+
// Resume WebAudio context
|
|
1046
|
+
if (sound.context?.audioContext?.state === 'suspended') {
|
|
1047
|
+
sound.context.audioContext.resume();
|
|
1048
|
+
}
|
|
1049
|
+
this._unlocked = true;
|
|
1050
|
+
this.removeMobileUnlock();
|
|
1051
|
+
};
|
|
1052
|
+
const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
|
|
1053
|
+
for (const event of events) {
|
|
1054
|
+
document.addEventListener(event, this._unlockHandler, { once: true });
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
removeMobileUnlock() {
|
|
1058
|
+
if (!this._unlockHandler)
|
|
1059
|
+
return;
|
|
1060
|
+
const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
|
|
1061
|
+
for (const event of events) {
|
|
1062
|
+
document.removeEventListener(event, this._unlockHandler);
|
|
1063
|
+
}
|
|
1064
|
+
this._unlockHandler = null;
|
|
1065
|
+
}
|
|
1066
|
+
saveState() {
|
|
1067
|
+
if (!this._persist)
|
|
1068
|
+
return;
|
|
1069
|
+
try {
|
|
1070
|
+
const state = {
|
|
1071
|
+
globalMuted: this._globalMuted,
|
|
1072
|
+
categories: this._categories,
|
|
1073
|
+
};
|
|
1074
|
+
localStorage.setItem(this._storageKey, JSON.stringify(state));
|
|
1075
|
+
}
|
|
1076
|
+
catch {
|
|
1077
|
+
// localStorage may not be available
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
restoreState() {
|
|
1081
|
+
try {
|
|
1082
|
+
const raw = localStorage.getItem(this._storageKey);
|
|
1083
|
+
if (!raw)
|
|
1084
|
+
return;
|
|
1085
|
+
const state = JSON.parse(raw);
|
|
1086
|
+
if (typeof state.globalMuted === 'boolean') {
|
|
1087
|
+
this._globalMuted = state.globalMuted;
|
|
1088
|
+
}
|
|
1089
|
+
if (state.categories) {
|
|
1090
|
+
for (const key of ['music', 'sfx', 'ui', 'ambient']) {
|
|
1091
|
+
if (state.categories[key]) {
|
|
1092
|
+
this._categories[key] = {
|
|
1093
|
+
volume: state.categories[key].volume ?? this._categories[key].volume,
|
|
1094
|
+
muted: state.categories[key].muted ?? false,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
catch {
|
|
1101
|
+
// ignore
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Unified input manager for touch, mouse, and keyboard.
|
|
1108
|
+
*
|
|
1109
|
+
* Features:
|
|
1110
|
+
* - Unified pointer events (works with touch + mouse)
|
|
1111
|
+
* - Swipe gesture detection
|
|
1112
|
+
* - Keyboard input with isKeyDown state
|
|
1113
|
+
* - Input locking (block input during animations)
|
|
1114
|
+
*
|
|
1115
|
+
* @example
|
|
1116
|
+
* ```ts
|
|
1117
|
+
* const input = new InputManager(app.canvas);
|
|
1118
|
+
*
|
|
1119
|
+
* input.on('tap', ({ x, y }) => console.log('Tapped at', x, y));
|
|
1120
|
+
* input.on('swipe', ({ direction }) => console.log('Swiped', direction));
|
|
1121
|
+
* input.on('keydown', ({ key }) => {
|
|
1122
|
+
* if (key === ' ') spin();
|
|
1123
|
+
* });
|
|
1124
|
+
*
|
|
1125
|
+
* // Block input during animations
|
|
1126
|
+
* input.lock();
|
|
1127
|
+
* await playAnimation();
|
|
1128
|
+
* input.unlock();
|
|
1129
|
+
* ```
|
|
1130
|
+
*/
|
|
1131
|
+
class InputManager extends EventEmitter {
|
|
1132
|
+
_canvas;
|
|
1133
|
+
_locked = false;
|
|
1134
|
+
_keysDown = new Set();
|
|
1135
|
+
_destroyed = false;
|
|
1136
|
+
// Viewport transform (set by ViewportManager via setViewportTransform)
|
|
1137
|
+
_viewportScale = 1;
|
|
1138
|
+
_viewportOffsetX = 0;
|
|
1139
|
+
_viewportOffsetY = 0;
|
|
1140
|
+
// Gesture tracking
|
|
1141
|
+
_pointerStart = null;
|
|
1142
|
+
_swipeThreshold = 50; // minimum distance in px
|
|
1143
|
+
_swipeMaxTime = 300; // max ms for swipe gesture
|
|
1144
|
+
constructor(canvas) {
|
|
1145
|
+
super();
|
|
1146
|
+
this._canvas = canvas;
|
|
1147
|
+
this.setupPointerEvents();
|
|
1148
|
+
this.setupKeyboardEvents();
|
|
1149
|
+
}
|
|
1150
|
+
/** Whether input is currently locked */
|
|
1151
|
+
get locked() {
|
|
1152
|
+
return this._locked;
|
|
1153
|
+
}
|
|
1154
|
+
/** Lock all input (e.g., during animations) */
|
|
1155
|
+
lock() {
|
|
1156
|
+
this._locked = true;
|
|
1157
|
+
}
|
|
1158
|
+
/** Unlock input */
|
|
1159
|
+
unlock() {
|
|
1160
|
+
this._locked = false;
|
|
1161
|
+
}
|
|
1162
|
+
/** Check if a key is currently pressed */
|
|
1163
|
+
isKeyDown(key) {
|
|
1164
|
+
return this._keysDown.has(key.toLowerCase());
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Update the viewport transform used for DOM→world coordinate mapping.
|
|
1168
|
+
* Called automatically by GameApplication when ViewportManager emits resize.
|
|
1169
|
+
*/
|
|
1170
|
+
setViewportTransform(scale, offsetX, offsetY) {
|
|
1171
|
+
this._viewportScale = scale;
|
|
1172
|
+
this._viewportOffsetX = offsetX;
|
|
1173
|
+
this._viewportOffsetY = offsetY;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Convert a DOM canvas position to game-world coordinates,
|
|
1177
|
+
* accounting for viewport scaling and offset.
|
|
1178
|
+
*/
|
|
1179
|
+
getWorldPosition(canvasX, canvasY) {
|
|
1180
|
+
return {
|
|
1181
|
+
x: (canvasX - this._viewportOffsetX) / this._viewportScale,
|
|
1182
|
+
y: (canvasY - this._viewportOffsetY) / this._viewportScale,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
/** Destroy the input manager */
|
|
1186
|
+
destroy() {
|
|
1187
|
+
this._destroyed = true;
|
|
1188
|
+
this._canvas.removeEventListener('pointerdown', this.onPointerDown);
|
|
1189
|
+
this._canvas.removeEventListener('pointerup', this.onPointerUp);
|
|
1190
|
+
this._canvas.removeEventListener('pointermove', this.onPointerMove);
|
|
1191
|
+
document.removeEventListener('keydown', this.onKeyDown);
|
|
1192
|
+
document.removeEventListener('keyup', this.onKeyUp);
|
|
1193
|
+
this._keysDown.clear();
|
|
1194
|
+
this.removeAllListeners();
|
|
1195
|
+
}
|
|
1196
|
+
// ─── Private: Pointer ──────────────────────────────────
|
|
1197
|
+
setupPointerEvents() {
|
|
1198
|
+
this._canvas.addEventListener('pointerdown', this.onPointerDown);
|
|
1199
|
+
this._canvas.addEventListener('pointerup', this.onPointerUp);
|
|
1200
|
+
this._canvas.addEventListener('pointermove', this.onPointerMove);
|
|
1201
|
+
}
|
|
1202
|
+
onPointerDown = (e) => {
|
|
1203
|
+
if (this._locked || this._destroyed)
|
|
1204
|
+
return;
|
|
1205
|
+
const pos = this.getCanvasPosition(e);
|
|
1206
|
+
this._pointerStart = { ...pos, time: Date.now() };
|
|
1207
|
+
this.emit('press', pos);
|
|
1208
|
+
};
|
|
1209
|
+
onPointerUp = (e) => {
|
|
1210
|
+
if (this._locked || this._destroyed)
|
|
1211
|
+
return;
|
|
1212
|
+
const pos = this.getCanvasPosition(e);
|
|
1213
|
+
this.emit('release', pos);
|
|
1214
|
+
// Check for tap vs swipe
|
|
1215
|
+
if (this._pointerStart) {
|
|
1216
|
+
const dx = pos.x - this._pointerStart.x;
|
|
1217
|
+
const dy = pos.y - this._pointerStart.y;
|
|
1218
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1219
|
+
const elapsed = Date.now() - this._pointerStart.time;
|
|
1220
|
+
if (dist > this._swipeThreshold && elapsed < this._swipeMaxTime) {
|
|
1221
|
+
// Swipe detected
|
|
1222
|
+
const absDx = Math.abs(dx);
|
|
1223
|
+
const absDy = Math.abs(dy);
|
|
1224
|
+
let direction;
|
|
1225
|
+
if (absDx > absDy) {
|
|
1226
|
+
direction = dx > 0 ? 'right' : 'left';
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
direction = dy > 0 ? 'down' : 'up';
|
|
1230
|
+
}
|
|
1231
|
+
this.emit('swipe', { direction, velocity: dist / elapsed });
|
|
1232
|
+
}
|
|
1233
|
+
else if (dist < 10) {
|
|
1234
|
+
// Tap (minimal movement)
|
|
1235
|
+
this.emit('tap', pos);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
this._pointerStart = null;
|
|
1239
|
+
};
|
|
1240
|
+
onPointerMove = (e) => {
|
|
1241
|
+
if (this._locked || this._destroyed)
|
|
1242
|
+
return;
|
|
1243
|
+
this.emit('move', this.getCanvasPosition(e));
|
|
1244
|
+
};
|
|
1245
|
+
getCanvasPosition(e) {
|
|
1246
|
+
const rect = this._canvas.getBoundingClientRect();
|
|
1247
|
+
return {
|
|
1248
|
+
x: e.clientX - rect.left,
|
|
1249
|
+
y: e.clientY - rect.top,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
// ─── Private: Keyboard ─────────────────────────────────
|
|
1253
|
+
setupKeyboardEvents() {
|
|
1254
|
+
document.addEventListener('keydown', this.onKeyDown);
|
|
1255
|
+
document.addEventListener('keyup', this.onKeyUp);
|
|
1256
|
+
}
|
|
1257
|
+
onKeyDown = (e) => {
|
|
1258
|
+
if (this._locked || this._destroyed)
|
|
1259
|
+
return;
|
|
1260
|
+
this._keysDown.add(e.key.toLowerCase());
|
|
1261
|
+
this.emit('keydown', { key: e.key, code: e.code });
|
|
1262
|
+
};
|
|
1263
|
+
onKeyUp = (e) => {
|
|
1264
|
+
if (this._destroyed)
|
|
1265
|
+
return;
|
|
1266
|
+
this._keysDown.delete(e.key.toLowerCase());
|
|
1267
|
+
if (this._locked)
|
|
1268
|
+
return;
|
|
1269
|
+
this.emit('keyup', { key: e.key, code: e.code });
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Manages responsive scaling of the game canvas to fit its container.
|
|
1275
|
+
*
|
|
1276
|
+
* Supports three scale modes:
|
|
1277
|
+
* - **FIT** — letterbox/pillarbox to maintain aspect ratio (industry standard)
|
|
1278
|
+
* - **FILL** — fill container, crop edges
|
|
1279
|
+
* - **STRETCH** — stretch to fill (distorts)
|
|
1280
|
+
*
|
|
1281
|
+
* Also handles:
|
|
1282
|
+
* - Orientation detection (landscape/portrait)
|
|
1283
|
+
* - Safe areas (mobile notch)
|
|
1284
|
+
* - ResizeObserver for smooth container resizing
|
|
1285
|
+
*
|
|
1286
|
+
* @example
|
|
1287
|
+
* ```ts
|
|
1288
|
+
* const viewport = new ViewportManager(app, container, {
|
|
1289
|
+
* designWidth: 1920,
|
|
1290
|
+
* designHeight: 1080,
|
|
1291
|
+
* scaleMode: ScaleMode.FIT,
|
|
1292
|
+
* orientation: Orientation.LANDSCAPE,
|
|
1293
|
+
* });
|
|
1294
|
+
*
|
|
1295
|
+
* viewport.on('resize', ({ width, height, scale }) => {
|
|
1296
|
+
* console.log(`New size: ${width}x${height} @ ${scale}x`);
|
|
1297
|
+
* });
|
|
1298
|
+
* ```
|
|
1299
|
+
*/
|
|
1300
|
+
class ViewportManager extends EventEmitter {
|
|
1301
|
+
_app;
|
|
1302
|
+
_container;
|
|
1303
|
+
_config;
|
|
1304
|
+
_resizeObserver = null;
|
|
1305
|
+
_currentOrientation = Orientation.LANDSCAPE;
|
|
1306
|
+
_currentWidth = 0;
|
|
1307
|
+
_currentHeight = 0;
|
|
1308
|
+
_currentScale = 1;
|
|
1309
|
+
_destroyed = false;
|
|
1310
|
+
_resizeTimeout = null;
|
|
1311
|
+
constructor(app, container, config) {
|
|
1312
|
+
super();
|
|
1313
|
+
this._app = app;
|
|
1314
|
+
this._container = container;
|
|
1315
|
+
this._config = config;
|
|
1316
|
+
this.setupObserver();
|
|
1317
|
+
}
|
|
1318
|
+
/** Current canvas width in game units */
|
|
1319
|
+
get width() {
|
|
1320
|
+
return this._currentWidth;
|
|
1321
|
+
}
|
|
1322
|
+
/** Current canvas height in game units */
|
|
1323
|
+
get height() {
|
|
1324
|
+
return this._currentHeight;
|
|
1325
|
+
}
|
|
1326
|
+
/** Current scale factor */
|
|
1327
|
+
get scale() {
|
|
1328
|
+
return this._currentScale;
|
|
1329
|
+
}
|
|
1330
|
+
/** Current orientation */
|
|
1331
|
+
get orientation() {
|
|
1332
|
+
return this._currentOrientation;
|
|
1333
|
+
}
|
|
1334
|
+
/** Design reference width */
|
|
1335
|
+
get designWidth() {
|
|
1336
|
+
return this._config.designWidth;
|
|
1337
|
+
}
|
|
1338
|
+
/** Design reference height */
|
|
1339
|
+
get designHeight() {
|
|
1340
|
+
return this._config.designHeight;
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Force a resize calculation. Called automatically on container size change.
|
|
1344
|
+
*/
|
|
1345
|
+
refresh() {
|
|
1346
|
+
if (this._destroyed)
|
|
1347
|
+
return;
|
|
1348
|
+
const containerWidth = this._container.clientWidth || window.innerWidth;
|
|
1349
|
+
const containerHeight = this._container.clientHeight || window.innerHeight;
|
|
1350
|
+
if (containerWidth === 0 || containerHeight === 0)
|
|
1351
|
+
return;
|
|
1352
|
+
const { designWidth, designHeight, scaleMode } = this._config;
|
|
1353
|
+
const designRatio = designWidth / designHeight;
|
|
1354
|
+
const containerRatio = containerWidth / containerHeight;
|
|
1355
|
+
let gameWidth;
|
|
1356
|
+
let gameHeight;
|
|
1357
|
+
let scale;
|
|
1358
|
+
switch (scaleMode) {
|
|
1359
|
+
case ScaleMode.FIT: {
|
|
1360
|
+
if (containerRatio > designRatio) {
|
|
1361
|
+
// Container is wider → pillarbox
|
|
1362
|
+
scale = containerHeight / designHeight;
|
|
1363
|
+
gameWidth = designWidth;
|
|
1364
|
+
gameHeight = designHeight;
|
|
1365
|
+
}
|
|
1366
|
+
else {
|
|
1367
|
+
// Container is taller → letterbox
|
|
1368
|
+
scale = containerWidth / designWidth;
|
|
1369
|
+
gameWidth = designWidth;
|
|
1370
|
+
gameHeight = designHeight;
|
|
1371
|
+
}
|
|
1372
|
+
break;
|
|
1373
|
+
}
|
|
1374
|
+
case ScaleMode.FILL: {
|
|
1375
|
+
if (containerRatio > designRatio) {
|
|
1376
|
+
// Container is wider → crop top/bottom
|
|
1377
|
+
scale = containerWidth / designWidth;
|
|
1378
|
+
}
|
|
1379
|
+
else {
|
|
1380
|
+
// Container is taller → crop left/right
|
|
1381
|
+
scale = containerHeight / designHeight;
|
|
1382
|
+
}
|
|
1383
|
+
gameWidth = containerWidth / scale;
|
|
1384
|
+
gameHeight = containerHeight / scale;
|
|
1385
|
+
break;
|
|
1386
|
+
}
|
|
1387
|
+
case ScaleMode.STRETCH: {
|
|
1388
|
+
gameWidth = designWidth;
|
|
1389
|
+
gameHeight = designHeight;
|
|
1390
|
+
scale = 1; // stretch is handled by CSS
|
|
1391
|
+
break;
|
|
1392
|
+
}
|
|
1393
|
+
default:
|
|
1394
|
+
gameWidth = designWidth;
|
|
1395
|
+
gameHeight = designHeight;
|
|
1396
|
+
scale = 1;
|
|
1397
|
+
}
|
|
1398
|
+
// Resize the renderer
|
|
1399
|
+
this._app.renderer.resize(Math.round(containerWidth), Math.round(containerHeight));
|
|
1400
|
+
// Scale the stage
|
|
1401
|
+
const stageScale = scaleMode === ScaleMode.STRETCH
|
|
1402
|
+
? Math.min(containerWidth / designWidth, containerHeight / designHeight)
|
|
1403
|
+
: scale;
|
|
1404
|
+
this._app.stage.scale.set(stageScale);
|
|
1405
|
+
// Center the stage for FIT mode
|
|
1406
|
+
if (scaleMode === ScaleMode.FIT) {
|
|
1407
|
+
this._app.stage.x = Math.round((containerWidth - designWidth * stageScale) / 2);
|
|
1408
|
+
this._app.stage.y = Math.round((containerHeight - designHeight * stageScale) / 2);
|
|
1409
|
+
}
|
|
1410
|
+
else if (scaleMode === ScaleMode.FILL) {
|
|
1411
|
+
this._app.stage.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
|
|
1412
|
+
this._app.stage.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
|
|
1413
|
+
}
|
|
1414
|
+
else {
|
|
1415
|
+
this._app.stage.x = 0;
|
|
1416
|
+
this._app.stage.y = 0;
|
|
1417
|
+
}
|
|
1418
|
+
this._currentWidth = gameWidth;
|
|
1419
|
+
this._currentHeight = gameHeight;
|
|
1420
|
+
this._currentScale = stageScale;
|
|
1421
|
+
// Check orientation
|
|
1422
|
+
const newOrientation = containerWidth >= containerHeight ? Orientation.LANDSCAPE : Orientation.PORTRAIT;
|
|
1423
|
+
if (newOrientation !== this._currentOrientation) {
|
|
1424
|
+
this._currentOrientation = newOrientation;
|
|
1425
|
+
this.emit('orientationChange', newOrientation);
|
|
1426
|
+
}
|
|
1427
|
+
this.emit('resize', {
|
|
1428
|
+
width: gameWidth,
|
|
1429
|
+
height: gameHeight,
|
|
1430
|
+
scale: stageScale,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Destroy the viewport manager.
|
|
1435
|
+
*/
|
|
1436
|
+
destroy() {
|
|
1437
|
+
this._destroyed = true;
|
|
1438
|
+
this._resizeObserver?.disconnect();
|
|
1439
|
+
this._resizeObserver = null;
|
|
1440
|
+
// Remove fallback window resize listener if it was used
|
|
1441
|
+
window.removeEventListener('resize', this.onWindowResize);
|
|
1442
|
+
if (this._resizeTimeout !== null) {
|
|
1443
|
+
clearTimeout(this._resizeTimeout);
|
|
1444
|
+
}
|
|
1445
|
+
this.removeAllListeners();
|
|
1446
|
+
}
|
|
1447
|
+
// ─── Private ───────────────────────────────────────────
|
|
1448
|
+
setupObserver() {
|
|
1449
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1450
|
+
this._resizeObserver = new ResizeObserver(() => {
|
|
1451
|
+
this.debouncedRefresh();
|
|
1452
|
+
});
|
|
1453
|
+
this._resizeObserver.observe(this._container);
|
|
1454
|
+
}
|
|
1455
|
+
else {
|
|
1456
|
+
// Fallback for older browsers
|
|
1457
|
+
window.addEventListener('resize', this.onWindowResize);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
onWindowResize = () => {
|
|
1461
|
+
this.debouncedRefresh();
|
|
1462
|
+
};
|
|
1463
|
+
debouncedRefresh() {
|
|
1464
|
+
if (this._resizeTimeout !== null) {
|
|
1465
|
+
clearTimeout(this._resizeTimeout);
|
|
1466
|
+
}
|
|
1467
|
+
this._resizeTimeout = window.setTimeout(() => {
|
|
1468
|
+
this.refresh();
|
|
1469
|
+
this._resizeTimeout = null;
|
|
1470
|
+
}, 16); // ~1 frame
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Base class for all scenes.
|
|
1476
|
+
* Provides a root PixiJS Container and lifecycle hooks.
|
|
1477
|
+
*
|
|
1478
|
+
* @example
|
|
1479
|
+
* ```ts
|
|
1480
|
+
* class MenuScene extends Scene {
|
|
1481
|
+
* async onEnter() {
|
|
1482
|
+
* const bg = Sprite.from('menu-bg');
|
|
1483
|
+
* this.container.addChild(bg);
|
|
1484
|
+
* }
|
|
1485
|
+
*
|
|
1486
|
+
* onUpdate(dt: number) {
|
|
1487
|
+
* // per-frame logic
|
|
1488
|
+
* }
|
|
1489
|
+
*
|
|
1490
|
+
* onResize(width: number, height: number) {
|
|
1491
|
+
* // reposition UI
|
|
1492
|
+
* }
|
|
1493
|
+
* }
|
|
1494
|
+
* ```
|
|
1495
|
+
*/
|
|
1496
|
+
class Scene {
|
|
1497
|
+
container;
|
|
1498
|
+
constructor() {
|
|
1499
|
+
this.container = new pixi_js.Container();
|
|
1500
|
+
this.container.label = this.constructor.name;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Build the loading scene variant of the logo SVG.
|
|
1506
|
+
* Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
|
|
1507
|
+
*/
|
|
1508
|
+
function buildLoadingLogoSVG() {
|
|
1509
|
+
return loading.buildLogoSVG({
|
|
1510
|
+
idPrefix: 'ls',
|
|
1511
|
+
svgStyle: 'width:100%;height:auto;',
|
|
1512
|
+
clipRectId: 'ge-loader-rect',
|
|
1513
|
+
textId: 'ge-loader-pct',
|
|
1514
|
+
textContent: '0%',
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Built-in loading screen using the Energy8 SVG logo with animated loader bar.
|
|
1519
|
+
*
|
|
1520
|
+
* Renders as an HTML overlay on top of the canvas for crisp SVG quality.
|
|
1521
|
+
* The loader bar fill width is driven by asset loading progress.
|
|
1522
|
+
*/
|
|
1523
|
+
class LoadingScene extends Scene {
|
|
1524
|
+
_engine;
|
|
1525
|
+
_targetScene;
|
|
1526
|
+
_targetData;
|
|
1527
|
+
_config;
|
|
1528
|
+
// HTML overlay
|
|
1529
|
+
_overlay = null;
|
|
1530
|
+
_loaderRect = null;
|
|
1531
|
+
_percentEl = null;
|
|
1532
|
+
_tapToStartEl = null;
|
|
1533
|
+
// State
|
|
1534
|
+
_displayedProgress = 0;
|
|
1535
|
+
_targetProgress = 0;
|
|
1536
|
+
_loadingComplete = false;
|
|
1537
|
+
_startTime = 0;
|
|
1538
|
+
async onEnter(data) {
|
|
1539
|
+
const { engine, targetScene, targetData } = data;
|
|
1540
|
+
this._engine = engine;
|
|
1541
|
+
this._targetScene = targetScene;
|
|
1542
|
+
this._targetData = targetData;
|
|
1543
|
+
this._config = engine.config.loading ?? {};
|
|
1544
|
+
this._startTime = Date.now();
|
|
1545
|
+
// Create the HTML overlay with the SVG logo
|
|
1546
|
+
this.createOverlay();
|
|
1547
|
+
// Initialize asset manager
|
|
1548
|
+
await this._engine.assets.init();
|
|
1549
|
+
// Initialize audio manager
|
|
1550
|
+
await this._engine.audio.init();
|
|
1551
|
+
// Phase 1: Load preload bundle
|
|
1552
|
+
const bundles = this._engine.assets.getBundleNames();
|
|
1553
|
+
const hasPreload = bundles.includes('preload');
|
|
1554
|
+
if (hasPreload) {
|
|
1555
|
+
const preloadAssets = this._engine.config.manifest?.bundles?.find((b) => b.name === 'preload')?.assets;
|
|
1556
|
+
if (preloadAssets && preloadAssets.length > 0) {
|
|
1557
|
+
await this._engine.assets.loadBundle('preload', (p) => {
|
|
1558
|
+
this._targetProgress = p * 0.15;
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
else {
|
|
1562
|
+
this._targetProgress = 0.15;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
// Phase 2: Load remaining bundles
|
|
1566
|
+
const remainingBundles = bundles.filter((b) => b !== 'preload' && !this._engine.assets.isBundleLoaded(b));
|
|
1567
|
+
if (remainingBundles.length > 0) {
|
|
1568
|
+
const hasAssets = remainingBundles.some((name) => {
|
|
1569
|
+
const bundle = this._engine.config.manifest?.bundles?.find((b) => b.name === name);
|
|
1570
|
+
return bundle?.assets && bundle.assets.length > 0;
|
|
1571
|
+
});
|
|
1572
|
+
if (hasAssets) {
|
|
1573
|
+
await this._engine.assets.loadBundles(remainingBundles, (p) => {
|
|
1574
|
+
this._targetProgress = 0.15 + p * 0.85;
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
this._targetProgress = 1;
|
|
1579
|
+
this._loadingComplete = true;
|
|
1580
|
+
// Enforce minimum display time: spread the remaining progress fill
|
|
1581
|
+
// over the remaining time so the bar fills smoothly, not abruptly
|
|
1582
|
+
const minTime = this._config.minDisplayTime ?? 1500;
|
|
1583
|
+
const elapsed = Date.now() - this._startTime;
|
|
1584
|
+
const remaining = Math.max(0, minTime - elapsed);
|
|
1585
|
+
if (remaining > 0) {
|
|
1586
|
+
// Distribute fill animation over the remaining time
|
|
1587
|
+
await this.animateProgressTo(1, remaining);
|
|
1588
|
+
}
|
|
1589
|
+
// Final snap to 100%
|
|
1590
|
+
this._displayedProgress = 1;
|
|
1591
|
+
this.updateLoaderBar(1);
|
|
1592
|
+
// Show "Tap to Start" or transition directly
|
|
1593
|
+
if (this._config.tapToStart !== false) {
|
|
1594
|
+
await this.showTapToStart();
|
|
1595
|
+
}
|
|
1596
|
+
else {
|
|
1597
|
+
await this.transitionToGame();
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
onUpdate(dt) {
|
|
1601
|
+
// Smooth progress bar fill via HTML (during active loading)
|
|
1602
|
+
if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
|
|
1603
|
+
this._displayedProgress = Math.min(this._displayedProgress + dt * 1.5, this._targetProgress);
|
|
1604
|
+
this.updateLoaderBar(this._displayedProgress);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
onResize(_width, _height) {
|
|
1608
|
+
// Overlay is CSS-based, auto-resizes
|
|
1609
|
+
}
|
|
1610
|
+
onDestroy() {
|
|
1611
|
+
this.removeOverlay();
|
|
1612
|
+
}
|
|
1613
|
+
// ─── HTML Overlay ──────────────────────────────────────
|
|
1614
|
+
createOverlay() {
|
|
1615
|
+
const bgColor = typeof this._config.backgroundColor === 'string'
|
|
1616
|
+
? this._config.backgroundColor
|
|
1617
|
+
: typeof this._config.backgroundColor === 'number'
|
|
1618
|
+
? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
|
|
1619
|
+
: '#0a0a1a';
|
|
1620
|
+
const bgGradient = this._config.backgroundGradient ??
|
|
1621
|
+
`linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
|
|
1622
|
+
this._overlay = document.createElement('div');
|
|
1623
|
+
this._overlay.id = '__ge-loading-overlay__';
|
|
1624
|
+
this._overlay.innerHTML = `
|
|
1625
|
+
<div class="ge-loading-content">
|
|
1626
|
+
${buildLoadingLogoSVG()}
|
|
1627
|
+
</div>
|
|
1628
|
+
`;
|
|
1629
|
+
const style = document.createElement('style');
|
|
1630
|
+
style.id = '__ge-loading-style__';
|
|
1631
|
+
style.textContent = `
|
|
1632
|
+
#__ge-loading-overlay__ {
|
|
1633
|
+
position: absolute;
|
|
1634
|
+
top: 0; left: 0;
|
|
1635
|
+
width: 100%; height: 100%;
|
|
1636
|
+
background: ${bgGradient};
|
|
1637
|
+
display: flex;
|
|
1638
|
+
align-items: center;
|
|
1639
|
+
justify-content: center;
|
|
1640
|
+
z-index: 9999;
|
|
1641
|
+
transition: opacity 0.5s ease-out;
|
|
1642
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1643
|
+
}
|
|
1644
|
+
#__ge-loading-overlay__.ge-fade-out {
|
|
1645
|
+
opacity: 0;
|
|
1646
|
+
pointer-events: none;
|
|
1647
|
+
}
|
|
1648
|
+
.ge-loading-content {
|
|
1649
|
+
display: flex;
|
|
1650
|
+
flex-direction: column;
|
|
1651
|
+
align-items: center;
|
|
1652
|
+
width: 75%;
|
|
1653
|
+
max-width: 650px;
|
|
1654
|
+
}
|
|
1655
|
+
.ge-loading-content svg {
|
|
1656
|
+
filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
|
|
1657
|
+
cursor: default;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
.ge-svg-pulse {
|
|
1661
|
+
animation: ge-tap-pulse 1.2s ease-in-out infinite;
|
|
1662
|
+
}
|
|
1663
|
+
@keyframes ge-tap-pulse {
|
|
1664
|
+
0%, 100% { opacity: 0.5; }
|
|
1665
|
+
50% { opacity: 1; }
|
|
1666
|
+
}
|
|
1667
|
+
`;
|
|
1668
|
+
// Get the container that holds the canvas
|
|
1669
|
+
const container = this._engine.app?.canvas?.parentElement;
|
|
1670
|
+
if (container) {
|
|
1671
|
+
container.style.position = container.style.position || 'relative';
|
|
1672
|
+
container.appendChild(style);
|
|
1673
|
+
container.appendChild(this._overlay);
|
|
1674
|
+
}
|
|
1675
|
+
// Cache the SVG loader rect for progress updates
|
|
1676
|
+
this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
|
|
1677
|
+
this._percentEl = this._overlay.querySelector('#ge-loader-pct');
|
|
1678
|
+
}
|
|
1679
|
+
removeOverlay() {
|
|
1680
|
+
this._overlay?.remove();
|
|
1681
|
+
document.getElementById('__ge-loading-style__')?.remove();
|
|
1682
|
+
this._overlay = null;
|
|
1683
|
+
this._loaderRect = null;
|
|
1684
|
+
this._percentEl = null;
|
|
1685
|
+
this._tapToStartEl = null;
|
|
1686
|
+
}
|
|
1687
|
+
// ─── Progress ──────────────────────────────────────────
|
|
1688
|
+
updateLoaderBar(progress) {
|
|
1689
|
+
if (this._loaderRect) {
|
|
1690
|
+
this._loaderRect.setAttribute('width', String(loading.LOADER_BAR_MAX_WIDTH * progress));
|
|
1691
|
+
}
|
|
1692
|
+
if (this._percentEl) {
|
|
1693
|
+
const pct = Math.round(progress * 100);
|
|
1694
|
+
this._percentEl.textContent = `${pct}%`;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Smoothly animate the displayed progress from its current value to `target`
|
|
1699
|
+
* over `durationMs` using an easeOutCubic curve.
|
|
1700
|
+
*/
|
|
1701
|
+
async animateProgressTo(target, durationMs) {
|
|
1702
|
+
const startVal = this._displayedProgress;
|
|
1703
|
+
const delta = target - startVal;
|
|
1704
|
+
if (delta <= 0 || durationMs <= 0)
|
|
1705
|
+
return;
|
|
1706
|
+
const startTime = Date.now();
|
|
1707
|
+
return new Promise((resolve) => {
|
|
1708
|
+
const tick = () => {
|
|
1709
|
+
const elapsed = Date.now() - startTime;
|
|
1710
|
+
const t = Math.min(elapsed / durationMs, 1);
|
|
1711
|
+
// easeOutCubic for a natural deceleration feel
|
|
1712
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
1713
|
+
this._displayedProgress = startVal + delta * eased;
|
|
1714
|
+
this.updateLoaderBar(this._displayedProgress);
|
|
1715
|
+
if (t < 1) {
|
|
1716
|
+
requestAnimationFrame(tick);
|
|
1717
|
+
}
|
|
1718
|
+
else {
|
|
1719
|
+
resolve();
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
requestAnimationFrame(tick);
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
// ─── Tap to Start ─────────────────────────────────────
|
|
1726
|
+
async showTapToStart() {
|
|
1727
|
+
const tapText = this._config.tapToStartText ?? 'TAP TO START';
|
|
1728
|
+
// Reuse the same SVG text element — replace percentage with tap text
|
|
1729
|
+
if (this._percentEl) {
|
|
1730
|
+
const el = this._percentEl;
|
|
1731
|
+
el.textContent = tapText;
|
|
1732
|
+
el.setAttribute('fill', '#ffffff');
|
|
1733
|
+
el.classList.add('ge-svg-pulse');
|
|
1734
|
+
this._tapToStartEl = el;
|
|
1735
|
+
}
|
|
1736
|
+
// Make overlay clickable
|
|
1737
|
+
if (this._overlay) {
|
|
1738
|
+
this._overlay.style.cursor = 'pointer';
|
|
1739
|
+
}
|
|
1740
|
+
// Wait for tap
|
|
1741
|
+
return new Promise((resolve) => {
|
|
1742
|
+
const handler = async () => {
|
|
1743
|
+
this._overlay?.removeEventListener('click', handler);
|
|
1744
|
+
await this.transitionToGame();
|
|
1745
|
+
resolve();
|
|
1746
|
+
};
|
|
1747
|
+
// Listen on the full overlay for easier mobile tap
|
|
1748
|
+
this._overlay?.addEventListener('click', handler);
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
// ─── Transition ────────────────────────────────────────
|
|
1752
|
+
async transitionToGame() {
|
|
1753
|
+
// Fade out the HTML overlay
|
|
1754
|
+
if (this._overlay) {
|
|
1755
|
+
this._overlay.classList.add('ge-fade-out');
|
|
1756
|
+
await new Promise((resolve) => {
|
|
1757
|
+
this._overlay.addEventListener('transitionend', () => resolve(), { once: true });
|
|
1758
|
+
// Safety timeout
|
|
1759
|
+
setTimeout(resolve, 600);
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
// Remove overlay
|
|
1763
|
+
this.removeOverlay();
|
|
1764
|
+
// Navigate to the target scene, always passing the engine reference
|
|
1765
|
+
await this._engine.scenes.goto(this._targetScene, {
|
|
1766
|
+
engine: this._engine,
|
|
1767
|
+
...(this._targetData && typeof this._targetData === 'object' ? this._targetData : { data: this._targetData }),
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/**
|
|
1773
|
+
* FPS overlay for debugging performance.
|
|
1774
|
+
*
|
|
1775
|
+
* Shows FPS, frame time, and draw call count in the corner of the screen.
|
|
1776
|
+
*
|
|
1777
|
+
* @example
|
|
1778
|
+
* ```ts
|
|
1779
|
+
* const fps = new FPSOverlay(app);
|
|
1780
|
+
* fps.show();
|
|
1781
|
+
* ```
|
|
1782
|
+
*/
|
|
1783
|
+
class FPSOverlay {
|
|
1784
|
+
_app;
|
|
1785
|
+
_container;
|
|
1786
|
+
_fpsText;
|
|
1787
|
+
_visible = false;
|
|
1788
|
+
_samples = [];
|
|
1789
|
+
_maxSamples = 60;
|
|
1790
|
+
_lastUpdate = 0;
|
|
1791
|
+
_tickFn = null;
|
|
1792
|
+
constructor(app) {
|
|
1793
|
+
this._app = app;
|
|
1794
|
+
this._container = new pixi_js.Container();
|
|
1795
|
+
this._container.label = 'FPSOverlay';
|
|
1796
|
+
this._container.zIndex = 99999;
|
|
1797
|
+
this._fpsText = new pixi_js.Text({
|
|
1798
|
+
text: 'FPS: --',
|
|
1799
|
+
style: {
|
|
1800
|
+
fontFamily: 'monospace',
|
|
1801
|
+
fontSize: 14,
|
|
1802
|
+
fill: 0x00ff00,
|
|
1803
|
+
stroke: { color: 0x000000, width: 2 },
|
|
1804
|
+
},
|
|
1805
|
+
});
|
|
1806
|
+
this._fpsText.x = 8;
|
|
1807
|
+
this._fpsText.y = 8;
|
|
1808
|
+
this._container.addChild(this._fpsText);
|
|
1809
|
+
}
|
|
1810
|
+
/** Show the FPS overlay */
|
|
1811
|
+
show() {
|
|
1812
|
+
if (this._visible)
|
|
1813
|
+
return;
|
|
1814
|
+
this._visible = true;
|
|
1815
|
+
this._app.stage.addChild(this._container);
|
|
1816
|
+
this._tickFn = (ticker) => {
|
|
1817
|
+
this._samples.push(ticker.FPS);
|
|
1818
|
+
if (this._samples.length > this._maxSamples) {
|
|
1819
|
+
this._samples.shift();
|
|
1820
|
+
}
|
|
1821
|
+
// Update display every ~500ms
|
|
1822
|
+
const now = Date.now();
|
|
1823
|
+
if (now - this._lastUpdate > 500) {
|
|
1824
|
+
const avg = this._samples.reduce((a, b) => a + b, 0) / this._samples.length;
|
|
1825
|
+
const min = Math.min(...this._samples);
|
|
1826
|
+
this._fpsText.text = [
|
|
1827
|
+
`FPS: ${Math.round(avg)} (min: ${Math.round(min)})`,
|
|
1828
|
+
`Frame: ${ticker.deltaMS.toFixed(1)}ms`,
|
|
1829
|
+
].join('\n');
|
|
1830
|
+
this._lastUpdate = now;
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
this._app.ticker.add(this._tickFn);
|
|
1834
|
+
}
|
|
1835
|
+
/** Hide the FPS overlay */
|
|
1836
|
+
hide() {
|
|
1837
|
+
if (!this._visible)
|
|
1838
|
+
return;
|
|
1839
|
+
this._visible = false;
|
|
1840
|
+
this._container.removeFromParent();
|
|
1841
|
+
if (this._tickFn) {
|
|
1842
|
+
this._app.ticker.remove(this._tickFn);
|
|
1843
|
+
this._tickFn = null;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
/** Toggle visibility */
|
|
1847
|
+
toggle() {
|
|
1848
|
+
if (this._visible) {
|
|
1849
|
+
this.hide();
|
|
1850
|
+
}
|
|
1851
|
+
else {
|
|
1852
|
+
this.show();
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
/** Destroy the overlay */
|
|
1856
|
+
destroy() {
|
|
1857
|
+
this.hide();
|
|
1858
|
+
this._container.destroy({ children: true });
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* The main entry point for a game built on @energy8platform/game-engine.
|
|
1864
|
+
*
|
|
1865
|
+
* Orchestrates the full lifecycle:
|
|
1866
|
+
* 1. Create PixiJS Application
|
|
1867
|
+
* 2. Initialize SDK (or run offline)
|
|
1868
|
+
* 3. Show CSS preloader → Canvas loading screen with progress bar
|
|
1869
|
+
* 4. Load asset manifest
|
|
1870
|
+
* 5. Transition to the first game scene
|
|
1871
|
+
*
|
|
1872
|
+
* @example
|
|
1873
|
+
* ```ts
|
|
1874
|
+
* import { GameApplication, ScaleMode } from '@energy8platform/game-engine';
|
|
1875
|
+
* import { GameScene } from './scenes/GameScene';
|
|
1876
|
+
*
|
|
1877
|
+
* const game = new GameApplication({
|
|
1878
|
+
* container: '#game',
|
|
1879
|
+
* designWidth: 1920,
|
|
1880
|
+
* designHeight: 1080,
|
|
1881
|
+
* scaleMode: ScaleMode.FIT,
|
|
1882
|
+
* manifest: { bundles: [
|
|
1883
|
+
* { name: 'preload', assets: [{ alias: 'logo', src: 'logo.png' }] },
|
|
1884
|
+
* { name: 'game', assets: [{ alias: 'bg', src: 'background.png' }] },
|
|
1885
|
+
* ]},
|
|
1886
|
+
* loading: { tapToStart: true },
|
|
1887
|
+
* });
|
|
1888
|
+
*
|
|
1889
|
+
* game.scenes.register('game', GameScene);
|
|
1890
|
+
* await game.start('game');
|
|
1891
|
+
* ```
|
|
1892
|
+
*/
|
|
1893
|
+
class GameApplication extends EventEmitter {
|
|
1894
|
+
// ─── Public references ──────────────────────────────────
|
|
1895
|
+
/** PixiJS Application instance */
|
|
1896
|
+
app;
|
|
1897
|
+
/** Scene manager */
|
|
1898
|
+
scenes;
|
|
1899
|
+
/** Asset manager */
|
|
1900
|
+
assets;
|
|
1901
|
+
/** Audio manager */
|
|
1902
|
+
audio;
|
|
1903
|
+
/** Input manager */
|
|
1904
|
+
input;
|
|
1905
|
+
/** Viewport manager */
|
|
1906
|
+
viewport;
|
|
1907
|
+
/** SDK instance (null in offline mode) */
|
|
1908
|
+
sdk = null;
|
|
1909
|
+
/** FPS overlay instance (only when debug: true) */
|
|
1910
|
+
fpsOverlay = null;
|
|
1911
|
+
/** Data received from SDK initialization */
|
|
1912
|
+
initData = null;
|
|
1913
|
+
/** Platform session (SDK + optional DevBridge). null until start() runs. */
|
|
1914
|
+
platformSession = null;
|
|
1915
|
+
/** Branded game shell (only when config.shell is set). */
|
|
1916
|
+
shell;
|
|
1917
|
+
/** Configuration */
|
|
1918
|
+
config;
|
|
1919
|
+
// ─── Private state ──────────────────────────────────────
|
|
1920
|
+
_running = false;
|
|
1921
|
+
_destroyed = false;
|
|
1922
|
+
_container = null;
|
|
1923
|
+
constructor(config = {}) {
|
|
1924
|
+
super();
|
|
1925
|
+
this.config = {
|
|
1926
|
+
designWidth: 1920,
|
|
1927
|
+
designHeight: 1080,
|
|
1928
|
+
scaleMode: ScaleMode.FIT,
|
|
1929
|
+
orientation: Orientation.ANY,
|
|
1930
|
+
debug: false,
|
|
1931
|
+
...config,
|
|
1932
|
+
};
|
|
1933
|
+
// Create SceneManager early so scenes can be registered before start()
|
|
1934
|
+
this.scenes = new SceneManager();
|
|
1935
|
+
}
|
|
1936
|
+
// ─── Public getters ─────────────────────────────────────
|
|
1937
|
+
/** Current game config from SDK (or null in offline mode) */
|
|
1938
|
+
get gameConfig() {
|
|
1939
|
+
return this.initData?.config ?? null;
|
|
1940
|
+
}
|
|
1941
|
+
/** Current session data */
|
|
1942
|
+
get session() {
|
|
1943
|
+
return this.initData?.session ?? null;
|
|
1944
|
+
}
|
|
1945
|
+
/** Current balance */
|
|
1946
|
+
get balance() {
|
|
1947
|
+
return this.sdk?.balance ?? 0;
|
|
1948
|
+
}
|
|
1949
|
+
/** Current currency */
|
|
1950
|
+
get currency() {
|
|
1951
|
+
return this.sdk?.currency ?? 'USD';
|
|
1952
|
+
}
|
|
1953
|
+
/** Whether the engine is running */
|
|
1954
|
+
get isRunning() {
|
|
1955
|
+
return this._running;
|
|
1956
|
+
}
|
|
1957
|
+
// ─── Lifecycle ──────────────────────────────────────────
|
|
1958
|
+
/**
|
|
1959
|
+
* Start the game engine. This is the main entry point.
|
|
1960
|
+
*
|
|
1961
|
+
* @param firstScene - Key of the first scene to show after loading (must be registered)
|
|
1962
|
+
* @param sceneData - Optional data to pass to the first scene's onEnter
|
|
1963
|
+
*/
|
|
1964
|
+
async start(firstScene, sceneData) {
|
|
1965
|
+
if (this._running) {
|
|
1966
|
+
console.warn('[GameEngine] Already running');
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
try {
|
|
1970
|
+
// 1. Resolve container element
|
|
1971
|
+
this._container = this.resolveContainer();
|
|
1972
|
+
// 2. Show CSS preloader immediately (before PixiJS)
|
|
1973
|
+
loading.createCSSPreloader(this._container, this.config.loading);
|
|
1974
|
+
// 3. Initialize PixiJS
|
|
1975
|
+
await this.initPixi();
|
|
1976
|
+
// 4. Initialize SDK (if enabled)
|
|
1977
|
+
await this.initSDK();
|
|
1978
|
+
// 4b. Mount the branded game shell after the SDK handshake (optional)
|
|
1979
|
+
if (this.config.shell) {
|
|
1980
|
+
const { createGameShell } = await import('@energy8platform/platform-core/shell');
|
|
1981
|
+
this.shell = createGameShell(this.config.shell);
|
|
1982
|
+
}
|
|
1983
|
+
// 5. Merge design dimensions from SDK config
|
|
1984
|
+
this.applySDKConfig();
|
|
1985
|
+
// 6. Initialize sub-systems
|
|
1986
|
+
this.initSubSystems();
|
|
1987
|
+
this.emit('initialized');
|
|
1988
|
+
// 7. Remove CSS preloader, show Canvas loading screen
|
|
1989
|
+
loading.removeCSSPreloader(this._container);
|
|
1990
|
+
// 8. Load assets with loading screen
|
|
1991
|
+
await this.loadAssets(firstScene, sceneData);
|
|
1992
|
+
this.emit('loaded');
|
|
1993
|
+
// 9. Start the game loop
|
|
1994
|
+
this._running = true;
|
|
1995
|
+
this.emit('started');
|
|
1996
|
+
}
|
|
1997
|
+
catch (err) {
|
|
1998
|
+
console.error('[GameEngine] Failed to start:', err);
|
|
1999
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
2000
|
+
throw err;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Destroy the engine and free all resources.
|
|
2005
|
+
*/
|
|
2006
|
+
async destroy() {
|
|
2007
|
+
if (this._destroyed)
|
|
2008
|
+
return;
|
|
2009
|
+
this._destroyed = true;
|
|
2010
|
+
this._running = false;
|
|
2011
|
+
if (this.shell) {
|
|
2012
|
+
const { removeGameShell } = await import('@energy8platform/platform-core/shell');
|
|
2013
|
+
await removeGameShell();
|
|
2014
|
+
this.shell = undefined;
|
|
2015
|
+
}
|
|
2016
|
+
this.scenes?.destroy();
|
|
2017
|
+
this.input?.destroy();
|
|
2018
|
+
this.audio?.destroy();
|
|
2019
|
+
this.viewport?.destroy();
|
|
2020
|
+
this.platformSession?.destroy();
|
|
2021
|
+
this.app?.destroy(true, { children: true, texture: true });
|
|
2022
|
+
this.emit('destroyed');
|
|
2023
|
+
this.removeAllListeners();
|
|
2024
|
+
}
|
|
2025
|
+
// ─── Private initialization steps ──────────────────────
|
|
2026
|
+
resolveContainer() {
|
|
2027
|
+
if (typeof this.config.container === 'string') {
|
|
2028
|
+
const el = document.querySelector(this.config.container);
|
|
2029
|
+
if (!el)
|
|
2030
|
+
throw new Error(`[GameEngine] Container "${this.config.container}" not found`);
|
|
2031
|
+
return el;
|
|
2032
|
+
}
|
|
2033
|
+
return this.config.container ?? document.body;
|
|
2034
|
+
}
|
|
2035
|
+
async initPixi() {
|
|
2036
|
+
this.app = new pixi_js.Application();
|
|
2037
|
+
const pixiOpts = {
|
|
2038
|
+
preference: 'webgl',
|
|
2039
|
+
background: typeof this.config.loading?.backgroundColor === 'number'
|
|
2040
|
+
? this.config.loading.backgroundColor
|
|
2041
|
+
: 0x000000,
|
|
2042
|
+
antialias: true,
|
|
2043
|
+
resolution: Math.min(window.devicePixelRatio, 2),
|
|
2044
|
+
autoDensity: true,
|
|
2045
|
+
...this.config.pixi,
|
|
2046
|
+
};
|
|
2047
|
+
await this.app.init(pixiOpts);
|
|
2048
|
+
// Append canvas to container
|
|
2049
|
+
this._container.appendChild(this.app.canvas);
|
|
2050
|
+
// Set canvas style
|
|
2051
|
+
this.app.canvas.style.display = 'block';
|
|
2052
|
+
this.app.canvas.style.width = '100%';
|
|
2053
|
+
this.app.canvas.style.height = '100%';
|
|
2054
|
+
}
|
|
2055
|
+
async initSDK() {
|
|
2056
|
+
// Delegate the SDK handshake (and any optional in-process DevBridge
|
|
2057
|
+
// wiring) to platform-core. The session forwards SDK events upward.
|
|
2058
|
+
this.platformSession = await platformCore.createPlatformSession({ sdk: this.config.sdk });
|
|
2059
|
+
this.sdk = this.platformSession.sdk;
|
|
2060
|
+
this.initData = this.platformSession.initData;
|
|
2061
|
+
this.platformSession.on('error', (err) => {
|
|
2062
|
+
this.emit('error', err);
|
|
2063
|
+
});
|
|
2064
|
+
this.platformSession.on('balanceUpdate', (data) => {
|
|
2065
|
+
this.emit('balanceUpdate', data);
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
applySDKConfig() {
|
|
2069
|
+
// If SDK provides viewport dimensions, use them as design reference
|
|
2070
|
+
if (this.initData?.config?.viewport) {
|
|
2071
|
+
const vp = this.initData.config.viewport;
|
|
2072
|
+
if (!this.config.designWidth)
|
|
2073
|
+
this.config.designWidth = vp.width;
|
|
2074
|
+
if (!this.config.designHeight)
|
|
2075
|
+
this.config.designHeight = vp.height;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
initSubSystems() {
|
|
2079
|
+
// Asset Manager
|
|
2080
|
+
const basePath = this.initData?.assetsUrl ?? '';
|
|
2081
|
+
this.assets = new AssetManager(basePath, this.config.manifest);
|
|
2082
|
+
// Audio Manager
|
|
2083
|
+
this.audio = new AudioManager(this.config.audio);
|
|
2084
|
+
// Input Manager
|
|
2085
|
+
this.input = new InputManager(this.app.canvas);
|
|
2086
|
+
// Viewport Manager
|
|
2087
|
+
this.viewport = new ViewportManager(this.app, this._container, {
|
|
2088
|
+
designWidth: this.config.designWidth,
|
|
2089
|
+
designHeight: this.config.designHeight,
|
|
2090
|
+
scaleMode: this.config.scaleMode,
|
|
2091
|
+
orientation: this.config.orientation,
|
|
2092
|
+
});
|
|
2093
|
+
// Wire SceneManager to the PixiJS stage
|
|
2094
|
+
this.scenes.setRoot(this.app.stage);
|
|
2095
|
+
this.scenes.setApp(this);
|
|
2096
|
+
// Wire viewport resize → scene manager + input manager
|
|
2097
|
+
this.viewport.on('resize', ({ width, height, scale }) => {
|
|
2098
|
+
this.scenes.resize(width, height);
|
|
2099
|
+
this.input.setViewportTransform(scale, this.app.stage.x, this.app.stage.y);
|
|
2100
|
+
this.emit('resize', { width, height });
|
|
2101
|
+
});
|
|
2102
|
+
this.viewport.on('orientationChange', (orientation) => {
|
|
2103
|
+
this.emit('orientationChange', orientation);
|
|
2104
|
+
});
|
|
2105
|
+
// Wire scene changes → engine event
|
|
2106
|
+
this.scenes.on('change', ({ from, to }) => {
|
|
2107
|
+
this.emit('sceneChange', { from, to });
|
|
2108
|
+
});
|
|
2109
|
+
// Connect ticker → scene updates
|
|
2110
|
+
this.app.ticker.add((ticker) => {
|
|
2111
|
+
// Always update scenes (loading screen needs onUpdate before _running=true)
|
|
2112
|
+
this.scenes.update(ticker.deltaTime / 60); // convert to seconds
|
|
2113
|
+
});
|
|
2114
|
+
// Trigger initial resize
|
|
2115
|
+
this.viewport.refresh();
|
|
2116
|
+
// Enable FPS overlay in debug mode
|
|
2117
|
+
if (this.config.debug) {
|
|
2118
|
+
this.fpsOverlay = new FPSOverlay(this.app);
|
|
2119
|
+
this.fpsOverlay.show();
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
async loadAssets(firstScene, sceneData) {
|
|
2123
|
+
// Register built-in loading scene
|
|
2124
|
+
this.scenes.register('__loading__', LoadingScene);
|
|
2125
|
+
// Enter loading scene
|
|
2126
|
+
await this.scenes.goto('__loading__', {
|
|
2127
|
+
engine: this,
|
|
2128
|
+
targetScene: firstScene,
|
|
2129
|
+
targetData: sceneData,
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// packages/game-engine/src/scenes/IntroScene.ts
|
|
2135
|
+
/**
|
|
2136
|
+
* Reusable splash scene: shows a title (or logo) + "tap to start", then advances.
|
|
2137
|
+
*
|
|
2138
|
+
* The host no longer special-cases the intro. Navigation works like every other
|
|
2139
|
+
* scene: the host injects `goto(key)` into this scene's start data. On tap this
|
|
2140
|
+
* scene calls `onStart` if the game supplied one, otherwise `goto(next ?? 'game')`.
|
|
2141
|
+
* (The built-in can't know the game's scene key generically, so it falls back to
|
|
2142
|
+
* the conventional 'game' key — override via `next`. Scaffold-generated intros
|
|
2143
|
+
* skip this primitive and call `goto('game')` directly.)
|
|
2144
|
+
*/
|
|
2145
|
+
class IntroScene extends Scene {
|
|
2146
|
+
layer;
|
|
2147
|
+
async onEnter(data) {
|
|
2148
|
+
const cfg = (data ?? {});
|
|
2149
|
+
const start = () => cfg.onStart ? cfg.onStart() : cfg.goto?.(cfg.next ?? 'game');
|
|
2150
|
+
const layer = new pixi_js.Container();
|
|
2151
|
+
this.layer = layer;
|
|
2152
|
+
this.container.addChild(layer);
|
|
2153
|
+
const title = new pixi_js.Text({
|
|
2154
|
+
text: cfg.title ?? 'PLAY',
|
|
2155
|
+
style: { fill: 0xffffff, fontSize: 96, fontFamily: 'Inter', align: 'center' },
|
|
2156
|
+
});
|
|
2157
|
+
title.anchor.set(0.5);
|
|
2158
|
+
title.position.set(960, 460);
|
|
2159
|
+
layer.addChild(title);
|
|
2160
|
+
if (cfg.tapToStart !== false) {
|
|
2161
|
+
const hint = new pixi_js.Text({
|
|
2162
|
+
text: 'Tap to start',
|
|
2163
|
+
style: { fill: 0xffd24a, fontSize: 36, fontFamily: 'Inter' },
|
|
2164
|
+
});
|
|
2165
|
+
hint.anchor.set(0.5);
|
|
2166
|
+
hint.position.set(960, 600);
|
|
2167
|
+
layer.addChild(hint);
|
|
2168
|
+
}
|
|
2169
|
+
// full-screen tap target
|
|
2170
|
+
const hit = new pixi_js.Graphics().rect(0, 0, 1920, 1080).fill({ color: 0x000000, alpha: 0.001 });
|
|
2171
|
+
hit.eventMode = 'static';
|
|
2172
|
+
hit.cursor = 'pointer';
|
|
2173
|
+
hit.once('pointerdown', () => start());
|
|
2174
|
+
layer.addChild(hit);
|
|
2175
|
+
}
|
|
2176
|
+
onExit() {
|
|
2177
|
+
this.layer?.destroy({ children: true });
|
|
2178
|
+
this.layer = undefined;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* Pure: map host options to a GameApplicationConfig with sane defaults.
|
|
2184
|
+
* `isStakeNow` is computed by the orchestrator (kept out of here so this
|
|
2185
|
+
* stays a pure, renderer-free function).
|
|
2186
|
+
*/
|
|
2187
|
+
function buildAppConfig(opts, isStakeNow) {
|
|
2188
|
+
return {
|
|
2189
|
+
container: opts.container ?? '#game',
|
|
2190
|
+
designWidth: opts.design?.width ?? 1920,
|
|
2191
|
+
designHeight: opts.design?.height ?? 1080,
|
|
2192
|
+
scaleMode: opts.scaleMode ?? ScaleMode.FILL,
|
|
2193
|
+
orientation: opts.orientation ?? Orientation.ANY,
|
|
2194
|
+
loading: opts.loading ?? { tapToStart: false, minDisplayTime: 600 },
|
|
2195
|
+
manifest: opts.manifest,
|
|
2196
|
+
audio: opts.audio,
|
|
2197
|
+
pixi: opts.pixi,
|
|
2198
|
+
sdk: { devMode: isStakeNow || (opts.dev ?? false) },
|
|
2199
|
+
debug: opts.dev ?? false,
|
|
2200
|
+
};
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/** Preload web fonts so Pixi text rasterizes with the right glyphs. Never throws. */
|
|
2204
|
+
async function loadFonts(specs) {
|
|
2205
|
+
if (!specs || specs.length === 0)
|
|
2206
|
+
return;
|
|
2207
|
+
try {
|
|
2208
|
+
await Promise.all(specs.map((s) => document.fonts.load(s)));
|
|
2209
|
+
await document.fonts.ready;
|
|
2210
|
+
}
|
|
2211
|
+
catch {
|
|
2212
|
+
/* font CDN unreachable → fall back to system fonts */
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
/** Smoother default downscaling for art-heavy slots. Pixel-art games omit this. */
|
|
2216
|
+
function applyTextureDefaults() {
|
|
2217
|
+
pixi_js.TextureSource.defaultOptions.autoGenerateMipmaps = true;
|
|
2218
|
+
}
|
|
2219
|
+
/** Idempotent double-boot guard. Returns true the first time, false thereafter. */
|
|
2220
|
+
function bootGuard(flag = '__e8SlotBooted__') {
|
|
2221
|
+
const w = window;
|
|
2222
|
+
if (w[flag])
|
|
2223
|
+
return false;
|
|
2224
|
+
w[flag] = true;
|
|
2225
|
+
return true;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// packages/game-engine/src/host/fatalError.ts
|
|
2229
|
+
/** Marker id so the modal is idempotent (first one wins; later calls replace its message). */
|
|
2230
|
+
const FATAL_ID = 'e8-fatal-error';
|
|
2231
|
+
/** Pure: extract a human-readable message from any thrown value / event reason. */
|
|
2232
|
+
function fatalMessage(input) {
|
|
2233
|
+
if (input == null)
|
|
2234
|
+
return 'Something went wrong.';
|
|
2235
|
+
if (typeof input === 'string')
|
|
2236
|
+
return input;
|
|
2237
|
+
if (input instanceof Error)
|
|
2238
|
+
return input.message || input.name || 'Something went wrong.';
|
|
2239
|
+
const anyIn = input;
|
|
2240
|
+
if (typeof anyIn.message === 'string' && anyIn.message)
|
|
2241
|
+
return anyIn.message;
|
|
2242
|
+
if (anyIn.reason != null)
|
|
2243
|
+
return fatalMessage(anyIn.reason);
|
|
2244
|
+
try {
|
|
2245
|
+
return String(input);
|
|
2246
|
+
}
|
|
2247
|
+
catch {
|
|
2248
|
+
return 'Something went wrong.';
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
/** Pure: build the modal overlay element (error text + a Reload button). */
|
|
2252
|
+
function buildFatalErrorModal(message, onReload) {
|
|
2253
|
+
const overlay = document.createElement('div');
|
|
2254
|
+
overlay.id = FATAL_ID;
|
|
2255
|
+
overlay.setAttribute('role', 'alertdialog');
|
|
2256
|
+
overlay.style.cssText =
|
|
2257
|
+
'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' +
|
|
2258
|
+
'background:rgba(10,5,4,0.92);z-index:99999;font-family:system-ui,sans-serif;padding:24px';
|
|
2259
|
+
const card = document.createElement('div');
|
|
2260
|
+
card.style.cssText =
|
|
2261
|
+
'max-width:420px;width:100%;background:#1a0f0a;border:1px solid #5a3a1e;border-radius:12px;' +
|
|
2262
|
+
'padding:28px 24px;text-align:center;box-shadow:0 12px 40px rgba(0,0,0,0.6)';
|
|
2263
|
+
const text = document.createElement('div');
|
|
2264
|
+
text.className = 'e8-fatal-message';
|
|
2265
|
+
text.style.cssText = 'color:#f0c98a;font:600 17px/1.4 system-ui,sans-serif;margin-bottom:22px';
|
|
2266
|
+
text.textContent = message;
|
|
2267
|
+
const button = document.createElement('button');
|
|
2268
|
+
button.type = 'button';
|
|
2269
|
+
button.className = 'e8-fatal-reload';
|
|
2270
|
+
button.textContent = 'Reload';
|
|
2271
|
+
button.style.cssText =
|
|
2272
|
+
'cursor:pointer;border:none;border-radius:8px;padding:12px 28px;font:600 15px system-ui,sans-serif;' +
|
|
2273
|
+
'color:#1a0f0a;background:#f0c98a';
|
|
2274
|
+
button.addEventListener('click', onReload);
|
|
2275
|
+
card.appendChild(text);
|
|
2276
|
+
card.appendChild(button);
|
|
2277
|
+
overlay.appendChild(card);
|
|
2278
|
+
return overlay;
|
|
2279
|
+
}
|
|
2280
|
+
/**
|
|
2281
|
+
* Render a blocking fatal-error modal with a Reload button. Idempotent: if a modal is already
|
|
2282
|
+
* shown, its message is replaced instead of stacking a second overlay.
|
|
2283
|
+
*/
|
|
2284
|
+
function showFatalError(container, message) {
|
|
2285
|
+
if (typeof document === 'undefined')
|
|
2286
|
+
return;
|
|
2287
|
+
const host = typeof container === 'string'
|
|
2288
|
+
? document.querySelector(container) ?? document.body
|
|
2289
|
+
: container;
|
|
2290
|
+
const existing = document.getElementById(FATAL_ID);
|
|
2291
|
+
if (existing) {
|
|
2292
|
+
const msg = existing.querySelector('.e8-fatal-message');
|
|
2293
|
+
if (msg)
|
|
2294
|
+
msg.textContent = message;
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
const overlay = buildFatalErrorModal(message, () => {
|
|
2298
|
+
try {
|
|
2299
|
+
location.reload();
|
|
2300
|
+
}
|
|
2301
|
+
catch {
|
|
2302
|
+
/* no-op in non-browser environments */
|
|
2303
|
+
}
|
|
2304
|
+
});
|
|
2305
|
+
host.appendChild(overlay);
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Install global handlers so ANY uncaught error or unhandled promise rejection surfaces the
|
|
2309
|
+
* fatal modal (game devs don't have to handle errors themselves). `fatal` defaults to the
|
|
2310
|
+
* built-in modal targeting `container`. Returns a disposer that removes the listeners.
|
|
2311
|
+
*/
|
|
2312
|
+
function installGlobalErrorHandlers(container, fatal = (m) => showFatalError(container, m)) {
|
|
2313
|
+
if (typeof window === 'undefined')
|
|
2314
|
+
return () => { };
|
|
2315
|
+
const onError = (e) => fatal(fatalMessage(e.error ?? e.message));
|
|
2316
|
+
const onRejection = (e) => fatal(fatalMessage(e.reason));
|
|
2317
|
+
window.addEventListener('error', onError);
|
|
2318
|
+
window.addEventListener('unhandledrejection', onRejection);
|
|
2319
|
+
return () => {
|
|
2320
|
+
window.removeEventListener('error', onError);
|
|
2321
|
+
window.removeEventListener('unhandledrejection', onRejection);
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// packages/game-engine/src/host/createSlotGame.ts
|
|
2326
|
+
/**
|
|
2327
|
+
* One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
|
|
2328
|
+
* → register scene → start. Collapses the per-game main.ts boilerplate.
|
|
2329
|
+
*
|
|
2330
|
+
* Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless
|
|
2331
|
+
* environments. The pure helpers it sequences are unit-tested individually.
|
|
2332
|
+
*/
|
|
2333
|
+
async function createSlotGame(opts) {
|
|
2334
|
+
if (!bootGuard())
|
|
2335
|
+
throw new Error('createSlotGame() called more than once');
|
|
2336
|
+
if (opts.textureDefaults)
|
|
2337
|
+
applyTextureDefaults();
|
|
2338
|
+
await loadFonts(opts.fonts);
|
|
2339
|
+
// Declared up front so `fatal` can route errors through the shell's own modal once it exists.
|
|
2340
|
+
let shell = null;
|
|
2341
|
+
const fatal = (message) => {
|
|
2342
|
+
if (opts.onFatalError)
|
|
2343
|
+
return opts.onFatalError(message);
|
|
2344
|
+
// Once the shell is up, use ITS branded modal (consistent chrome, social vocabulary, fit
|
|
2345
|
+
// scaling) rather than the bare DOM fallback. Errors thrown before the shell boots (asset
|
|
2346
|
+
// load, SDK handshake) still get the standalone overlay.
|
|
2347
|
+
if (shell) {
|
|
2348
|
+
shell.openModal({
|
|
2349
|
+
availableClose: false,
|
|
2350
|
+
title: shell.t('Something went wrong'),
|
|
2351
|
+
body: shell.t(message),
|
|
2352
|
+
actions: [{ title: shell.t('Reload'), on: () => { try {
|
|
2353
|
+
location.reload();
|
|
2354
|
+
}
|
|
2355
|
+
catch { /* non-browser */ } } }],
|
|
2356
|
+
});
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
showFatalError(opts.container ?? '#game', message);
|
|
2360
|
+
};
|
|
2361
|
+
// Global safety net: surface ANY uncaught error / unhandled rejection (e.g. an
|
|
2362
|
+
// `Uncaught (in promise) SDKError` on spin) through the same fatal modal so games
|
|
2363
|
+
// don't have to handle errors themselves. Honours the onFatalError override.
|
|
2364
|
+
installGlobalErrorHandlers(opts.container ?? '#game', fatal);
|
|
2365
|
+
let stakeBridge = null;
|
|
2366
|
+
let isStakeNow = false;
|
|
2367
|
+
if (opts.stake) {
|
|
2368
|
+
const { isStakeLaunch } = await import('@energy8platform/stake-bridge/detect');
|
|
2369
|
+
isStakeNow = isStakeLaunch(location.href);
|
|
2370
|
+
if (isStakeNow) {
|
|
2371
|
+
try {
|
|
2372
|
+
const { StakeBridge } = await import('@energy8platform/stake-bridge');
|
|
2373
|
+
stakeBridge = new StakeBridge({
|
|
2374
|
+
devMode: true,
|
|
2375
|
+
// In the dev harness the iframe is served over http and the dev-RGS
|
|
2376
|
+
// lives at the same (http) origin; force the matching scheme so
|
|
2377
|
+
// RGSClient can reach it. Prod (https) is unaffected.
|
|
2378
|
+
protocol: location.protocol === 'http:' ? 'http' : 'https',
|
|
2379
|
+
adapter: opts.stake.adapter,
|
|
2380
|
+
modeMap: opts.model.modeMap,
|
|
2381
|
+
gameId: opts.model.spec.id,
|
|
2382
|
+
url: location.href,
|
|
2383
|
+
});
|
|
2384
|
+
await stakeBridge.ready();
|
|
2385
|
+
}
|
|
2386
|
+
catch (err) {
|
|
2387
|
+
fatal('Could not connect to the game server. Please reload.');
|
|
2388
|
+
throw err;
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
const game = new GameApplication(buildAppConfig(opts, isStakeNow));
|
|
2393
|
+
// Register EVERY scene up front so any of them can navigate to any other.
|
|
2394
|
+
for (const { key, scene } of opts.scenes)
|
|
2395
|
+
game.scenes.register(key, scene);
|
|
2396
|
+
// Navigation injected into the start data of every scene: a scene reads `goto`
|
|
2397
|
+
// from its `onEnter(data)` and calls it to switch scenes (intro → game, etc.).
|
|
2398
|
+
const goto = (key, data) => {
|
|
2399
|
+
void game.scenes.goto(key, { ...data, goto });
|
|
2400
|
+
};
|
|
2401
|
+
// Pick the start scene from the ordered list + launch mode: a replay launch skips any leading
|
|
2402
|
+
// `skipOnReplay` scene (the intro) and starts directly on the game scene.
|
|
2403
|
+
const { resolveStartScene } = await Promise.resolve().then(function () { return sceneStart; });
|
|
2404
|
+
const startScene = resolveStartScene(opts.scenes, !!stakeBridge?.isReplay, opts.startScene);
|
|
2405
|
+
try {
|
|
2406
|
+
await game.start(startScene, { ...opts.startData, goto });
|
|
2407
|
+
}
|
|
2408
|
+
catch (err) {
|
|
2409
|
+
fatal('Could not start the game.');
|
|
2410
|
+
throw err;
|
|
2411
|
+
}
|
|
2412
|
+
let currentBet = opts.model.spec.defaultBet ?? opts.model.spec.betLevels[0];
|
|
2413
|
+
// Build slotPlay FIRST — bindGameScene() needs it to be in scope.
|
|
2414
|
+
const { createSlotPlay, enrichRoundMeta } = await Promise.resolve().then(function () { return slotPlay; });
|
|
2415
|
+
/** The current scene IFF it implements the SlotSceneController contract (duck-typed on
|
|
2416
|
+
* `present`). The host drives the play loop against whichever scene is current. */
|
|
2417
|
+
const gameScene = () => {
|
|
2418
|
+
const s = game.scenes.current?.scene;
|
|
2419
|
+
return typeof s?.present === 'function'
|
|
2420
|
+
? s
|
|
2421
|
+
: undefined;
|
|
2422
|
+
};
|
|
2423
|
+
const { runRound } = await Promise.resolve().then(function () { return runRound$1; });
|
|
2424
|
+
const { createBalanceGate } = await Promise.resolve().then(function () { return balanceGate; });
|
|
2425
|
+
const { createFreeSpinsCounter } = await Promise.resolve().then(function () { return freeSpinsCounter; });
|
|
2426
|
+
const { resolvePlayError } = await Promise.resolve().then(function () { return playError; });
|
|
2427
|
+
// slotPlay references shell via closure — define it after shell is assigned below.
|
|
2428
|
+
// We use a late-binding wrapper so the closure captures the variable, not null.
|
|
2429
|
+
const slotPlay$1 = createSlotPlay({
|
|
2430
|
+
play: (p) => game.platformSession.play(p),
|
|
2431
|
+
normalize: opts.normalize,
|
|
2432
|
+
// ACK the result AFTER the scene animates it (the scene calls host.ack()). On Stake this
|
|
2433
|
+
// triggers /wallet/end-round so a winning round settles post-animation instead of staying
|
|
2434
|
+
// open and blocking the next spin.
|
|
2435
|
+
ack: (raw) => game.platformSession.playAck(raw),
|
|
2436
|
+
});
|
|
2437
|
+
if (opts.shell) {
|
|
2438
|
+
const { createGameShell } = await import('@energy8platform/platform-core/shell');
|
|
2439
|
+
const { buildShellConfig } = await Promise.resolve().then(function () { return shellConfig; });
|
|
2440
|
+
const { resolveReplayBonusId } = await Promise.resolve().then(function () { return replay; });
|
|
2441
|
+
const ps = game.platformSession;
|
|
2442
|
+
const balance = game.initData?.balance ?? 0;
|
|
2443
|
+
const isReplay = !!stakeBridge?.isReplay;
|
|
2444
|
+
const mode = isReplay ? 'replay' : 'base';
|
|
2445
|
+
// initData.config carries the Stake bridge's currency/social/disclaimer surface (GameConfigData);
|
|
2446
|
+
// all are absent in non-stake/dev launches → graceful fallbacks downstream.
|
|
2447
|
+
const initData = game.initData;
|
|
2448
|
+
const config = initData?.config;
|
|
2449
|
+
const { resolveCurrency } = await Promise.resolve().then(function () { return shellConfig; });
|
|
2450
|
+
// SINGLE source of truth for the symbol: the Stake bridge already puts a full CurrencyMetaData
|
|
2451
|
+
// (symbol + placement) on initData.config.currency. In the non-stake/devBridge path that meta
|
|
2452
|
+
// is absent and we only have the spec's currency CODE — resolve it through the SAME table
|
|
2453
|
+
// (stake-bridge's lookupCurrency) so e.g. 'EUR' renders as '€', not the literal text "EUR".
|
|
2454
|
+
// stake-bridge ships with every scaffold; if it's somehow absent we degrade to the code.
|
|
2455
|
+
let currencyMeta = config?.currency;
|
|
2456
|
+
if (!currencyMeta?.symbol && opts.model.spec.currency) {
|
|
2457
|
+
try {
|
|
2458
|
+
const { lookupCurrency } = await import('@energy8platform/stake-bridge');
|
|
2459
|
+
currencyMeta = lookupCurrency(opts.model.spec.currency);
|
|
2460
|
+
}
|
|
2461
|
+
catch { /* stake-bridge not installed — resolveCurrency falls back to the code */ }
|
|
2462
|
+
}
|
|
2463
|
+
const runtime = {
|
|
2464
|
+
balance,
|
|
2465
|
+
currency: resolveCurrency(currencyMeta, opts.model.spec.currency),
|
|
2466
|
+
language: initData?.lang,
|
|
2467
|
+
mode,
|
|
2468
|
+
social: config?.socialMode,
|
|
2469
|
+
disclaimerLines: config?.disclaimerLines,
|
|
2470
|
+
jurisdiction: config?.jurisdiction,
|
|
2471
|
+
// Currency-specific ladder + per-currency default from /wallet/authenticate (Stake);
|
|
2472
|
+
// absent on dev/devBridge → buildShellConfig falls back to the spec.
|
|
2473
|
+
betLevels: config?.betLevels,
|
|
2474
|
+
defaultBet: config?.stake?.defaultBetLevel ?? config?.defaultBet,
|
|
2475
|
+
};
|
|
2476
|
+
if (opts.dev) {
|
|
2477
|
+
// Dev-only diagnostic. Logged as PLAIN STRINGS (not collapsed objects) so the values are
|
|
2478
|
+
// readable in the console without expanding. If the shown symbol is a bare code ("EUR")
|
|
2479
|
+
// instead of a glyph ("€"), paste this whole line.
|
|
2480
|
+
const cc = config?.currency;
|
|
2481
|
+
console.info(`[e8] currency → bridge.code=${cc?.code ?? '∅'} bridge.symbol=${cc?.symbol ?? '∅'} ` +
|
|
2482
|
+
`| spec=${opts.model.spec.currency ?? '∅'} ` +
|
|
2483
|
+
`| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`);
|
|
2484
|
+
}
|
|
2485
|
+
shell = createGameShell(buildShellConfig(opts.shell, opts.model, runtime));
|
|
2486
|
+
// The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
|
|
2487
|
+
// the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
|
|
2488
|
+
// async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
|
|
2489
|
+
// is the single source for both the displayed balance and `ensureAffordable`.
|
|
2490
|
+
const balanceGate = createBalanceGate((b) => shell.setBalance(b), balance);
|
|
2491
|
+
ps?.on('balanceUpdate', (d) => { balanceGate.onBalance(d.balance); });
|
|
2492
|
+
// Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
|
|
2493
|
+
let currentTurbo = shell.state.turbo;
|
|
2494
|
+
shell.on('turboChange', (level) => { currentTurbo = level; });
|
|
2495
|
+
const roleOf = (action) => opts.model.spec.actions[action]?.role;
|
|
2496
|
+
const makeContext = (action) => ({
|
|
2497
|
+
bet: currentBet,
|
|
2498
|
+
action,
|
|
2499
|
+
mode: opts.model.modeMap[action] ?? action.toUpperCase(),
|
|
2500
|
+
formatAmount: (v) => shell.formatWin(v),
|
|
2501
|
+
get turbo() { return currentTurbo; },
|
|
2502
|
+
});
|
|
2503
|
+
// Play-error + connection handling. A play rejection is classified into a player-facing modal
|
|
2504
|
+
// (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
|
|
2505
|
+
// overlay is suppressed while a play-error modal owns the screen.
|
|
2506
|
+
let playErrorOpen = false;
|
|
2507
|
+
let stopAutoplay = () => { }; // wired to the autoplay loop once it's created (below)
|
|
2508
|
+
const showPlayError = (err) => {
|
|
2509
|
+
stopAutoplay(); // a play error halts an autoplay run (the .catch swallows, so stop explicitly)
|
|
2510
|
+
const v = resolvePlayError(err);
|
|
2511
|
+
playErrorOpen = true;
|
|
2512
|
+
shell.openModal({
|
|
2513
|
+
availableClose: !v.reload,
|
|
2514
|
+
title: shell.t(v.title),
|
|
2515
|
+
body: shell.t(v.body),
|
|
2516
|
+
actions: v.reload
|
|
2517
|
+
? [{ title: shell.t('Reload'), on: () => { try {
|
|
2518
|
+
window.location.reload();
|
|
2519
|
+
}
|
|
2520
|
+
catch { /* non-browser */ } } }]
|
|
2521
|
+
: [{ title: shell.t('OK'), on: () => { playErrorOpen = false; } }],
|
|
2522
|
+
});
|
|
2523
|
+
};
|
|
2524
|
+
ps?.on('connectionStateChanged', (s) => {
|
|
2525
|
+
if (s.status === 'restored') {
|
|
2526
|
+
if (!playErrorOpen)
|
|
2527
|
+
shell.closeModal();
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
if (playErrorOpen)
|
|
2531
|
+
return; // a play-error modal owns the screen — don't mask it with "reconnecting"
|
|
2532
|
+
shell.openModal({
|
|
2533
|
+
availableClose: false,
|
|
2534
|
+
title: shell.t('Reconnecting…'),
|
|
2535
|
+
body: shell.t('Lost connection to the game server. Trying to reconnect…'),
|
|
2536
|
+
});
|
|
2537
|
+
});
|
|
2538
|
+
/** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
|
|
2539
|
+
* update only AFTER each present(), per the HUD-timing requirement. */
|
|
2540
|
+
const playRound = (action) => {
|
|
2541
|
+
const scene = gameScene();
|
|
2542
|
+
if (!scene)
|
|
2543
|
+
return;
|
|
2544
|
+
// Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
|
|
2545
|
+
// (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
|
|
2546
|
+
// the trigger segment (presented before onBonusEnter) doesn't count as a free spin.
|
|
2547
|
+
let inBonus = false;
|
|
2548
|
+
let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
|
|
2549
|
+
const fsCounter = createFreeSpinsCounter();
|
|
2550
|
+
shell.setBusy(true); // block re-spin / spacebar while the round plays out
|
|
2551
|
+
// RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
|
|
2552
|
+
// animation has finished — returning void would reopen it instantly, over a running animation.
|
|
2553
|
+
return runRound({
|
|
2554
|
+
// Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
|
|
2555
|
+
play: (a, b, rid) => { balanceGate.beginPlay(); return slotPlay$1.play(a, b, rid); },
|
|
2556
|
+
ack: slotPlay$1.ack,
|
|
2557
|
+
scene,
|
|
2558
|
+
context: makeContext,
|
|
2559
|
+
roleOf,
|
|
2560
|
+
afterPresent: (r) => {
|
|
2561
|
+
// WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
|
|
2562
|
+
// free-spins counter (totalWin) below, not the WIN readout.
|
|
2563
|
+
shell.setWin(r.totalWin - prevWin);
|
|
2564
|
+
prevWin = r.totalWin;
|
|
2565
|
+
balanceGate.afterPresent();
|
|
2566
|
+
if (inBonus)
|
|
2567
|
+
shell.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
|
|
2568
|
+
},
|
|
2569
|
+
onBonusEnter: async (trigger, ctx) => {
|
|
2570
|
+
inBonus = true;
|
|
2571
|
+
shell.setMode('freeSpins');
|
|
2572
|
+
shell.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
|
|
2573
|
+
await scene.onBonusEnter?.(trigger, ctx);
|
|
2574
|
+
},
|
|
2575
|
+
onBonusExit: async (last, ctx) => {
|
|
2576
|
+
inBonus = false;
|
|
2577
|
+
await scene.onBonusExit?.(last, ctx);
|
|
2578
|
+
shell.setMode('base');
|
|
2579
|
+
},
|
|
2580
|
+
}, action).catch(showPlayError).finally(() => shell.setBusy(false));
|
|
2581
|
+
};
|
|
2582
|
+
/**
|
|
2583
|
+
* Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
|
|
2584
|
+
* the bonus start (Continue animates each; Finish fast-forwards without animation), reaching the
|
|
2585
|
+
* final ack so /wallet/end-round credits the win — fixing the old resume that presented one
|
|
2586
|
+
* snapshot and never settled. The original trigger is gone on reload, so the FS counter here uses
|
|
2587
|
+
* the bridge's session counts; FS mode is entered/exited around the drain.
|
|
2588
|
+
*/
|
|
2589
|
+
const resumeDrain = async (firstRaw, animate) => {
|
|
2590
|
+
const scene = gameScene();
|
|
2591
|
+
if (!scene || !ps)
|
|
2592
|
+
return;
|
|
2593
|
+
const ctx = makeContext(firstRaw.action ?? 'spin');
|
|
2594
|
+
const fsView = (raw, totalWin) => {
|
|
2595
|
+
const s = raw.session;
|
|
2596
|
+
if (!s)
|
|
2597
|
+
return null;
|
|
2598
|
+
// The bridge session counts ALL segments incl. the trigger (segment 0); the free-spins
|
|
2599
|
+
// counter is over FREE spins only, so drop the one trigger segment → 1/10, not 2/11.
|
|
2600
|
+
const played = s.spinsPlayed ?? 0;
|
|
2601
|
+
const current = Math.max(0, played - 1);
|
|
2602
|
+
const total = Math.max(0, played + (s.spinsRemaining ?? 0) - 1);
|
|
2603
|
+
return { current, total, totalWin };
|
|
2604
|
+
};
|
|
2605
|
+
let raw = firstRaw;
|
|
2606
|
+
let r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
2607
|
+
let inBonus = false;
|
|
2608
|
+
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
2609
|
+
const applySegment = async () => {
|
|
2610
|
+
// A recovered open round with remaining segments is a bonus → show FS mode + counter.
|
|
2611
|
+
if (!inBonus && !r.complete) {
|
|
2612
|
+
inBonus = true;
|
|
2613
|
+
shell.setMode('freeSpins');
|
|
2614
|
+
}
|
|
2615
|
+
if (animate)
|
|
2616
|
+
await scene.present(r, ctx);
|
|
2617
|
+
if (inBonus) {
|
|
2618
|
+
const v = fsView(raw, r.totalWin);
|
|
2619
|
+
if (v)
|
|
2620
|
+
shell.setFreeSpins(v);
|
|
2621
|
+
}
|
|
2622
|
+
shell.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
2623
|
+
prevWin = r.totalWin;
|
|
2624
|
+
ps.playAck(raw); // settles via /wallet/end-round on the FINAL segment
|
|
2625
|
+
};
|
|
2626
|
+
shell.setBusy(true); // block input while the recovered round drains
|
|
2627
|
+
try {
|
|
2628
|
+
await applySegment();
|
|
2629
|
+
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
2630
|
+
raw = (await ps.play({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId }));
|
|
2631
|
+
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
2632
|
+
await applySegment();
|
|
2633
|
+
}
|
|
2634
|
+
if (inBonus)
|
|
2635
|
+
shell.setMode('base');
|
|
2636
|
+
}
|
|
2637
|
+
finally {
|
|
2638
|
+
shell.setBusy(false);
|
|
2639
|
+
}
|
|
2640
|
+
};
|
|
2641
|
+
if (mode === 'base') {
|
|
2642
|
+
let activeFeature = null;
|
|
2643
|
+
shell.on('featureActivate', ({ id }) => { activeFeature = id; });
|
|
2644
|
+
shell.on('featureDeactivate', ({ id: _id }) => { activeFeature = null; });
|
|
2645
|
+
const { stakeForAction } = await Promise.resolve().then(function () { return shellConfig; });
|
|
2646
|
+
// Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
|
|
2647
|
+
const ensureAffordable = (action) => {
|
|
2648
|
+
if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9)
|
|
2649
|
+
return true;
|
|
2650
|
+
shell.openModal({
|
|
2651
|
+
availableClose: true,
|
|
2652
|
+
title: shell.t('Insufficient balance'),
|
|
2653
|
+
body: shell.t('You don’t have enough balance for this bet. Lower your bet or top up.'),
|
|
2654
|
+
actions: [{ title: shell.t('OK') }],
|
|
2655
|
+
});
|
|
2656
|
+
return false;
|
|
2657
|
+
};
|
|
2658
|
+
shell.on('spin', () => {
|
|
2659
|
+
const action = activeFeature ?? 'spin';
|
|
2660
|
+
if (!ensureAffordable(action))
|
|
2661
|
+
return;
|
|
2662
|
+
void playRound(action);
|
|
2663
|
+
});
|
|
2664
|
+
shell.on('betChange', (bet) => { currentBet = bet; });
|
|
2665
|
+
shell.on('buyBonusSelect', ({ id }) => {
|
|
2666
|
+
if (!ensureAffordable(id))
|
|
2667
|
+
return;
|
|
2668
|
+
void playRound(id);
|
|
2669
|
+
});
|
|
2670
|
+
// Autoplay: the shell owns the picker/confirm/STOP/counter/lockout (all driven by state.autoplay);
|
|
2671
|
+
// the host just runs the loop and pushes the per-spin remaining back via setAutoplay.
|
|
2672
|
+
const { createAutoplayLoop } = await Promise.resolve().then(function () { return autoplay; });
|
|
2673
|
+
const autoplay$1 = createAutoplayLoop({
|
|
2674
|
+
resolveAction: () => activeFeature ?? 'spin',
|
|
2675
|
+
canAfford: (a) => ensureAffordable(a),
|
|
2676
|
+
playRound: (a) => Promise.resolve(playRound(a)),
|
|
2677
|
+
onState: (s) => shell.setAutoplay(s),
|
|
2678
|
+
});
|
|
2679
|
+
stopAutoplay = () => autoplay$1.stop();
|
|
2680
|
+
shell.on('autoplayStart', (o) => autoplay$1.start(o?.remaining ?? 0));
|
|
2681
|
+
shell.on('autoplayStop', () => autoplay$1.stop());
|
|
2682
|
+
// Resume offer: when the game scene is (or becomes) current on a reload, ask the host whether
|
|
2683
|
+
// a round is still open. If so, offer Continue (replay its animation, then settle) or Finish
|
|
2684
|
+
// (settle now). Settlement is the same playAck path a normal spin uses. Runs at most once.
|
|
2685
|
+
let resumeOffered = false;
|
|
2686
|
+
const offerResume = async () => {
|
|
2687
|
+
if (resumeOffered || !shell || !gameScene())
|
|
2688
|
+
return;
|
|
2689
|
+
resumeOffered = true;
|
|
2690
|
+
let snap = null;
|
|
2691
|
+
try {
|
|
2692
|
+
snap = await ps?.getState() ?? null;
|
|
2693
|
+
}
|
|
2694
|
+
catch {
|
|
2695
|
+
snap = null;
|
|
2696
|
+
}
|
|
2697
|
+
if (!snap)
|
|
2698
|
+
return;
|
|
2699
|
+
shell.openModal({
|
|
2700
|
+
availableClose: false,
|
|
2701
|
+
title: shell.t('Unfinished round'),
|
|
2702
|
+
body: shell.t('You have an unfinished round. Continue it or finish it now?'),
|
|
2703
|
+
actions: [
|
|
2704
|
+
// Continue: replay the round from the start with animation, then settle.
|
|
2705
|
+
{ title: shell.t('Continue'), on: () => { void resumeDrain(snap, true); } },
|
|
2706
|
+
// Finish: fast-forward the remaining segments (no animation) to settle the win now.
|
|
2707
|
+
{ title: shell.t('Finish'), on: () => { void resumeDrain(snap, false); } },
|
|
2708
|
+
],
|
|
2709
|
+
});
|
|
2710
|
+
};
|
|
2711
|
+
game.scenes.on('change', () => { void offerResume(); });
|
|
2712
|
+
void offerResume();
|
|
2713
|
+
}
|
|
2714
|
+
else {
|
|
2715
|
+
const stakeMode = stakeBridge?.replayMode ?? 'BASE';
|
|
2716
|
+
const bonusId = resolveReplayBonusId(opts.model, stakeMode);
|
|
2717
|
+
// The replayed round's OWN bet + payout (fetched up front per Stake rules), not the spec's
|
|
2718
|
+
// default bet — otherwise the replay modal always shows bet 1.
|
|
2719
|
+
const replayBet = stakeBridge?.replayBet || currentBet;
|
|
2720
|
+
currentBet = replayBet;
|
|
2721
|
+
// onReplay only spins — the shell reopens the modal after it resolves; never call openReplay inside onReplay (double-open).
|
|
2722
|
+
shell.openReplay({
|
|
2723
|
+
bonusId,
|
|
2724
|
+
bet: replayBet,
|
|
2725
|
+
payoutMultiplier: stakeBridge?.replayPayoutMultiplier ?? 0,
|
|
2726
|
+
onReplay: () => playRound(bonusId),
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
return { game, stakeBridge, shell };
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
// packages/game-engine/src/host/shellConfig.ts
|
|
2734
|
+
/**
|
|
2735
|
+
* Apply jurisdiction restrictions over the resolved shell features, in place. A restriction ALWAYS
|
|
2736
|
+
* wins over the author's intent (a forbidden control must stay off even if the game enabled it).
|
|
2737
|
+
*/
|
|
2738
|
+
function applyJurisdiction(features, j) {
|
|
2739
|
+
if (!j)
|
|
2740
|
+
return;
|
|
2741
|
+
if (j.disabledTurbo)
|
|
2742
|
+
features.turbo = 0;
|
|
2743
|
+
else if (j.disabledSuperTurbo && features.turbo > 1)
|
|
2744
|
+
features.turbo = 1;
|
|
2745
|
+
if (j.disabledSpacebar)
|
|
2746
|
+
features.spacebar = false;
|
|
2747
|
+
if (j.disabledAutoplay)
|
|
2748
|
+
features.autoplay = null;
|
|
2749
|
+
if (j.disabledBuyFeature)
|
|
2750
|
+
features.buyBonus = false;
|
|
2751
|
+
}
|
|
2752
|
+
/**
|
|
2753
|
+
* Resolve the shell `CurrencyConfig` from the SAME data the Stake bridge uses — the
|
|
2754
|
+
* `CurrencyMetaData` it puts on `initData.config.currency` (symbol + placement from
|
|
2755
|
+
* `symbolAfter`). No second symbol table lives here.
|
|
2756
|
+
*
|
|
2757
|
+
* Fallback chain (dev/devBridge with no Stake meta): `initData.config.currency`
|
|
2758
|
+
* → the spec's currency `code` (neutral `{ symbol: code, position: 'left' }`)
|
|
2759
|
+
* → `{ symbol: '€', position: 'left' }`.
|
|
2760
|
+
*/
|
|
2761
|
+
/** Extra precision for WIN / TOTAL-WIN readouts so small-bet wins (e.g. 0.0041 on a 0.01 bet) are
|
|
2762
|
+
* not rounded away to 0.00. Balance / bet stay at the currency's own decimals (`minDecimals`). */
|
|
2763
|
+
const WIN_MAX_DECIMALS = 4;
|
|
2764
|
+
/** Attach decimals: `minDecimals` (balance/bet/prices, fixed) = the currency's decimals; `maxDecimals`
|
|
2765
|
+
* (win/total-win, variable, trailing zeros trimmed) = up to WIN_MAX_DECIMALS — but only when the
|
|
2766
|
+
* currency actually has fraction digits (a 0-decimal currency like JPY keeps wins integer). */
|
|
2767
|
+
function withDecimals(base, decimals) {
|
|
2768
|
+
return {
|
|
2769
|
+
...base,
|
|
2770
|
+
minDecimals: decimals,
|
|
2771
|
+
maxDecimals: decimals > 0 ? Math.max(decimals, WIN_MAX_DECIMALS) : 0,
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
function resolveCurrency(meta, specCurrency) {
|
|
2775
|
+
const hasMeta = !!(meta && meta.symbol);
|
|
2776
|
+
// Single expression, no early-return branches (the bundler was treeshaking the meta branch away).
|
|
2777
|
+
const symbol = hasMeta ? meta.symbol : (specCurrency || '€');
|
|
2778
|
+
const position = hasMeta && meta.symbolAfter ? 'right' : 'left';
|
|
2779
|
+
const decimals = hasMeta && typeof meta.decimals === 'number' ? meta.decimals : 2;
|
|
2780
|
+
return withDecimals({ symbol, position }, decimals);
|
|
2781
|
+
}
|
|
2782
|
+
/** Total stake for an action = bet × the action's cost multiplier (1 for a base spin; e.g. 100 for
|
|
2783
|
+
* a buy bonus). The host uses this to block a play the balance can't cover. */
|
|
2784
|
+
function stakeForAction(model, action, bet) {
|
|
2785
|
+
const cost = (model.spec.actions?.[action]?.cost ?? 1);
|
|
2786
|
+
return cost * bet;
|
|
2787
|
+
}
|
|
2788
|
+
/** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
|
|
2789
|
+
function toBonusOptions(model) {
|
|
2790
|
+
const out = [];
|
|
2791
|
+
for (const [key, action] of Object.entries(model.spec.actions)) {
|
|
2792
|
+
const role = action.role ?? 'base';
|
|
2793
|
+
if (role !== 'buy' && role !== 'feature')
|
|
2794
|
+
continue;
|
|
2795
|
+
out.push({
|
|
2796
|
+
id: key,
|
|
2797
|
+
type: role === 'buy' ? 'bonus' : 'feature',
|
|
2798
|
+
title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
|
|
2799
|
+
description: action.description ?? '',
|
|
2800
|
+
priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
return out;
|
|
2804
|
+
}
|
|
2805
|
+
/** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
|
|
2806
|
+
function paytableSection(model) {
|
|
2807
|
+
const symbols = model.paytable?.symbols ?? [];
|
|
2808
|
+
const rows = [];
|
|
2809
|
+
for (const s of symbols) {
|
|
2810
|
+
const wins = Object.entries(s.pay ?? {})
|
|
2811
|
+
.map(([count, multiplier]) => ({ count: String(count), multiplier: Number(multiplier) }))
|
|
2812
|
+
.filter((w) => Number.isFinite(w.multiplier) && w.multiplier > 0)
|
|
2813
|
+
.sort((a, b) => Number(a.count) - Number(b.count));
|
|
2814
|
+
if (!wins.length)
|
|
2815
|
+
continue;
|
|
2816
|
+
rows.push({ symbol: { text: s.name ?? s.id }, wins });
|
|
2817
|
+
}
|
|
2818
|
+
if (!rows.length)
|
|
2819
|
+
return null;
|
|
2820
|
+
return { type: 'paytable', title: 'PAYTABLE', rows };
|
|
2821
|
+
}
|
|
2822
|
+
/** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
|
|
2823
|
+
function winsSection(model) {
|
|
2824
|
+
const { cols, rows } = model.spec.grid;
|
|
2825
|
+
const grid = { cols, rows };
|
|
2826
|
+
switch (model.spec.mechanic) {
|
|
2827
|
+
case 'cluster':
|
|
2828
|
+
return { type: 'wins', kind: 'cluster', minCount: 5, grid };
|
|
2829
|
+
case 'ways':
|
|
2830
|
+
return { type: 'wins', kind: 'ways', grid };
|
|
2831
|
+
default:
|
|
2832
|
+
return { type: 'wins', kind: 'anywhere', minCount: 3, grid };
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
/** Title of the legal disclaimer section — used to build it and to exempt it from socialization. */
|
|
2836
|
+
const DISCLAIMER_TITLE = 'DISCLAIMER';
|
|
2837
|
+
/** A disclaimer section from initData's disclaimer lines; null when none supplied. */
|
|
2838
|
+
function disclaimerSection(lines) {
|
|
2839
|
+
const clean = (lines ?? []).map((l) => l.trim()).filter(Boolean);
|
|
2840
|
+
if (!clean.length)
|
|
2841
|
+
return null;
|
|
2842
|
+
const html = clean.map((l) => `<p>${l}</p>`).join('');
|
|
2843
|
+
return { type: 'custom', title: DISCLAIMER_TITLE, html };
|
|
2844
|
+
}
|
|
2845
|
+
/** The legal disclaimer must be shown verbatim — this identifies it so socialization skips it. */
|
|
2846
|
+
function isDisclaimerSection(s) {
|
|
2847
|
+
return s.type === 'custom' && s.title === DISCLAIMER_TITLE;
|
|
2848
|
+
}
|
|
2849
|
+
/** Move the legal disclaimer to the very END of the section list — it must always render last,
|
|
2850
|
+
* regardless of where an author merge or an extra section would otherwise place it. */
|
|
2851
|
+
function orderDisclaimerLast(sections) {
|
|
2852
|
+
const disclaimer = sections.filter(isDisclaimerSection);
|
|
2853
|
+
if (!disclaimer.length)
|
|
2854
|
+
return sections;
|
|
2855
|
+
return [...sections.filter((s) => !isDisclaimerSection(s)), ...disclaimer];
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Pure: derive a maximal default GameInfoContent from the model + runtime so every game
|
|
2859
|
+
* gets a real info panel for free (paytable, win illustration, controls, and the Stake
|
|
2860
|
+
* disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
|
|
2861
|
+
* section identity (see `mergeGameInfo`), not wholesale-replaced.
|
|
2862
|
+
*/
|
|
2863
|
+
function defaultGameInfo(model, runtime) {
|
|
2864
|
+
const sections = [];
|
|
2865
|
+
sections.push(winsSection(model));
|
|
2866
|
+
const pay = paytableSection(model);
|
|
2867
|
+
if (pay)
|
|
2868
|
+
sections.push(pay);
|
|
2869
|
+
const modes = modesSection(model);
|
|
2870
|
+
if (modes)
|
|
2871
|
+
sections.push(modes);
|
|
2872
|
+
sections.push({ type: 'controls' });
|
|
2873
|
+
const disclaimer = disclaimerSection(runtime.disclaimerLines);
|
|
2874
|
+
if (disclaimer)
|
|
2875
|
+
sections.push(disclaimer);
|
|
2876
|
+
return { sections };
|
|
2877
|
+
}
|
|
2878
|
+
/** Per-mode info table (BASE / ANTE / each buy tier) derived from the spec's modes — the SAME SSOT
|
|
2879
|
+
* (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
|
|
2880
|
+
* compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
|
|
2881
|
+
* mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
|
|
2882
|
+
* already drops them — free spins are part of a bonus, not a purchasable mode). */
|
|
2883
|
+
function modesSection(model) {
|
|
2884
|
+
const modes = model.mathModes ?? [];
|
|
2885
|
+
if (!modes.length)
|
|
2886
|
+
return null;
|
|
2887
|
+
const rows = modes.map((m) => {
|
|
2888
|
+
const action = model.spec.actions[m.action];
|
|
2889
|
+
const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
|
|
2890
|
+
const row = {
|
|
2891
|
+
title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
|
|
2892
|
+
maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
|
|
2893
|
+
};
|
|
2894
|
+
// Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
|
|
2895
|
+
if (m.costMultiplier && m.costMultiplier !== 1)
|
|
2896
|
+
row.price = `${m.costMultiplier}×`;
|
|
2897
|
+
if (typeof m.rtp === 'number')
|
|
2898
|
+
row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
|
|
2899
|
+
if (action?.description)
|
|
2900
|
+
row.description = action.description;
|
|
2901
|
+
return row;
|
|
2902
|
+
});
|
|
2903
|
+
return { type: 'modes', title: 'MODES', modes: rows };
|
|
2904
|
+
}
|
|
2905
|
+
/** Identity key for merge. `wins` is keyed by `kind` (different mechanics coexist). `custom` has
|
|
2906
|
+
* no structural discriminant and several can coexist (MAX WIN, DISCLAIMER, …) so it is keyed by
|
|
2907
|
+
* its `title` (an author `custom` with a matching title replaces that derived block; a new title
|
|
2908
|
+
* is added). Every other type is a singleton keyed by `type`. */
|
|
2909
|
+
function sectionKey(s) {
|
|
2910
|
+
if (s.type === 'wins')
|
|
2911
|
+
return `wins:${s.kind}`;
|
|
2912
|
+
if (s.type === 'custom')
|
|
2913
|
+
return `custom:${s.title ?? ''}`;
|
|
2914
|
+
return s.type;
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Merge author `gameInfo` over the host-derived set by section identity: an author section
|
|
2918
|
+
* REPLACES the derived section of the same identity (same `type`, or same `wins` `kind`); a new
|
|
2919
|
+
* identity is APPENDED (after the derived ones, in author order); derived sections without an
|
|
2920
|
+
* author override are KEPT. `override` undefined → the pure derived set.
|
|
2921
|
+
*/
|
|
2922
|
+
function mergeGameInfo(derived, override) {
|
|
2923
|
+
if (!override)
|
|
2924
|
+
return derived;
|
|
2925
|
+
const authorByKey = new Map();
|
|
2926
|
+
for (const s of override.sections ?? [])
|
|
2927
|
+
authorByKey.set(sectionKey(s), s);
|
|
2928
|
+
const out = [];
|
|
2929
|
+
const used = new Set();
|
|
2930
|
+
// Keep derived order; swap in the author's version where identities collide.
|
|
2931
|
+
for (const s of derived.sections ?? []) {
|
|
2932
|
+
const k = sectionKey(s);
|
|
2933
|
+
const replacement = authorByKey.get(k);
|
|
2934
|
+
if (replacement) {
|
|
2935
|
+
out.push(replacement);
|
|
2936
|
+
used.add(k);
|
|
2937
|
+
}
|
|
2938
|
+
else
|
|
2939
|
+
out.push(s);
|
|
2940
|
+
}
|
|
2941
|
+
// Append author sections whose identity wasn't in the derived set, in author order.
|
|
2942
|
+
for (const s of override.sections ?? []) {
|
|
2943
|
+
const k = sectionKey(s);
|
|
2944
|
+
if (!used.has(k)) {
|
|
2945
|
+
out.push(s);
|
|
2946
|
+
used.add(k);
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
return { sections: out };
|
|
2950
|
+
}
|
|
2951
|
+
/** Run a section's player-facing text through `socialize`. Applied to the full MERGED set
|
|
2952
|
+
* (host-derived + author) in social mode. Covers section titles, custom HTML, and PAYTABLE row
|
|
2953
|
+
* symbol labels — the paytable's symbol text comes straight from the gameSpec's `symbols[].name`,
|
|
2954
|
+
* so a forbidden word in a spec symbol name is rewritten here too. A `node`-based custom section is
|
|
2955
|
+
* returned untouched — its DOM is author-owned and not introspected. */
|
|
2956
|
+
function socializeSection(s) {
|
|
2957
|
+
const next = { ...s };
|
|
2958
|
+
if ('title' in next && typeof next.title === 'string') {
|
|
2959
|
+
next.title = shell.socialize(next.title);
|
|
2960
|
+
}
|
|
2961
|
+
if (next.type === 'custom' && typeof next.html === 'string') {
|
|
2962
|
+
next.html = shell.socialize(next.html);
|
|
2963
|
+
}
|
|
2964
|
+
if (next.type === 'paytable' && Array.isArray(next.rows)) {
|
|
2965
|
+
next.rows = next.rows.map((r) => typeof r.symbol?.text === 'string'
|
|
2966
|
+
? { ...r, symbol: { ...r.symbol, text: shell.socialize(r.symbol.text) } }
|
|
2967
|
+
: r);
|
|
2968
|
+
}
|
|
2969
|
+
if (next.type === 'modes' && Array.isArray(next.modes)) {
|
|
2970
|
+
next.modes = next.modes.map((m) => ({
|
|
2971
|
+
...m,
|
|
2972
|
+
title: shell.socialize(m.title),
|
|
2973
|
+
...(m.description ? { description: shell.socialize(m.description) } : {}),
|
|
2974
|
+
}));
|
|
2975
|
+
}
|
|
2976
|
+
return next;
|
|
2977
|
+
}
|
|
2978
|
+
/** Socialize buy-bonus card copy (title/description) when in social mode; a no-op otherwise.
|
|
2979
|
+
* Applied to the final option set (author override or spec-derived) so forbidden words in author
|
|
2980
|
+
* card copy are rewritten too. */
|
|
2981
|
+
function socializeBonusOptions(options, isSocial) {
|
|
2982
|
+
if (!isSocial)
|
|
2983
|
+
return options;
|
|
2984
|
+
return options.map((o) => ({ ...o, title: shell.socialize(o.title), description: shell.socialize(o.description) }));
|
|
2985
|
+
}
|
|
2986
|
+
/** Pure: assemble a ShellConfig from the model + runtime context (currency/balance/language/mode). */
|
|
2987
|
+
function buildShellConfig(opts, model, runtime) {
|
|
2988
|
+
// Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
|
|
2989
|
+
const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
|
|
2990
|
+
// Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
|
|
2991
|
+
const defaultBet = runtime.defaultBet ?? model.spec.defaultBet ?? betLevels[0];
|
|
2992
|
+
// runtime.currency is the resolved CurrencyConfig (derived from initData.config.currency by the
|
|
2993
|
+
// host); opts.currency still wins. Fall back to the spec code, then a neutral euro.
|
|
2994
|
+
const currency = opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
|
|
2995
|
+
const isSocial = runtime.social ?? false;
|
|
2996
|
+
// Merge author sections over the host-derived defaults, THEN socialize the WHOLE merged set in
|
|
2997
|
+
// social mode — so restricted gambling vocabulary is rewritten in BOTH the built-in copy AND any
|
|
2998
|
+
// author-supplied text (title + custom HTML). A game can no longer surface a forbidden word in
|
|
2999
|
+
// social mode just because the author wrote it in their own info section. (Custom sections built
|
|
3000
|
+
// from a raw DOM `node` can't be rewritten automatically — author owns the node and can call the
|
|
3001
|
+
// exported `socialize` from '@energy8platform/game-engine/host' on their own strings.)
|
|
3002
|
+
// Author gameInfo may be a plain object or a `(t) => content` factory. `t` socializes when in
|
|
3003
|
+
// social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
|
|
3004
|
+
// still socialized below as a safety net.
|
|
3005
|
+
const t = isSocial ? shell.socialize : (text) => text;
|
|
3006
|
+
const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
|
|
3007
|
+
let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
|
|
3008
|
+
// The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
|
|
3009
|
+
// wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
|
|
3010
|
+
if (isSocial) {
|
|
3011
|
+
gameInfo = {
|
|
3012
|
+
sections: (gameInfo.sections ?? []).map((s) => (isDisclaimerSection(s) ? s : socializeSection(s))),
|
|
3013
|
+
};
|
|
3014
|
+
}
|
|
3015
|
+
// The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
|
|
3016
|
+
gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
|
|
3017
|
+
// Buy-bonus cards: socialize the FINAL options (author override or spec-derived) in social mode.
|
|
3018
|
+
const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model), isSocial);
|
|
3019
|
+
// Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
|
|
3020
|
+
const features = {
|
|
3021
|
+
turbo: 0,
|
|
3022
|
+
spacebar: true,
|
|
3023
|
+
autoplay: {},
|
|
3024
|
+
buyBonus,
|
|
3025
|
+
...(opts.features ?? {}),
|
|
3026
|
+
};
|
|
3027
|
+
applyJurisdiction(features, runtime.jurisdiction);
|
|
3028
|
+
return {
|
|
3029
|
+
mount: opts.mount ?? (typeof document !== 'undefined' ? document.body : undefined),
|
|
3030
|
+
language: runtime.language ?? 'en',
|
|
3031
|
+
isSocial,
|
|
3032
|
+
currency,
|
|
3033
|
+
gameInfo,
|
|
3034
|
+
availableBets: [...betLevels],
|
|
3035
|
+
defaultBet,
|
|
3036
|
+
currentBet: defaultBet,
|
|
3037
|
+
balance: runtime.balance,
|
|
3038
|
+
win: 0,
|
|
3039
|
+
mode: runtime.mode,
|
|
3040
|
+
features,
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
var shellConfig = /*#__PURE__*/Object.freeze({
|
|
3045
|
+
__proto__: null,
|
|
3046
|
+
applyJurisdiction: applyJurisdiction,
|
|
3047
|
+
buildShellConfig: buildShellConfig,
|
|
3048
|
+
defaultGameInfo: defaultGameInfo,
|
|
3049
|
+
mergeGameInfo: mergeGameInfo,
|
|
3050
|
+
resolveCurrency: resolveCurrency,
|
|
3051
|
+
stakeForAction: stakeForAction,
|
|
3052
|
+
toBonusOptions: toBonusOptions
|
|
3053
|
+
});
|
|
3054
|
+
|
|
3055
|
+
/** Reverse the model's modeMap (Stake bet mode → SDK action key) for replay labelling/cost. */
|
|
3056
|
+
function resolveReplayBonusId(model, stakeMode) {
|
|
3057
|
+
for (const [action, mode] of Object.entries(model.modeMap)) {
|
|
3058
|
+
if (mode === stakeMode)
|
|
3059
|
+
return action;
|
|
3060
|
+
}
|
|
3061
|
+
return stakeMode;
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
var replay = /*#__PURE__*/Object.freeze({
|
|
3065
|
+
__proto__: null,
|
|
3066
|
+
resolveReplayBonusId: resolveReplayBonusId
|
|
3067
|
+
});
|
|
3068
|
+
|
|
3069
|
+
/**
|
|
3070
|
+
* Pick the scene to START with, given the registered scenes (in order) and the launch mode.
|
|
3071
|
+
*
|
|
3072
|
+
* Rules:
|
|
3073
|
+
* - On a replay launch, scenes flagged `skipOnReplay` are NOT eligible to start (they stay
|
|
3074
|
+
* registered for `goto`, they just aren't auto-started) — so a leading intro is skipped and
|
|
3075
|
+
* the game scene starts directly.
|
|
3076
|
+
* - An explicit `startScene` wins, but only if that scene is itself eligible; otherwise the first
|
|
3077
|
+
* eligible scene wins.
|
|
3078
|
+
* - Falls back to the first scene unconditionally if nothing is eligible (degenerate config).
|
|
3079
|
+
*/
|
|
3080
|
+
function resolveStartScene(scenes, isReplay, explicitStart) {
|
|
3081
|
+
const eligible = scenes.filter((s) => !(isReplay && s.skipOnReplay));
|
|
3082
|
+
if (explicitStart) {
|
|
3083
|
+
const ok = eligible.find((s) => s.key === explicitStart);
|
|
3084
|
+
if (ok)
|
|
3085
|
+
return ok.key;
|
|
3086
|
+
}
|
|
3087
|
+
return eligible[0]?.key ?? scenes[0]?.key;
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
var sceneStart = /*#__PURE__*/Object.freeze({
|
|
3091
|
+
__proto__: null,
|
|
3092
|
+
resolveStartScene: resolveStartScene
|
|
3093
|
+
});
|
|
3094
|
+
|
|
3095
|
+
// packages/game-engine/src/host/index.ts
|
|
3096
|
+
|
|
3097
|
+
/**
|
|
3098
|
+
* Enrich a normalized result with round-continuation metadata (roundId / nextActions / complete)
|
|
3099
|
+
* read from the raw play result, so a caller can drain the remaining segments of a multi-segment
|
|
3100
|
+
* round by replaying the SAME roundId. The game's normalizer stays focused on render data. Shared by
|
|
3101
|
+
* `createSlotPlay` (normal play) and the host's resume path (draining a recovered open round).
|
|
3102
|
+
*/
|
|
3103
|
+
function enrichRoundMeta(result, raw) {
|
|
3104
|
+
const meta = (raw ?? {});
|
|
3105
|
+
result.roundId = meta.roundId;
|
|
3106
|
+
result.nextActions = meta.nextActions;
|
|
3107
|
+
// A round is complete when there is no open session, or the session reports completed. The host
|
|
3108
|
+
// sets a session on every segment, so this is `session.completed` in practice.
|
|
3109
|
+
result.complete = !meta.session || meta.session.completed === true;
|
|
3110
|
+
return result;
|
|
3111
|
+
}
|
|
3112
|
+
/** Build the host play/ack pair. Host-agnostic wiring; unit-testable. The returned `play` stashes
|
|
3113
|
+
* the raw host result so the matching `ack()` can forward it to `deps.ack` (PlatformSession.playAck)
|
|
3114
|
+
* once the scene has finished animating. Plays are sequential (awaited), so a single stash is safe. */
|
|
3115
|
+
function createSlotPlay(deps) {
|
|
3116
|
+
let lastRaw = null;
|
|
3117
|
+
return {
|
|
3118
|
+
play: async (action, bet, roundId) => {
|
|
3119
|
+
const raw = await deps.play({ action, bet, roundId });
|
|
3120
|
+
lastRaw = raw;
|
|
3121
|
+
const result = enrichRoundMeta(deps.normalize(raw), raw);
|
|
3122
|
+
deps.onWin?.(result.totalWin);
|
|
3123
|
+
return result;
|
|
3124
|
+
},
|
|
3125
|
+
ack: () => {
|
|
3126
|
+
if (lastRaw != null)
|
|
3127
|
+
deps.ack?.(lastRaw);
|
|
3128
|
+
},
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
var slotPlay = /*#__PURE__*/Object.freeze({
|
|
3133
|
+
__proto__: null,
|
|
3134
|
+
createSlotPlay: createSlotPlay,
|
|
3135
|
+
enrichRoundMeta: enrichRoundMeta
|
|
3136
|
+
});
|
|
3137
|
+
|
|
3138
|
+
/**
|
|
3139
|
+
* Drive ONE round end-to-end: play the trigger, present it, ack; then drain the remaining segments
|
|
3140
|
+
* (a bonus's free spins) by replaying nextActions[0] with the SAME roundId until the round reports
|
|
3141
|
+
* `complete`. Fires `onBonusEnter` EXACTLY before the first free-role segment and `onBonusExit`
|
|
3142
|
+
* after the last. A plain spin with no bonus is already `complete`, so the while-loop is a no-op.
|
|
3143
|
+
*
|
|
3144
|
+
* `ctx.bet` is captured once (bet can't change mid-round); `ctx.turbo` is a live getter so a
|
|
3145
|
+
* mid-round toggle is honoured on the next segment.
|
|
3146
|
+
*/
|
|
3147
|
+
async function runRound(deps, action) {
|
|
3148
|
+
const ctx = deps.context(action);
|
|
3149
|
+
let r = await deps.play(action, ctx.bet);
|
|
3150
|
+
await deps.scene.present(r, ctx);
|
|
3151
|
+
deps.ack();
|
|
3152
|
+
deps.afterPresent?.(r); // HUD readouts update AFTER the animation, not before
|
|
3153
|
+
let inBonus = false;
|
|
3154
|
+
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
3155
|
+
const next = r.nextActions[0];
|
|
3156
|
+
if (!inBonus && deps.roleOf(next) === 'free') {
|
|
3157
|
+
inBonus = true;
|
|
3158
|
+
await deps.onBonusEnter?.(r, ctx);
|
|
3159
|
+
}
|
|
3160
|
+
// Snapshot the TRIGGER context per segment: { ... } freezes the live `turbo` getter into a data
|
|
3161
|
+
// property (so a mid-round toggle is reflected on the NEXT segment), while action/mode/bet stay
|
|
3162
|
+
// the round's (the trigger's) identity — a scene must see the same bonus identity all round.
|
|
3163
|
+
const segCtx = { ...deps.context(action) };
|
|
3164
|
+
r = await deps.play(next, ctx.bet, r.roundId);
|
|
3165
|
+
await deps.scene.present(r, segCtx);
|
|
3166
|
+
deps.ack();
|
|
3167
|
+
deps.afterPresent?.(r);
|
|
3168
|
+
}
|
|
3169
|
+
if (inBonus)
|
|
3170
|
+
await deps.onBonusExit?.(r, ctx);
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
var runRound$1 = /*#__PURE__*/Object.freeze({
|
|
3174
|
+
__proto__: null,
|
|
3175
|
+
runRound: runRound
|
|
3176
|
+
});
|
|
3177
|
+
|
|
3178
|
+
function createBalanceGate(paint, initial = 0) {
|
|
3179
|
+
let latest = initial;
|
|
3180
|
+
let suppressed = false;
|
|
3181
|
+
return {
|
|
3182
|
+
onBalance(amount) {
|
|
3183
|
+
latest = amount;
|
|
3184
|
+
if (!suppressed)
|
|
3185
|
+
paint(amount);
|
|
3186
|
+
},
|
|
3187
|
+
beginPlay() {
|
|
3188
|
+
suppressed = true;
|
|
3189
|
+
},
|
|
3190
|
+
afterPresent() {
|
|
3191
|
+
paint(latest);
|
|
3192
|
+
suppressed = false;
|
|
3193
|
+
},
|
|
3194
|
+
get balance() {
|
|
3195
|
+
return latest;
|
|
3196
|
+
},
|
|
3197
|
+
};
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
var balanceGate = /*#__PURE__*/Object.freeze({
|
|
3201
|
+
__proto__: null,
|
|
3202
|
+
createBalanceGate: createBalanceGate
|
|
3203
|
+
});
|
|
3204
|
+
|
|
3205
|
+
function createFreeSpinsCounter() {
|
|
3206
|
+
let total = 0;
|
|
3207
|
+
let current = 0;
|
|
3208
|
+
return {
|
|
3209
|
+
enter(awarded) {
|
|
3210
|
+
total = awarded;
|
|
3211
|
+
current = 0;
|
|
3212
|
+
return { current, total, totalWin: 0 };
|
|
3213
|
+
},
|
|
3214
|
+
spin(awarded, totalWin) {
|
|
3215
|
+
current += 1;
|
|
3216
|
+
total += awarded; // a retrigger grows the pool
|
|
3217
|
+
return { current, total, totalWin };
|
|
3218
|
+
},
|
|
3219
|
+
};
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
var freeSpinsCounter = /*#__PURE__*/Object.freeze({
|
|
3223
|
+
__proto__: null,
|
|
3224
|
+
createFreeSpinsCounter: createFreeSpinsCounter
|
|
3225
|
+
});
|
|
3226
|
+
|
|
3227
|
+
/** Pull a Stake/SDK error code off an unknown thrown value. */
|
|
3228
|
+
function errorCode(err) {
|
|
3229
|
+
const code = err?.code;
|
|
3230
|
+
return typeof code === 'string' ? code : undefined;
|
|
3231
|
+
}
|
|
3232
|
+
function resolvePlayError(err) {
|
|
3233
|
+
const code = errorCode(err);
|
|
3234
|
+
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
|
3235
|
+
switch (code) {
|
|
3236
|
+
case 'ACTIVE_SESSION_EXISTS':
|
|
3237
|
+
return {
|
|
3238
|
+
title: 'Round in progress',
|
|
3239
|
+
body: 'You have an unfinished round. Reload to resume it.',
|
|
3240
|
+
reload: true,
|
|
3241
|
+
};
|
|
3242
|
+
case 'NO_ACTIVE_SESSION':
|
|
3243
|
+
return {
|
|
3244
|
+
title: 'Round expired',
|
|
3245
|
+
body: 'This round is no longer active. Reload to continue.',
|
|
3246
|
+
reload: true,
|
|
3247
|
+
};
|
|
3248
|
+
case 'INSUFFICIENT_FUNDS':
|
|
3249
|
+
return {
|
|
3250
|
+
title: 'Insufficient balance',
|
|
3251
|
+
body: 'You don’t have enough balance for this bet. Lower your bet or top up.',
|
|
3252
|
+
reload: false,
|
|
3253
|
+
};
|
|
3254
|
+
case 'TIMEOUT':
|
|
3255
|
+
return {
|
|
3256
|
+
title: 'Connection timed out',
|
|
3257
|
+
body: 'The game server did not respond in time. Please try again.',
|
|
3258
|
+
reload: false,
|
|
3259
|
+
};
|
|
3260
|
+
default:
|
|
3261
|
+
// Unknown code: surface the server message verbatim under a generic heading (never the
|
|
3262
|
+
// connection overlay), so an operator can diagnose without a code change.
|
|
3263
|
+
return {
|
|
3264
|
+
title: 'Game error',
|
|
3265
|
+
body: message || 'Something went wrong. Please reload the game.',
|
|
3266
|
+
reload: true,
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
var playError = /*#__PURE__*/Object.freeze({
|
|
3272
|
+
__proto__: null,
|
|
3273
|
+
errorCode: errorCode,
|
|
3274
|
+
resolvePlayError: resolvePlayError
|
|
3275
|
+
});
|
|
3276
|
+
|
|
3277
|
+
function createAutoplayLoop(deps) {
|
|
3278
|
+
let active = false;
|
|
3279
|
+
let remaining = 0;
|
|
3280
|
+
let running = false; // guards against a second concurrent loop
|
|
3281
|
+
const stop = () => {
|
|
3282
|
+
if (!active && remaining === 0)
|
|
3283
|
+
return;
|
|
3284
|
+
active = false;
|
|
3285
|
+
remaining = 0;
|
|
3286
|
+
deps.onState({ active: false, remaining: 0 });
|
|
3287
|
+
};
|
|
3288
|
+
async function loop() {
|
|
3289
|
+
if (running)
|
|
3290
|
+
return;
|
|
3291
|
+
running = true;
|
|
3292
|
+
try {
|
|
3293
|
+
while (active && remaining > 0) {
|
|
3294
|
+
const action = deps.resolveAction();
|
|
3295
|
+
if (!deps.canAfford(action)) {
|
|
3296
|
+
stop();
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
// Decrement at spin START (so the spin in flight is `total − remaining`), then play it out.
|
|
3300
|
+
remaining -= 1;
|
|
3301
|
+
deps.onState({ active: true, remaining });
|
|
3302
|
+
try {
|
|
3303
|
+
await deps.playRound(action);
|
|
3304
|
+
}
|
|
3305
|
+
catch {
|
|
3306
|
+
stop(); // a play error already surfaced its own modal — just halt the run
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
if (active)
|
|
3311
|
+
stop(); // ran the budget out
|
|
3312
|
+
}
|
|
3313
|
+
finally {
|
|
3314
|
+
running = false;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
return {
|
|
3318
|
+
start(count) {
|
|
3319
|
+
if (active || running || count <= 0)
|
|
3320
|
+
return;
|
|
3321
|
+
active = true;
|
|
3322
|
+
remaining = count;
|
|
3323
|
+
deps.onState({ active: true, remaining });
|
|
3324
|
+
void loop();
|
|
3325
|
+
},
|
|
3326
|
+
stop,
|
|
3327
|
+
get active() { return active; },
|
|
3328
|
+
get remaining() { return remaining; },
|
|
3329
|
+
};
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
var autoplay = /*#__PURE__*/Object.freeze({
|
|
3333
|
+
__proto__: null,
|
|
3334
|
+
createAutoplayLoop: createAutoplayLoop
|
|
3335
|
+
});
|
|
3336
|
+
|
|
3337
|
+
Object.defineProperty(exports, "socialize", {
|
|
3338
|
+
enumerable: true,
|
|
3339
|
+
get: function () { return shell.socialize; }
|
|
3340
|
+
});
|
|
3341
|
+
exports.buildShellConfig = buildShellConfig;
|
|
3342
|
+
exports.createSlotGame = createSlotGame;
|
|
3343
|
+
exports.resolveReplayBonusId = resolveReplayBonusId;
|
|
3344
|
+
exports.resolveStartScene = resolveStartScene;
|
|
3345
|
+
exports.stakeForAction = stakeForAction;
|
|
3346
|
+
//# sourceMappingURL=host.cjs.js.map
|