@energy8platform/game-engine 0.28.0 → 0.30.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/host.cjs.js +144 -36
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +49 -0
- package/dist/host.esm.js +144 -36
- package/dist/host.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/host/buildConfig.ts +3 -2
- package/src/host/createSlotGame.ts +189 -43
- package/src/host/overlayController.ts +5 -1
- package/src/host/sceneController.ts +4 -0
- package/src/host/types.ts +24 -1
|
@@ -8,6 +8,7 @@ import type { CreateSlotGameOptions, SlotGameHandle } from './types';
|
|
|
8
8
|
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
9
9
|
import type { ShellMode } from '@energy8platform/shell/pixi';
|
|
10
10
|
import type { SceneApi, SlotSceneController } from './sceneController';
|
|
11
|
+
import type { FreeSpinsView } from './freeSpinsCounter';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
|
|
@@ -37,7 +38,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
37
38
|
availableClose: false,
|
|
38
39
|
title: shell.t('Something went wrong'),
|
|
39
40
|
body: shell.t(message),
|
|
40
|
-
actions: [
|
|
41
|
+
actions: [
|
|
42
|
+
{
|
|
43
|
+
title: shell.t('Reload'),
|
|
44
|
+
on: () => {
|
|
45
|
+
try {
|
|
46
|
+
location.reload();
|
|
47
|
+
} catch {
|
|
48
|
+
/* non-browser */
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
41
53
|
});
|
|
42
54
|
return;
|
|
43
55
|
}
|
|
@@ -61,7 +73,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
61
73
|
const launch = classifyStakeLaunch(location.href);
|
|
62
74
|
if (launch === 'blocked') {
|
|
63
75
|
fatal('Invalid game server address. Please relaunch the game from the lobby.');
|
|
64
|
-
throw new Error(
|
|
76
|
+
throw new Error(
|
|
77
|
+
'createSlotGame: refusing to run — Stake launch with a missing or invalid rgs_url',
|
|
78
|
+
);
|
|
65
79
|
}
|
|
66
80
|
isStakeNow = launch === 'stake';
|
|
67
81
|
if (isStakeNow) {
|
|
@@ -149,7 +163,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
149
163
|
// ACK the result AFTER the scene animates it (the scene calls host.ack()). On Stake this
|
|
150
164
|
// triggers /wallet/end-round so a winning round settles post-animation instead of staying
|
|
151
165
|
// open and blocking the next spin.
|
|
152
|
-
ack: (raw) =>
|
|
166
|
+
ack: (raw) =>
|
|
167
|
+
game.platformSession!.playAck(raw as import('@energy8platform/platform-core').PlayResultData),
|
|
153
168
|
});
|
|
154
169
|
|
|
155
170
|
if (opts.shell) {
|
|
@@ -187,7 +202,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
187
202
|
try {
|
|
188
203
|
const { lookupCurrency } = await import('@energy8platform/stake-bridge');
|
|
189
204
|
currencyMeta = lookupCurrency(opts.model.spec.currency);
|
|
190
|
-
} catch {
|
|
205
|
+
} catch {
|
|
206
|
+
/* stake-bridge not installed — resolveCurrency falls back to the code */
|
|
207
|
+
}
|
|
191
208
|
}
|
|
192
209
|
const runtime = {
|
|
193
210
|
balance,
|
|
@@ -209,14 +226,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
209
226
|
const cc = config?.currency as { code?: string; symbol?: string } | undefined;
|
|
210
227
|
console.info(
|
|
211
228
|
`[e8] currency → bridge.code=${cc?.code ?? '∅'} bridge.symbol=${cc?.symbol ?? '∅'} ` +
|
|
212
|
-
|
|
213
|
-
|
|
229
|
+
`| spec=${opts.model.spec.currency ?? '∅'} ` +
|
|
230
|
+
`| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`,
|
|
214
231
|
);
|
|
215
232
|
}
|
|
216
233
|
// pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
|
|
217
234
|
// scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
|
|
218
235
|
// The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
|
|
219
|
-
const pixiShellCfg: import('@energy8platform/shell/pixi').PixiShellConfig = {
|
|
236
|
+
const pixiShellCfg: import('@energy8platform/shell/pixi').PixiShellConfig = {
|
|
237
|
+
...buildShellConfig(opts.shell, opts.model, runtime),
|
|
238
|
+
app: game.app,
|
|
239
|
+
parent: game.uiLayer,
|
|
240
|
+
};
|
|
220
241
|
// The game may swap in its own shell (a custom renderer over the same core) via shellFactory;
|
|
221
242
|
// default is the built-in Pixi shell. The host drives whichever it gets through the Shell contract.
|
|
222
243
|
shell = (opts.shellFactory ?? createPixiShell)(pixiShellCfg);
|
|
@@ -229,21 +250,34 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
229
250
|
// async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
|
|
230
251
|
// is the single source for both the displayed balance and `ensureAffordable`.
|
|
231
252
|
const balanceGate = createBalanceGate((b) => shell!.setBalance(b), balance);
|
|
232
|
-
ps?.on('balanceUpdate', (d: { balance: number }) => {
|
|
253
|
+
ps?.on('balanceUpdate', (d: { balance: number }) => {
|
|
254
|
+
balanceGate.onBalance(d.balance);
|
|
255
|
+
});
|
|
233
256
|
|
|
234
257
|
// Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
|
|
235
258
|
let currentTurbo = shell.state.turbo;
|
|
236
|
-
shell.on('turboChange', (level: number) => {
|
|
259
|
+
shell.on('turboChange', (level: number) => {
|
|
260
|
+
currentTurbo = level;
|
|
261
|
+
gameScene()?.onTurboChanged?.(level);
|
|
262
|
+
});
|
|
237
263
|
// Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
|
|
238
264
|
const skipEnabled = opts.skipGesture ?? true;
|
|
239
265
|
|
|
240
266
|
// Shell settings → engine state. Sound/volume map onto the AudioManager.
|
|
241
267
|
shell.on('settingChange', ({ key, value }: { key: string; value: unknown }) => {
|
|
242
268
|
switch (key) {
|
|
243
|
-
case 'sound':
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
case '
|
|
269
|
+
case 'sound':
|
|
270
|
+
value ? game.audio.unmuteAll() : game.audio.muteAll();
|
|
271
|
+
break;
|
|
272
|
+
case 'master':
|
|
273
|
+
game.audio.setMasterVolume(Number(value));
|
|
274
|
+
break;
|
|
275
|
+
case 'music':
|
|
276
|
+
game.audio.setVolume('music', Number(value));
|
|
277
|
+
break;
|
|
278
|
+
case 'sfx':
|
|
279
|
+
game.audio.setVolume('sfx', Number(value));
|
|
280
|
+
break;
|
|
247
281
|
}
|
|
248
282
|
});
|
|
249
283
|
|
|
@@ -267,11 +301,21 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
267
301
|
sceneApi = {
|
|
268
302
|
audio: createSceneAudio(game.audio),
|
|
269
303
|
overlay: overlayCtl.overlay,
|
|
270
|
-
shell: {
|
|
304
|
+
shell: {
|
|
305
|
+
get safeArea() {
|
|
306
|
+
return shell!.safeArea;
|
|
307
|
+
},
|
|
308
|
+
},
|
|
271
309
|
formatAmount: (v) => shell!.formatWin(v),
|
|
272
|
-
get bet() {
|
|
273
|
-
|
|
274
|
-
|
|
310
|
+
get bet() {
|
|
311
|
+
return currentBet;
|
|
312
|
+
},
|
|
313
|
+
get mode() {
|
|
314
|
+
return opts.model.modeMap['spin'] ?? 'BASE';
|
|
315
|
+
},
|
|
316
|
+
get turbo() {
|
|
317
|
+
return currentTurbo;
|
|
318
|
+
},
|
|
275
319
|
};
|
|
276
320
|
|
|
277
321
|
const roleOf = (action: string) => opts.model.spec.actions[action]?.role;
|
|
@@ -284,7 +328,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
284
328
|
action,
|
|
285
329
|
mode: opts.model.modeMap[action] ?? action.toUpperCase(),
|
|
286
330
|
formatAmount: (v) => shell!.formatWin(v),
|
|
287
|
-
get turbo() {
|
|
331
|
+
get turbo() {
|
|
332
|
+
return currentTurbo;
|
|
333
|
+
},
|
|
288
334
|
});
|
|
289
335
|
// Play-error + connection handling. A play rejection is classified into a player-facing modal
|
|
290
336
|
// (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
|
|
@@ -300,12 +346,33 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
300
346
|
title: shell!.t(v.title),
|
|
301
347
|
body: shell!.t(v.body),
|
|
302
348
|
actions: v.reload
|
|
303
|
-
? [
|
|
304
|
-
|
|
349
|
+
? [
|
|
350
|
+
{
|
|
351
|
+
title: shell!.t('Reload'),
|
|
352
|
+
on: () => {
|
|
353
|
+
try {
|
|
354
|
+
window.location.reload();
|
|
355
|
+
} catch {
|
|
356
|
+
/* non-browser */
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
]
|
|
361
|
+
: [
|
|
362
|
+
{
|
|
363
|
+
title: shell!.t('OK'),
|
|
364
|
+
on: () => {
|
|
365
|
+
playErrorOpen = false;
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
],
|
|
305
369
|
});
|
|
306
370
|
};
|
|
307
371
|
ps?.on('connectionStateChanged', (s: { status: string }) => {
|
|
308
|
-
if (s.status === 'restored') {
|
|
372
|
+
if (s.status === 'restored') {
|
|
373
|
+
if (!playErrorOpen) shell!.closeModal();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
309
376
|
if (playErrorOpen) return; // a play-error modal owns the screen — don't mask it with "reconnecting"
|
|
310
377
|
shell!.openModal({
|
|
311
378
|
availableClose: false,
|
|
@@ -327,7 +394,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
327
394
|
const skip = createDoubleTapSkip({
|
|
328
395
|
enabled: () => skipEnabled,
|
|
329
396
|
active: () => presenting,
|
|
330
|
-
onSkip: () => {
|
|
397
|
+
onSkip: () => {
|
|
398
|
+
currentSegmentAbort?.abort();
|
|
399
|
+
gameScene()?.onSkip?.();
|
|
400
|
+
},
|
|
331
401
|
});
|
|
332
402
|
// Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
|
|
333
403
|
// lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
|
|
@@ -346,9 +416,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
346
416
|
return () => document.removeEventListener('visibilitychange', cb);
|
|
347
417
|
},
|
|
348
418
|
onHidden: () => {
|
|
349
|
-
game.app.ticker.stop();
|
|
350
|
-
game.audio.duckMusic(0);
|
|
351
|
-
stopAutoplay();
|
|
419
|
+
game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
|
|
420
|
+
game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
|
|
421
|
+
stopAutoplay(); // hold autoplay — don't start the next auto-round
|
|
352
422
|
gameScene()?.onPause?.();
|
|
353
423
|
},
|
|
354
424
|
onVisible: () => {
|
|
@@ -358,6 +428,28 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
358
428
|
},
|
|
359
429
|
});
|
|
360
430
|
|
|
431
|
+
/** Push a bonus segment's readout to the shell bar. Default (no `opts.bonus`) = the free-spins
|
|
432
|
+
* counter (label 'Free spins', value current/total). With `opts.bonus`, the game supplies the
|
|
433
|
+
* label + value string (adventure / hold-and-spin / respins) and we drive the generic 'bonus'
|
|
434
|
+
* hero instead — the shell stays free of any per-game bonus concept. */
|
|
435
|
+
const applyBonusReadout = (result: T, view: FreeSpinsView, mode: string): void => {
|
|
436
|
+
const b = opts.bonus;
|
|
437
|
+
if (!b) {
|
|
438
|
+
shell!.setFreeSpins(view);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const label = typeof b.label === 'function' ? b.label(mode) : (b.label ?? 'Free spins');
|
|
442
|
+
const value = b.readout
|
|
443
|
+
? b.readout(result, { view, mode })
|
|
444
|
+
: view.current == null
|
|
445
|
+
? String(view.total)
|
|
446
|
+
: `${view.current} / ${view.total}`;
|
|
447
|
+
shell!.setBonus({ label, value, totalWin: view.totalWin });
|
|
448
|
+
};
|
|
449
|
+
/** The mode entered via setMode for a bonus — 'bonus' when the game customises the readout,
|
|
450
|
+
* else 'freeSpins' (the back-compat default the shell already renders). */
|
|
451
|
+
const bonusShellMode: ShellMode = opts.bonus ? 'bonus' : 'freeSpins';
|
|
452
|
+
|
|
361
453
|
/** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
|
|
362
454
|
* update only AFTER each onSpin(), per the HUD-timing requirement. */
|
|
363
455
|
const playRound = (action: string) => {
|
|
@@ -367,6 +459,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
367
459
|
// (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
|
|
368
460
|
// the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
|
|
369
461
|
let inBonus = false;
|
|
462
|
+
let bonusMode = ''; // the mode string captured on bonus-enter, reused for each settled spin
|
|
370
463
|
let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
|
|
371
464
|
const fsCounter = createFreeSpinsCounter();
|
|
372
465
|
shell!.setBusy(true); // block re-spin / spacebar while the round plays out
|
|
@@ -376,13 +469,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
376
469
|
return runRound<T>(
|
|
377
470
|
{
|
|
378
471
|
// Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
|
|
379
|
-
play: (a, b, rid) => {
|
|
472
|
+
play: (a, b, rid) => {
|
|
473
|
+
balanceGate.beginPlay();
|
|
474
|
+
return slotPlay.play(a, b, rid);
|
|
475
|
+
},
|
|
380
476
|
ack: slotPlay.ack,
|
|
381
477
|
scene,
|
|
382
478
|
context: makeContext,
|
|
383
479
|
roleOf,
|
|
384
480
|
// Hand the host the per-segment AbortController so a double-tap can skip the live segment.
|
|
385
|
-
beforeSegment: (ac) => {
|
|
481
|
+
beforeSegment: (ac) => {
|
|
482
|
+
currentSegmentAbort = ac;
|
|
483
|
+
},
|
|
386
484
|
onSpinStart: () => scene.onSpinStart?.(),
|
|
387
485
|
onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
|
|
388
486
|
afterPresent: (r) => {
|
|
@@ -391,12 +489,22 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
391
489
|
shell!.setWin(r.totalWin - prevWin);
|
|
392
490
|
prevWin = r.totalWin;
|
|
393
491
|
balanceGate.afterPresent();
|
|
394
|
-
if (inBonus)
|
|
492
|
+
if (inBonus)
|
|
493
|
+
applyBonusReadout(
|
|
494
|
+
r,
|
|
495
|
+
fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin),
|
|
496
|
+
bonusMode,
|
|
497
|
+
);
|
|
395
498
|
},
|
|
396
499
|
onEnterMode: async (trigger, ctx) => {
|
|
397
500
|
inBonus = true;
|
|
398
|
-
|
|
399
|
-
shell!.
|
|
501
|
+
bonusMode = ctx.mode;
|
|
502
|
+
shell!.setMode(bonusShellMode);
|
|
503
|
+
applyBonusReadout(
|
|
504
|
+
trigger,
|
|
505
|
+
fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0),
|
|
506
|
+
bonusMode,
|
|
507
|
+
);
|
|
400
508
|
await scene.onEnterMode?.(trigger, ctx);
|
|
401
509
|
},
|
|
402
510
|
onExitMode: async (last, ctx) => {
|
|
@@ -406,7 +514,12 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
406
514
|
},
|
|
407
515
|
},
|
|
408
516
|
action,
|
|
409
|
-
)
|
|
517
|
+
)
|
|
518
|
+
.catch(showPlayError)
|
|
519
|
+
.finally(() => {
|
|
520
|
+
presenting = false;
|
|
521
|
+
shell!.setBusy(false);
|
|
522
|
+
});
|
|
410
523
|
};
|
|
411
524
|
|
|
412
525
|
/**
|
|
@@ -444,9 +557,15 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
444
557
|
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
445
558
|
const applySegment = async (): Promise<void> => {
|
|
446
559
|
// A recovered open round with remaining segments is a bonus → show FS mode + counter.
|
|
447
|
-
if (!inBonus && !r.complete) {
|
|
560
|
+
if (!inBonus && !r.complete) {
|
|
561
|
+
inBonus = true;
|
|
562
|
+
shell!.setMode(bonusShellMode);
|
|
563
|
+
}
|
|
448
564
|
if (animate) await scene.onSpin(r, ctx);
|
|
449
|
-
if (inBonus) {
|
|
565
|
+
if (inBonus) {
|
|
566
|
+
const v = fsView(raw, r.totalWin);
|
|
567
|
+
if (v) applyBonusReadout(r, v, ctx.mode);
|
|
568
|
+
}
|
|
450
569
|
shell!.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
451
570
|
prevWin = r.totalWin;
|
|
452
571
|
ps!.playAck(raw); // settles via /wallet/end-round on the FINAL segment
|
|
@@ -455,8 +574,11 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
455
574
|
try {
|
|
456
575
|
await applySegment();
|
|
457
576
|
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
458
|
-
raw = (await ps.play({
|
|
459
|
-
|
|
577
|
+
raw = (await ps.play({
|
|
578
|
+
action: r.nextActions[0],
|
|
579
|
+
bet: ctx.bet,
|
|
580
|
+
roundId: r.roundId,
|
|
581
|
+
})) as import('@energy8platform/platform-core').PlayResultData;
|
|
460
582
|
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
461
583
|
await applySegment();
|
|
462
584
|
}
|
|
@@ -468,13 +590,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
468
590
|
|
|
469
591
|
if (mode === 'base') {
|
|
470
592
|
let activeFeature: string | null = null;
|
|
471
|
-
shell.on('featureActivate', ({ id }: { id: string }) => {
|
|
472
|
-
|
|
593
|
+
shell.on('featureActivate', ({ id }: { id: string }) => {
|
|
594
|
+
activeFeature = id;
|
|
595
|
+
});
|
|
596
|
+
shell.on('featureDeactivate', ({ id: _id }: { id: string }) => {
|
|
597
|
+
activeFeature = null;
|
|
598
|
+
});
|
|
473
599
|
|
|
474
600
|
const { stakeForAction } = await import('./shellConfig');
|
|
475
601
|
// Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
|
|
476
602
|
const ensureAffordable = (action: string): boolean => {
|
|
477
|
-
if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9)
|
|
603
|
+
if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9)
|
|
604
|
+
return true;
|
|
478
605
|
shell!.openModal({
|
|
479
606
|
availableClose: true,
|
|
480
607
|
title: shell!.t('Insufficient balance'),
|
|
@@ -489,7 +616,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
489
616
|
if (!ensureAffordable(action)) return;
|
|
490
617
|
void playRound(action);
|
|
491
618
|
});
|
|
492
|
-
shell.on('betChange', (bet: number) => {
|
|
619
|
+
shell.on('betChange', (bet: number) => {
|
|
620
|
+
currentBet = bet;
|
|
621
|
+
gameScene()?.onBetChanged?.(bet);
|
|
622
|
+
});
|
|
493
623
|
shell.on('buyBonusSelect', ({ id }: { id: string }) => {
|
|
494
624
|
if (!ensureAffordable(id)) return;
|
|
495
625
|
void playRound(id);
|
|
@@ -519,7 +649,11 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
519
649
|
if (resumeOffered || !shell || !gameScene()) return;
|
|
520
650
|
resumeOffered = true;
|
|
521
651
|
let snap: import('@energy8platform/platform-core').PlayResultData | null = null;
|
|
522
|
-
try {
|
|
652
|
+
try {
|
|
653
|
+
snap = (await ps?.getState()) ?? null;
|
|
654
|
+
} catch {
|
|
655
|
+
snap = null;
|
|
656
|
+
}
|
|
523
657
|
if (!snap) return;
|
|
524
658
|
shell.openModal({
|
|
525
659
|
availableClose: false,
|
|
@@ -527,13 +661,25 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
527
661
|
body: shell.t('You have an unfinished round. Continue it or finish it now?'),
|
|
528
662
|
actions: [
|
|
529
663
|
// Continue: replay the round from the start with animation, then settle.
|
|
530
|
-
{
|
|
664
|
+
{
|
|
665
|
+
title: shell.t('Continue'),
|
|
666
|
+
on: () => {
|
|
667
|
+
void resumeDrain(snap!, true);
|
|
668
|
+
},
|
|
669
|
+
},
|
|
531
670
|
// Finish: fast-forward the remaining segments (no animation) to settle the win now.
|
|
532
|
-
{
|
|
671
|
+
{
|
|
672
|
+
title: shell.t('Finish'),
|
|
673
|
+
on: () => {
|
|
674
|
+
void resumeDrain(snap!, false);
|
|
675
|
+
},
|
|
676
|
+
},
|
|
533
677
|
],
|
|
534
678
|
});
|
|
535
679
|
};
|
|
536
|
-
game.scenes.on('change', () => {
|
|
680
|
+
game.scenes.on('change', () => {
|
|
681
|
+
void offerResume();
|
|
682
|
+
});
|
|
537
683
|
void offerResume();
|
|
538
684
|
} else {
|
|
539
685
|
const stakeMode = stakeBridge?.replayMode ?? 'BASE';
|
|
@@ -10,6 +10,9 @@ interface OverlayDeps {
|
|
|
10
10
|
|
|
11
11
|
interface ActiveOverlay {
|
|
12
12
|
layer: Container;
|
|
13
|
+
/** The scene-owned content container from `build` — re-laid-out on resize. */
|
|
14
|
+
content: Container;
|
|
15
|
+
onResize: OverlayShowOptions['onResize'];
|
|
13
16
|
resolve(): void;
|
|
14
17
|
timer: ReturnType<typeof setTimeout> | null;
|
|
15
18
|
dim: number;
|
|
@@ -58,7 +61,7 @@ export function createOverlayController(deps: OverlayDeps): {
|
|
|
58
61
|
|
|
59
62
|
return new Promise<void>((resolve) => {
|
|
60
63
|
const dimValue = opts.dim ?? 0.0001;
|
|
61
|
-
current = { layer, resolve, timer: null, dim: dimValue };
|
|
64
|
+
current = { layer, content, onResize: opts.onResize, resolve, timer: null, dim: dimValue };
|
|
62
65
|
const closeOn = opts.closeOn ?? 'tap';
|
|
63
66
|
if (closeOn === 'tap') hit.on('pointertap', teardown);
|
|
64
67
|
if (typeof opts.autoCloseMs === 'number') {
|
|
@@ -75,6 +78,7 @@ export function createOverlayController(deps: OverlayDeps): {
|
|
|
75
78
|
if (!current) return;
|
|
76
79
|
const hit = current.layer.getChildAt(0) as Graphics;
|
|
77
80
|
hit.clear().rect(0, 0, w, h).fill({ color: 0x000000, alpha: current.dim });
|
|
81
|
+
current.onResize?.(current.content, { width: w, height: h });
|
|
78
82
|
},
|
|
79
83
|
destroy(): void { teardown(); },
|
|
80
84
|
};
|
|
@@ -30,6 +30,10 @@ export interface SceneAudio {
|
|
|
30
30
|
export interface OverlayShowOptions {
|
|
31
31
|
/** Draw the overlay content into `container` (sized to the canvas). */
|
|
32
32
|
build(container: Container, size: { width: number; height: number }): void;
|
|
33
|
+
/** Re-layout the same `container` when the canvas resizes while the overlay is open.
|
|
34
|
+
* Receives the content container from `build` and the new size. Optional — omit for
|
|
35
|
+
* overlays that self-center or don't care about resize. */
|
|
36
|
+
onResize?(container: Container, size: { width: number; height: number }): void;
|
|
33
37
|
/** Auto-close after N ms (combine with closeOn — whichever fires first). */
|
|
34
38
|
autoCloseMs?: number;
|
|
35
39
|
/** Dismiss on a single tap. Default 'tap'. Set false to require an explicit close(). */
|
package/src/host/types.ts
CHANGED
|
@@ -7,7 +7,27 @@ import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../t
|
|
|
7
7
|
import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
|
|
8
8
|
import type { GameApplication } from '../core';
|
|
9
9
|
import type { SlotShellOptions } from './shellConfig';
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
SlotSpinResultBase,
|
|
12
|
+
SlotResultNormalizer,
|
|
13
|
+
} from '@energy8platform/platform-core/slot-result';
|
|
14
|
+
import type { FreeSpinsView } from './freeSpinsCounter';
|
|
15
|
+
|
|
16
|
+
/** Turns a bonus segment into the bar readout for games whose bonus ISN'T a plain free-spins
|
|
17
|
+
* counter (adventure, hold-and-spin, respins). The shell shows a host-driven hero + Total Win in
|
|
18
|
+
* ANY bonus; this only customises the label + counter VALUE. Omit `bonus` entirely and the host
|
|
19
|
+
* falls back to the free-spins default (label 'Free spins', value current/total, retrigger-aware). */
|
|
20
|
+
export interface BonusReadoutConfig<T extends SlotSpinResultBase = SlotSpinResultBase> {
|
|
21
|
+
/** Bar label (localized by the shell, so a game i18n entry is honoured). A string, or a function
|
|
22
|
+
* of the current mode (e.g. `m => m === 'ADVENTURE' ? 'Adventure' : 'Free spins'`).
|
|
23
|
+
* Default: 'Free spins'. */
|
|
24
|
+
label?: string | ((mode: string) => string);
|
|
25
|
+
/** Format the counter VALUE string from the settled segment. `view` is the host's default
|
|
26
|
+
* free-spins counter (current/total, retrigger-aware) — use it for the common case, or ignore it
|
|
27
|
+
* and read your own fields off `result` (respins left, coins collected, a multiplier).
|
|
28
|
+
* Default: `view.current == null ? String(view.total) : `${view.current} / ${view.total}``. */
|
|
29
|
+
readout?: (result: T, ctx: { view: FreeSpinsView; mode: string }) => string;
|
|
30
|
+
}
|
|
11
31
|
|
|
12
32
|
export interface StakeIntegration {
|
|
13
33
|
/** The game's BookAdapter (or its module). modeMap + gameId come from the model. */
|
|
@@ -58,6 +78,9 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
|
|
|
58
78
|
dev?: boolean;
|
|
59
79
|
stake?: StakeIntegration;
|
|
60
80
|
shell?: SlotShellOptions;
|
|
81
|
+
/** Customise the bonus bar readout for games whose bonus isn't plain free spins (adventure,
|
|
82
|
+
* hold-and-spin, respins). Omit for the free-spins default. See `BonusReadoutConfig`. */
|
|
83
|
+
bonus?: BonusReadoutConfig<T>;
|
|
61
84
|
/** Override how the control-bar shell is built. The host resolves the full shell config (theme,
|
|
62
85
|
* features, gameInfo, currency, balance) and the Pixi mount (`app`/`parent`) and hands it to this
|
|
63
86
|
* factory; return any `Shell` — e.g. `createShell({ renderer: new MyRenderer(...), ...config })`
|