@energy8platform/game-engine 0.18.0 → 0.20.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/audio.cjs.js +15 -5
- package/dist/audio.cjs.js.map +1 -1
- package/dist/audio.d.ts +5 -0
- package/dist/audio.esm.js +15 -5
- package/dist/audio.esm.js.map +1 -1
- package/dist/core.cjs.js +79 -174
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +13 -1
- package/dist/core.esm.js +80 -175
- package/dist/core.esm.js.map +1 -1
- package/dist/host.cjs.js +401 -251
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +117 -26
- package/dist/host.esm.js +403 -253
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +79 -174
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +23 -12
- package/dist/index.esm.js +80 -175
- package/dist/index.esm.js.map +1 -1
- package/dist/react.d.ts +13 -1
- package/package.json +3 -2
- package/src/audio/AudioManager.ts +17 -5
- package/src/core/GameApplication.ts +28 -10
- package/src/host/createSlotGame.ts +143 -23
- package/src/host/index.ts +4 -1
- package/src/host/overlayController.ts +81 -0
- package/src/host/pauseController.ts +21 -0
- package/src/host/runRound.ts +35 -43
- package/src/host/sceneAudio.ts +14 -0
- package/src/host/sceneController.ts +84 -19
- package/src/host/shellConfig.ts +53 -35
- package/src/host/skipGesture.ts +24 -0
- package/src/host/types.ts +5 -2
- package/src/loading/LoadingScene.ts +31 -174
- package/src/viewport/ViewportManager.ts +19 -9
package/src/host/runRound.ts
CHANGED
|
@@ -1,63 +1,55 @@
|
|
|
1
1
|
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
2
2
|
import type { RenderContext, SlotSceneController } from './sceneController';
|
|
3
3
|
|
|
4
|
-
/** Injected dependencies for one round. All host-agnostic + unit-testable. */
|
|
5
4
|
export interface RunRoundDeps<T extends SlotSpinResultBase> {
|
|
6
|
-
/** play → normalize → enrich (roundId/nextActions/complete). From createSlotPlay. */
|
|
7
5
|
play(action: string, bet: number, roundId?: string): Promise<T>;
|
|
8
|
-
/** Settle the most recent result (post-animation). From createSlotPlay. */
|
|
9
6
|
ack(): void;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
context(action: string): RenderContext;
|
|
14
|
-
/** Role of an action from the spec ('base'|'buy'|'feature'|'free'); drives bonus detection. */
|
|
7
|
+
scene: Pick<SlotSceneController<T>, 'onSpin'>;
|
|
8
|
+
/** Build the per-round render context (without signal — runRound injects it per segment). */
|
|
9
|
+
context(action: string): Omit<RenderContext, 'signal'> & { signal?: AbortSignal };
|
|
15
10
|
roleOf(action: string): string | undefined;
|
|
16
|
-
/** Fires after each segment is presented + acked. The host updates HUD readouts (win/balance)
|
|
17
|
-
* here so they change WITH the animation, never eagerly when the play result arrives. */
|
|
18
11
|
afterPresent?(result: T): void;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
/** Once, before the first segment is played (player pressed spin). */
|
|
13
|
+
onSpinStart?(): void;
|
|
14
|
+
/** Once, after the full drain. */
|
|
15
|
+
onSpinEnd?(last: T, ctx: RenderContext): void;
|
|
16
|
+
/** Fires when entering a non-BASE mode (first free segment). */
|
|
17
|
+
onEnterMode?(trigger: T, ctx: RenderContext): Promise<void>;
|
|
18
|
+
/** Fires after the last segment of a mode. */
|
|
19
|
+
onExitMode?(last: T, ctx: RenderContext): Promise<void>;
|
|
20
|
+
/** Hands the host the AbortController for the segment about to present (for skip). */
|
|
21
|
+
beforeSegment?(ac: AbortController): void;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
/**
|
|
28
|
-
* Drive ONE round end-to-end: play the trigger, present it, ack; then drain the remaining segments
|
|
29
|
-
* (a bonus's free spins) by replaying nextActions[0] with the SAME roundId until the round reports
|
|
30
|
-
* `complete`. Fires `onBonusEnter` EXACTLY before the first free-role segment and `onBonusExit`
|
|
31
|
-
* after the last. A plain spin with no bonus is already `complete`, so the while-loop is a no-op.
|
|
32
|
-
*
|
|
33
|
-
* `ctx.bet` is captured once (bet can't change mid-round); `ctx.turbo` is a live getter so a
|
|
34
|
-
* mid-round toggle is honoured on the next segment.
|
|
35
|
-
*/
|
|
36
24
|
export async function runRound<T extends SlotSpinResultBase>(
|
|
37
25
|
deps: RunRoundDeps<T>,
|
|
38
26
|
action: string,
|
|
39
27
|
): Promise<void> {
|
|
40
|
-
|
|
41
|
-
let r = await deps.play(action, ctx.bet);
|
|
42
|
-
await deps.scene.present(r, ctx);
|
|
43
|
-
deps.ack();
|
|
44
|
-
deps.afterPresent?.(r); // HUD readouts update AFTER the animation, not before
|
|
28
|
+
deps.onSpinStart?.();
|
|
45
29
|
|
|
46
|
-
|
|
30
|
+
const ctxBet = deps.context(action).bet;
|
|
31
|
+
|
|
32
|
+
const segment = async (a: string, roundId: string | undefined): Promise<{ r: T; ctx: RenderContext }> => {
|
|
33
|
+
const ac = new AbortController();
|
|
34
|
+
deps.beforeSegment?.(ac);
|
|
35
|
+
const r = await deps.play(a, ctxBet, roundId);
|
|
36
|
+
const ctx = { ...deps.context(action), signal: ac.signal } as RenderContext;
|
|
37
|
+
await deps.scene.onSpin(r, ctx);
|
|
38
|
+
deps.ack();
|
|
39
|
+
deps.afterPresent?.(r);
|
|
40
|
+
return { r, ctx };
|
|
41
|
+
};
|
|
42
|
+
let { r, ctx } = await segment(action, undefined);
|
|
43
|
+
|
|
44
|
+
let inMode = false;
|
|
47
45
|
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
48
46
|
const next = r.nextActions[0];
|
|
49
|
-
if (!
|
|
50
|
-
|
|
51
|
-
await deps.
|
|
47
|
+
if (!inMode && deps.roleOf(next) === 'free') {
|
|
48
|
+
inMode = true;
|
|
49
|
+
await deps.onEnterMode?.(r, ctx);
|
|
52
50
|
}
|
|
53
|
-
|
|
54
|
-
// property (so a mid-round toggle is reflected on the NEXT segment), while action/mode/bet stay
|
|
55
|
-
// the round's (the trigger's) identity — a scene must see the same bonus identity all round.
|
|
56
|
-
const segCtx = { ...deps.context(action) } as RenderContext;
|
|
57
|
-
r = await deps.play(next, ctx.bet, r.roundId);
|
|
58
|
-
await deps.scene.present(r, segCtx);
|
|
59
|
-
deps.ack();
|
|
60
|
-
deps.afterPresent?.(r);
|
|
51
|
+
({ r, ctx } = await segment(next, r.roundId));
|
|
61
52
|
}
|
|
62
|
-
if (
|
|
53
|
+
if (inMode) await deps.onExitMode?.(r, ctx);
|
|
54
|
+
deps.onSpinEnd?.(r, ctx);
|
|
63
55
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AudioManager } from '../audio/AudioManager';
|
|
2
|
+
import type { SceneAudio } from './sceneController';
|
|
3
|
+
|
|
4
|
+
/** Wrap the engine's AudioManager into the playback-only handle a scene receives. Volume/mute are
|
|
5
|
+
* deliberately omitted — those are driven by the shell's settingChange → host. */
|
|
6
|
+
export function createSceneAudio(audio: AudioManager): SceneAudio {
|
|
7
|
+
return {
|
|
8
|
+
play: (alias, opts) => audio.play(alias, 'sfx', opts),
|
|
9
|
+
playMusic: (alias, fadeMs) => audio.playMusic(alias, fadeMs),
|
|
10
|
+
stopMusic: () => audio.stopMusic(),
|
|
11
|
+
duck: (factor) => audio.duckMusic(factor),
|
|
12
|
+
unduck: () => audio.unduckMusic(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -1,31 +1,96 @@
|
|
|
1
|
+
import type { Container } from 'pixi.js';
|
|
1
2
|
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
2
3
|
|
|
3
|
-
/** Everything a scene needs to render one
|
|
4
|
+
/** Everything a scene needs to render one segment. The host builds it per segment. */
|
|
4
5
|
export interface RenderContext {
|
|
5
|
-
/** Bet for this round (major units). Stable for the whole round
|
|
6
|
+
/** Bet for this round (major units). Stable for the whole round. */
|
|
6
7
|
bet: number;
|
|
7
|
-
/** Trigger action in the game's own vocabulary (
|
|
8
|
-
* 'buy_bonus' | … Stable for the whole round. */
|
|
8
|
+
/** Trigger action in the game's own vocabulary ('spin' | 'ante' | 'buy_bonus' | …). */
|
|
9
9
|
action: string;
|
|
10
|
-
/** Stake bet-mode of the round (
|
|
11
|
-
* Canonical per-round identifier of WHICH bonus/feature this is. Stable for the whole round. */
|
|
10
|
+
/** Stake bet-mode of the round ('BASE' | 'ANTE' | 'BONUS' | …). */
|
|
12
11
|
mode: string;
|
|
13
|
-
/** Currency-aware money formatter.
|
|
12
|
+
/** Currency-aware money formatter. */
|
|
14
13
|
formatAmount(value: number): string;
|
|
15
|
-
/** LIVE turbo level (0 = off, 1..3 = escalating speed)
|
|
16
|
-
* the moment of access (getter) so a mid-round toggle is reflected. */
|
|
14
|
+
/** LIVE turbo level (0 = off, 1..3 = escalating speed). Read at access. */
|
|
17
15
|
readonly turbo: number;
|
|
16
|
+
/** Aborted when the player skips this segment (double-tap). The scene's async pacing can race or
|
|
17
|
+
* cancel on it; on abort the scene must collapse to the segment's final visual state. */
|
|
18
|
+
signal: AbortSignal;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
21
|
+
/** Playback-only audio handle. Volume/mute are shell settings → host, never the scene. */
|
|
22
|
+
export interface SceneAudio {
|
|
23
|
+
play(alias: string, opts?: { volume?: number; loop?: boolean; speed?: number }): void;
|
|
24
|
+
playMusic(alias: string, fadeMs?: number): void;
|
|
25
|
+
stopMusic(): void;
|
|
26
|
+
duck(factor: number): void;
|
|
27
|
+
unduck(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OverlayShowOptions {
|
|
31
|
+
/** Draw the overlay content into `container` (sized to the canvas). */
|
|
32
|
+
build(container: Container, size: { width: number; height: number }): void;
|
|
33
|
+
/** Auto-close after N ms (combine with closeOn — whichever fires first). */
|
|
34
|
+
autoCloseMs?: number;
|
|
35
|
+
/** Dismiss on a single tap. Default 'tap'. Set false to require an explicit close(). */
|
|
36
|
+
closeOn?: 'tap' | false;
|
|
37
|
+
/** Optional host-drawn backdrop alpha (0..1). Default: none (game draws its own). */
|
|
38
|
+
dim?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Single host-owned layer above scene + shell. Eats pointer events so shell controls are
|
|
42
|
+
* unreachable while open. */
|
|
43
|
+
export interface SceneOverlay {
|
|
44
|
+
/** Resolves when the overlay closes. Rejects if one is already open. */
|
|
45
|
+
show(opts: OverlayShowOptions): Promise<void>;
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SceneShell {
|
|
50
|
+
/** Live insets (px). `bottom` = the shell bar height; read inside onResize. */
|
|
51
|
+
readonly safeArea: { top: number; right: number; bottom: number; left: number };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AutoplaySceneState {
|
|
55
|
+
running: boolean;
|
|
56
|
+
remaining: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Stable capabilities injected once via onCreate. */
|
|
60
|
+
export interface SceneApi {
|
|
61
|
+
audio: SceneAudio;
|
|
62
|
+
overlay: SceneOverlay;
|
|
63
|
+
shell: SceneShell;
|
|
64
|
+
formatAmount(value: number): string;
|
|
65
|
+
readonly bet: number;
|
|
66
|
+
readonly mode: string;
|
|
67
|
+
readonly turbo: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The contract a slot scene implements. The HOST owns the play→present→ack→drain loop and the
|
|
71
|
+
* shell; the scene only renders + reacts. The core spin-lifecycle hooks are REQUIRED (implement
|
|
72
|
+
* them — empty bodies are fine where a game has nothing to do); the incidental reactions below
|
|
73
|
+
* stay optional. */
|
|
22
74
|
export interface SlotSceneController<T extends SlotSpinResultBase = SlotSpinResultBase> {
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
|
|
75
|
+
/** Injected ONCE before the first round — capabilities, subscriptions, one-time setup. */
|
|
76
|
+
onCreate(api: SceneApi): void;
|
|
77
|
+
/** Fires once per round when the player presses spin (before the network result). */
|
|
78
|
+
onSpinStart(): void;
|
|
79
|
+
/** Render ONE segment (a spin or one free spin). Await your own pacing. */
|
|
80
|
+
onSpin(result: T, ctx: RenderContext): Promise<void>;
|
|
81
|
+
/** Fires when ctx.mode changes between segments (entering a non-BASE mode/bonus). */
|
|
82
|
+
onEnterMode(result: T, ctx: RenderContext): Promise<void>;
|
|
83
|
+
/** Fires when leaving a mode (back toward BASE). */
|
|
84
|
+
onExitMode(result: T, ctx: RenderContext): Promise<void>;
|
|
85
|
+
/** Fires once per round after the full drain (controls unlocked). */
|
|
86
|
+
onSpinEnd(result: T, ctx: RenderContext): void;
|
|
87
|
+
/** Shell events (may fire while idle) — optional. */
|
|
88
|
+
onBetChanged?(bet: number): void;
|
|
89
|
+
onTurboChanged?(level: number): void;
|
|
90
|
+
onAutoplayChanged?(state: AutoplaySceneState): void;
|
|
91
|
+
/** Double-tap skip during an active onSpin (gated by the skipGesture setting). */
|
|
92
|
+
onSkip?(): void;
|
|
93
|
+
/** Tab focus lost / regained. */
|
|
94
|
+
onPause?(): void;
|
|
95
|
+
onResume?(): void;
|
|
31
96
|
}
|
package/src/host/shellConfig.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// packages/game-engine/src/host/shellConfig.ts
|
|
2
|
-
|
|
2
|
+
// `socialize` / `createI18n` are runtime helpers sourced from platform-core/shell;
|
|
3
|
+
// pixi-shell re-exports only types so we import directly from platform-core.
|
|
4
|
+
import { socialize, createI18n } from '@energy8platform/platform-core/shell';
|
|
5
|
+
import type { Lang } from '@energy8platform/platform-core/shell';
|
|
3
6
|
import type {
|
|
4
|
-
|
|
7
|
+
PixiShellConfig, ShellMode, CurrencyConfig, GameInfoContent, GameInfoSection, PaytableRow,
|
|
5
8
|
BonusOption, ShellFeatures, GameMode,
|
|
6
|
-
} from '@energy8platform/
|
|
9
|
+
} from '@energy8platform/pixi-shell';
|
|
7
10
|
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
8
11
|
import type { WinTier } from '../slot';
|
|
9
12
|
|
|
10
13
|
export interface SlotShellOptions {
|
|
11
|
-
mount?: HTMLElement;
|
|
12
14
|
/** Override the derived currency (normally taken from initData). */
|
|
13
15
|
currency?: CurrencyConfig;
|
|
14
16
|
/** Author-supplied info sections, MERGED over the host-derived set by section identity (an author
|
|
@@ -25,6 +27,11 @@ export interface SlotShellOptions {
|
|
|
25
27
|
buyBonus?: BonusOption[];
|
|
26
28
|
tiers?: WinTier[];
|
|
27
29
|
features?: Partial<ShellFeatures>;
|
|
30
|
+
/** Per-game translation map. Keys are the English source strings (from the spec or copy);
|
|
31
|
+
* values are the translated strings for each language. Merged with the shell's built-in
|
|
32
|
+
* `LOCALES` catalog — game strings take precedence over the built-in entries for the same key.
|
|
33
|
+
* When not supplied, English source strings pass through verbatim (socialised in social mode). */
|
|
34
|
+
i18n?: Partial<Record<Lang, Record<string, string>>>;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
/** The currency metadata surfaced on `initData.config.currency` (game-sdk `CurrencyMetaData`).
|
|
@@ -129,8 +136,9 @@ export function stakeForAction(model: GameModel, action: string, bet: number): n
|
|
|
129
136
|
return cost * bet;
|
|
130
137
|
}
|
|
131
138
|
|
|
132
|
-
/** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT).
|
|
133
|
-
|
|
139
|
+
/** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT).
|
|
140
|
+
* @param t Optional translator applied to `title` and `description` before they enter the shell. */
|
|
141
|
+
export function toBonusOptions(model: GameModel, t: (s: string) => string = (s) => s): BonusOption[] {
|
|
134
142
|
const out: BonusOption[] = [];
|
|
135
143
|
for (const [key, action] of Object.entries(model.spec.actions)) {
|
|
136
144
|
const role = action.role ?? 'base';
|
|
@@ -138,16 +146,19 @@ export function toBonusOptions(model: GameModel): BonusOption[] {
|
|
|
138
146
|
out.push({
|
|
139
147
|
id: key,
|
|
140
148
|
type: role === 'buy' ? 'bonus' : 'feature',
|
|
141
|
-
title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
|
|
142
|
-
description: action.description ?? '',
|
|
149
|
+
title: t(action.title ?? key.replace(/_/g, ' ').toUpperCase()),
|
|
150
|
+
description: t(action.description ?? ''),
|
|
143
151
|
priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
|
|
152
|
+
// Volatility (1–5 bolts) is part of the spec action SSOT; forward it so the buy card shows it.
|
|
153
|
+
...(action.volatility != null ? { volatility: action.volatility } : {}),
|
|
144
154
|
});
|
|
145
155
|
}
|
|
146
156
|
return out;
|
|
147
157
|
}
|
|
148
158
|
|
|
149
|
-
/** Build a paytable section from the model's derived paytable view (multipliers per symbol count).
|
|
150
|
-
|
|
159
|
+
/** Build a paytable section from the model's derived paytable view (multipliers per symbol count).
|
|
160
|
+
* @param t Optional translator applied to symbol names before they enter the shell. */
|
|
161
|
+
function paytableSection(model: GameModel, t: (s: string) => string = (s) => s): GameInfoSection | null {
|
|
151
162
|
const symbols = model.paytable?.symbols ?? [];
|
|
152
163
|
const rows: PaytableRow[] = [];
|
|
153
164
|
for (const s of symbols) {
|
|
@@ -156,10 +167,12 @@ function paytableSection(model: GameModel): GameInfoSection | null {
|
|
|
156
167
|
.filter((w) => Number.isFinite(w.multiplier) && w.multiplier > 0)
|
|
157
168
|
.sort((a, b) => Number(a.count) - Number(b.count));
|
|
158
169
|
if (!wins.length) continue;
|
|
159
|
-
rows.push({ symbol: { text: s.name ?? s.id }, wins });
|
|
170
|
+
rows.push({ symbol: { text: t(s.name ?? s.id) }, wins });
|
|
160
171
|
}
|
|
161
172
|
if (!rows.length) return null;
|
|
162
|
-
|
|
173
|
+
// No literal title — the shell renders `s.title ?? host.t('Paytable')`, so the heading is
|
|
174
|
+
// localized (matching how the modes/wins sections rely on the shell's translated fallback).
|
|
175
|
+
return { type: 'paytable', rows };
|
|
163
176
|
}
|
|
164
177
|
|
|
165
178
|
/** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
|
|
@@ -205,13 +218,14 @@ function orderDisclaimerLast(sections: GameInfoSection[]): GameInfoSection[] {
|
|
|
205
218
|
* gets a real info panel for free (paytable, win illustration, controls, and the Stake
|
|
206
219
|
* disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
|
|
207
220
|
* section identity (see `mergeGameInfo`), not wholesale-replaced.
|
|
221
|
+
* @param t Optional translator applied to spec-derived player-facing strings (symbol names, mode titles, etc.).
|
|
208
222
|
*/
|
|
209
|
-
export function defaultGameInfo(model: GameModel, runtime: ShellRuntime): GameInfoContent {
|
|
223
|
+
export function defaultGameInfo(model: GameModel, runtime: ShellRuntime, t: (s: string) => string = (s) => s): GameInfoContent {
|
|
210
224
|
const sections: GameInfoSection[] = [];
|
|
211
225
|
sections.push(winsSection(model));
|
|
212
|
-
const pay = paytableSection(model);
|
|
226
|
+
const pay = paytableSection(model, t);
|
|
213
227
|
if (pay) sections.push(pay);
|
|
214
|
-
const modes = modesSection(model);
|
|
228
|
+
const modes = modesSection(model, t);
|
|
215
229
|
if (modes) sections.push(modes);
|
|
216
230
|
sections.push({ type: 'controls' });
|
|
217
231
|
const disclaimer = disclaimerSection(runtime.disclaimerLines);
|
|
@@ -223,21 +237,22 @@ export function defaultGameInfo(model: GameModel, runtime: ShellRuntime): GameIn
|
|
|
223
237
|
* (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
|
|
224
238
|
* compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
|
|
225
239
|
* mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
|
|
226
|
-
* already drops them — free spins are part of a bonus, not a purchasable mode).
|
|
227
|
-
|
|
240
|
+
* already drops them — free spins are part of a bonus, not a purchasable mode).
|
|
241
|
+
* @param t Optional translator applied to row `title` and `description` before they enter the shell. */
|
|
242
|
+
function modesSection(model: GameModel, t: (s: string) => string = (s) => s): GameInfoSection | null {
|
|
228
243
|
const modes = model.mathModes ?? [];
|
|
229
244
|
if (!modes.length) return null;
|
|
230
245
|
const rows: GameMode[] = modes.map((m) => {
|
|
231
246
|
const action = model.spec.actions[m.action];
|
|
232
247
|
const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
|
|
233
248
|
const row: GameMode = {
|
|
234
|
-
title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
|
|
249
|
+
title: t(action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' '))),
|
|
235
250
|
maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
|
|
236
251
|
};
|
|
237
252
|
// Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
|
|
238
253
|
if (m.costMultiplier && m.costMultiplier !== 1) row.price = `${m.costMultiplier}×`;
|
|
239
254
|
if (typeof m.rtp === 'number') row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
|
|
240
|
-
if (action?.description) row.description = action.description;
|
|
255
|
+
if (action?.description) row.description = t(action.description);
|
|
241
256
|
return row;
|
|
242
257
|
});
|
|
243
258
|
return { type: 'modes', title: 'MODES', modes: rows };
|
|
@@ -319,8 +334,13 @@ function socializeBonusOptions(options: BonusOption[], isSocial: boolean): Bonus
|
|
|
319
334
|
return options.map((o) => ({ ...o, title: socialize(o.title), description: socialize(o.description) }));
|
|
320
335
|
}
|
|
321
336
|
|
|
322
|
-
/** Pure: assemble
|
|
323
|
-
|
|
337
|
+
/** Pure: assemble the shell config (sans mount target) from the model + runtime context
|
|
338
|
+
* (currency/balance/language/mode). The host adds `app` at the call site. */
|
|
339
|
+
export function buildShellConfig(
|
|
340
|
+
opts: SlotShellOptions,
|
|
341
|
+
model: GameModel,
|
|
342
|
+
runtime: ShellRuntime,
|
|
343
|
+
): Omit<PixiShellConfig, 'app' | 'parent'> {
|
|
324
344
|
// Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
|
|
325
345
|
const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
|
|
326
346
|
// Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
|
|
@@ -330,18 +350,17 @@ export function buildShellConfig(opts: SlotShellOptions, model: GameModel, runti
|
|
|
330
350
|
const currency =
|
|
331
351
|
opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
|
|
332
352
|
const isSocial = runtime.social ?? false;
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
// social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
|
|
341
|
-
// still socialized below as a safety net.
|
|
342
|
-
const t = isSocial ? socialize : (text: string) => text;
|
|
353
|
+
// Build the merged resolver: game i18n map + shell LOCALES (via createI18n), then socialize on
|
|
354
|
+
// top for English social mode. For non-English languages, createI18n already handles the
|
|
355
|
+
// translation lookup; social rewriting is English-only and applied separately after.
|
|
356
|
+
// Author gameInfo may be a plain object or a `(t) => content` factory. `t` is the resolver so
|
|
357
|
+
// authors can wrap player-facing copy explicitly; the full merged set is still socialized below
|
|
358
|
+
// (section pass) as a safety net so restricted words can't slip through even if t() was missed.
|
|
359
|
+
const { t } = createI18n({ language: runtime.language ?? 'en', isSocial, messages: opts.i18n });
|
|
343
360
|
const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
|
|
344
|
-
|
|
361
|
+
// Pass t() through to spec-derived sections so symbol names, mode titles, and descriptions are
|
|
362
|
+
// pre-translated before they reach the shell renderer.
|
|
363
|
+
let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime, t), authored);
|
|
345
364
|
// The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
|
|
346
365
|
// wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
|
|
347
366
|
if (isSocial) {
|
|
@@ -351,8 +370,8 @@ export function buildShellConfig(opts: SlotShellOptions, model: GameModel, runti
|
|
|
351
370
|
}
|
|
352
371
|
// The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
|
|
353
372
|
gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
|
|
354
|
-
// Buy-bonus cards:
|
|
355
|
-
const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model), isSocial);
|
|
373
|
+
// Buy-bonus cards: apply t() to spec-derived options (pre-translate), then socialize for en+social.
|
|
374
|
+
const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model, t), isSocial);
|
|
356
375
|
// Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
|
|
357
376
|
const features: ShellFeatures = {
|
|
358
377
|
turbo: 0,
|
|
@@ -363,7 +382,6 @@ export function buildShellConfig(opts: SlotShellOptions, model: GameModel, runti
|
|
|
363
382
|
} as ShellFeatures;
|
|
364
383
|
applyJurisdiction(features, runtime.jurisdiction);
|
|
365
384
|
return {
|
|
366
|
-
mount: opts.mount ?? (typeof document !== 'undefined' ? document.body : (undefined as never)),
|
|
367
385
|
language: runtime.language ?? 'en',
|
|
368
386
|
isSocial,
|
|
369
387
|
currency,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface SkipDeps {
|
|
2
|
+
/** The skipGesture setting is on. */
|
|
3
|
+
enabled(): boolean;
|
|
4
|
+
/** An onSpin is currently presenting (skippable window). */
|
|
5
|
+
active(): boolean;
|
|
6
|
+
onSkip(): void;
|
|
7
|
+
/** Max ms between the two taps. Default 300. */
|
|
8
|
+
thresholdMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Pure double-tap recognizer. The host feeds it pointer `tap(now)` (e.g. performance.now()) and
|
|
12
|
+
* supplies the enabled/active gates + the onSkip effect. */
|
|
13
|
+
export function createDoubleTapSkip(deps: SkipDeps): { tap(now: number): void; destroy(): void } {
|
|
14
|
+
const threshold = deps.thresholdMs ?? 300;
|
|
15
|
+
let last = -Infinity;
|
|
16
|
+
return {
|
|
17
|
+
tap(now: number): void {
|
|
18
|
+
const isDouble = now - last <= threshold;
|
|
19
|
+
last = isDouble ? -Infinity : now; // consume the pair so a 3rd tap starts fresh
|
|
20
|
+
if (isDouble && deps.enabled() && deps.active()) deps.onSkip();
|
|
21
|
+
},
|
|
22
|
+
destroy(): void { last = -Infinity; },
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/host/types.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { ApplicationOptions } from 'pixi.js';
|
|
3
3
|
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
4
4
|
import type { AssetManifest, LoadingScreenConfig } from '@energy8platform/platform-core';
|
|
5
|
-
import type {
|
|
5
|
+
import type { PixiGameShell } from '@energy8platform/pixi-shell';
|
|
6
6
|
import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../types';
|
|
7
7
|
import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
|
|
8
8
|
import type { GameApplication } from '../core';
|
|
@@ -61,11 +61,14 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
|
|
|
61
61
|
dev?: boolean;
|
|
62
62
|
stake?: StakeIntegration;
|
|
63
63
|
shell?: SlotShellOptions;
|
|
64
|
+
/** Double-tap on the play area to skip the current spin animation. Default `true`. Set `false`
|
|
65
|
+
* to disable the gesture (e.g. games where a tap means something else). */
|
|
66
|
+
skipGesture?: boolean;
|
|
64
67
|
onFatalError?: (message: string) => void;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
export interface SlotGameHandle {
|
|
68
71
|
game: GameApplication;
|
|
69
72
|
stakeBridge: StakeBridge | null;
|
|
70
|
-
shell:
|
|
73
|
+
shell: PixiGameShell | null;
|
|
71
74
|
}
|