@energy8platform/game-engine 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/game-engine",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Universal casino game engine built on PixiJS v8 and @energy8platform/game-sdk",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs.js",
@@ -181,19 +181,20 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
181
181
 
182
182
  this.emit('initialized');
183
183
 
184
- // 7. Remove CSS preloader, show Canvas loading screen
185
- removeCSSPreloader(this._container);
186
-
187
- // 8. Load assets with loading screen
184
+ // 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
185
+ // its progress/tap and removes it before entering the game, so there's
186
+ // a single continuous overlay from boot to gameplay (no logo flash).
188
187
  await this.loadAssets(firstScene, sceneData);
189
188
 
190
189
  this.emit('loaded');
191
190
 
192
- // 9. Start the game loop
191
+ // 8. Start the game loop
193
192
  this._running = true;
194
193
  this.emit('started');
195
194
  } catch (err) {
196
195
  console.error('[GameEngine] Failed to start:', err);
196
+ // Tear down the preloader so a failure doesn't strand the brand frame.
197
+ if (this._container) removeCSSPreloader(this._container);
197
198
  this.emit('error', err instanceof Error ? err : new Error(String(err)));
198
199
  throw err;
199
200
  }
@@ -1,7 +1,8 @@
1
1
  // packages/game-engine/src/host/shellConfig.ts
2
- // `socialize` is a runtime helper that pixi-shell does NOT re-export (its index only re-exports
3
- // types), so it stays sourced from platform-core/shell; the shapes are structurally identical.
4
- import { socialize } from '@energy8platform/platform-core/shell';
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';
5
6
  import type {
6
7
  PixiShellConfig, ShellMode, CurrencyConfig, GameInfoContent, GameInfoSection, PaytableRow,
7
8
  BonusOption, ShellFeatures, GameMode,
@@ -26,6 +27,11 @@ export interface SlotShellOptions {
26
27
  buyBonus?: BonusOption[];
27
28
  tiers?: WinTier[];
28
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>>>;
29
35
  }
30
36
 
31
37
  /** The currency metadata surfaced on `initData.config.currency` (game-sdk `CurrencyMetaData`).
@@ -130,8 +136,9 @@ export function stakeForAction(model: GameModel, action: string, bet: number): n
130
136
  return cost * bet;
131
137
  }
132
138
 
133
- /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
134
- export function toBonusOptions(model: GameModel): BonusOption[] {
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[] {
135
142
  const out: BonusOption[] = [];
136
143
  for (const [key, action] of Object.entries(model.spec.actions)) {
137
144
  const role = action.role ?? 'base';
@@ -139,16 +146,19 @@ export function toBonusOptions(model: GameModel): BonusOption[] {
139
146
  out.push({
140
147
  id: key,
141
148
  type: role === 'buy' ? 'bonus' : 'feature',
142
- title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
143
- description: action.description ?? '',
149
+ title: t(action.title ?? key.replace(/_/g, ' ').toUpperCase()),
150
+ description: t(action.description ?? ''),
144
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 } : {}),
145
154
  });
146
155
  }
147
156
  return out;
148
157
  }
149
158
 
150
- /** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
151
- function paytableSection(model: GameModel): GameInfoSection | null {
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 {
152
162
  const symbols = model.paytable?.symbols ?? [];
153
163
  const rows: PaytableRow[] = [];
154
164
  for (const s of symbols) {
@@ -157,10 +167,12 @@ function paytableSection(model: GameModel): GameInfoSection | null {
157
167
  .filter((w) => Number.isFinite(w.multiplier) && w.multiplier > 0)
158
168
  .sort((a, b) => Number(a.count) - Number(b.count));
159
169
  if (!wins.length) continue;
160
- rows.push({ symbol: { text: s.name ?? s.id }, wins });
170
+ rows.push({ symbol: { text: t(s.name ?? s.id) }, wins });
161
171
  }
162
172
  if (!rows.length) return null;
163
- return { type: 'paytable', title: 'PAYTABLE', rows };
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 };
164
176
  }
165
177
 
166
178
  /** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
@@ -206,13 +218,14 @@ function orderDisclaimerLast(sections: GameInfoSection[]): GameInfoSection[] {
206
218
  * gets a real info panel for free (paytable, win illustration, controls, and the Stake
207
219
  * disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
208
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.).
209
222
  */
210
- export function defaultGameInfo(model: GameModel, runtime: ShellRuntime): GameInfoContent {
223
+ export function defaultGameInfo(model: GameModel, runtime: ShellRuntime, t: (s: string) => string = (s) => s): GameInfoContent {
211
224
  const sections: GameInfoSection[] = [];
212
225
  sections.push(winsSection(model));
213
- const pay = paytableSection(model);
226
+ const pay = paytableSection(model, t);
214
227
  if (pay) sections.push(pay);
215
- const modes = modesSection(model);
228
+ const modes = modesSection(model, t);
216
229
  if (modes) sections.push(modes);
217
230
  sections.push({ type: 'controls' });
218
231
  const disclaimer = disclaimerSection(runtime.disclaimerLines);
@@ -224,21 +237,22 @@ export function defaultGameInfo(model: GameModel, runtime: ShellRuntime): GameIn
224
237
  * (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
225
238
  * compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
226
239
  * mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
227
- * already drops them — free spins are part of a bonus, not a purchasable mode). */
228
- function modesSection(model: GameModel): GameInfoSection | null {
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 {
229
243
  const modes = model.mathModes ?? [];
230
244
  if (!modes.length) return null;
231
245
  const rows: GameMode[] = modes.map((m) => {
232
246
  const action = model.spec.actions[m.action];
233
247
  const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
234
248
  const row: GameMode = {
235
- title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
249
+ title: t(action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' '))),
236
250
  maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
237
251
  };
238
252
  // Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
239
253
  if (m.costMultiplier && m.costMultiplier !== 1) row.price = `${m.costMultiplier}×`;
240
254
  if (typeof m.rtp === 'number') row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
241
- if (action?.description) row.description = action.description;
255
+ if (action?.description) row.description = t(action.description);
242
256
  return row;
243
257
  });
244
258
  return { type: 'modes', title: 'MODES', modes: rows };
@@ -336,18 +350,17 @@ export function buildShellConfig(
336
350
  const currency =
337
351
  opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
338
352
  const isSocial = runtime.social ?? false;
339
- // Merge author sections over the host-derived defaults, THEN socialize the WHOLE merged set in
340
- // social mode so restricted gambling vocabulary is rewritten in BOTH the built-in copy AND any
341
- // author-supplied text (title + custom HTML). A game can no longer surface a forbidden word in
342
- // social mode just because the author wrote it in their own info section. (Custom sections built
343
- // from a raw DOM `node` can't be rewritten automatically author owns the node and can call the
344
- // exported `socialize` from '@energy8platform/game-engine/host' on their own strings.)
345
- // Author gameInfo may be a plain object or a `(t) => content` factory. `t` socializes when in
346
- // social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
347
- // still socialized below as a safety net.
348
- 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 });
349
360
  const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
350
- let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
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);
351
364
  // The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
352
365
  // wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
353
366
  if (isSocial) {
@@ -357,8 +370,8 @@ export function buildShellConfig(
357
370
  }
358
371
  // The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
359
372
  gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
360
- // Buy-bonus cards: socialize the FINAL options (author override or spec-derived) in social mode.
361
- 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);
362
375
  // Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
363
376
  const features: ShellFeatures = {
364
377
  turbo: 0,
@@ -1,23 +1,10 @@
1
- import { Container, Graphics, Text, Sprite, Assets } from 'pixi.js';
2
1
  import { Scene } from '../core/Scene';
3
- import { Tween } from '../animation/Tween';
4
- import { Easing } from '../animation/Easing';
5
2
  import type { LoadingScreenConfig } from '../types';
6
- import { buildLogoSVG, LOADER_BAR_MAX_WIDTH } from '@energy8platform/platform-core/loading';
7
-
8
- /**
9
- * Build the loading scene variant of the logo SVG.
10
- * Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
11
- */
12
- function buildLoadingLogoSVG(): string {
13
- return buildLogoSVG({
14
- idPrefix: 'ls',
15
- svgStyle: 'width:100%;height:auto;',
16
- clipRectId: 'ge-loader-rect',
17
- textId: 'ge-loader-pct',
18
- textContent: '0%',
19
- });
20
- }
3
+ import {
4
+ setCSSPreloaderProgress,
5
+ waitCSSPreloaderTap,
6
+ removeCSSPreloader,
7
+ } from '@energy8platform/platform-core/loading';
21
8
 
22
9
  interface LoadingSceneData {
23
10
  engine: any; // GameApplication — avoid circular import
@@ -26,10 +13,14 @@ interface LoadingSceneData {
26
13
  }
27
14
 
28
15
  /**
29
- * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
16
+ * Built-in loading screen.
30
17
  *
31
- * Renders as an HTML overlay on top of the canvas for crisp SVG quality.
32
- * The loader bar fill width is driven by asset loading progress.
18
+ * It does NOT render its own overlay the CSS preloader created at boot
19
+ * (`createPlatformSession`/`GameApplication.start`) stays on screen, and this
20
+ * scene merely drives it: asset-load progress → `setCSSPreloaderProgress`,
21
+ * tap-to-start → `waitCSSPreloaderTap`, then fades it out via
22
+ * `removeCSSPreloader` before entering the game. One continuous overlay from
23
+ * boot to gameplay — no second logo, no mid-load flash.
33
24
  */
34
25
  export class LoadingScene extends Scene {
35
26
  private _engine!: any;
@@ -37,12 +28,6 @@ export class LoadingScene extends Scene {
37
28
  private _targetData?: unknown;
38
29
  private _config!: LoadingScreenConfig;
39
30
 
40
- // HTML overlay
41
- private _overlay: HTMLDivElement | null = null;
42
- private _loaderRect: SVGRectElement | null = null;
43
- private _percentEl: Element | null = null;
44
- private _tapToStartEl: Element | null = null;
45
-
46
31
  // State
47
32
  private _displayedProgress = 0;
48
33
  private _targetProgress = 0;
@@ -57,9 +42,6 @@ export class LoadingScene extends Scene {
57
42
  this._config = engine.config.loading ?? {};
58
43
  this._startTime = Date.now();
59
44
 
60
- // Create the HTML overlay with the SVG logo
61
- this.createOverlay();
62
-
63
45
  // Initialize asset manager
64
46
  await this._engine.assets.init();
65
47
 
@@ -122,16 +104,14 @@ export class LoadingScene extends Scene {
122
104
  this._displayedProgress = 1;
123
105
  this.updateLoaderBar(1);
124
106
 
125
- // Show "Tap to Start" or transition directly
126
- if (this._config.tapToStart !== false) {
127
- await this.showTapToStart();
128
- } else {
129
- await this.transitionToGame();
130
- }
107
+ // Wait for the player's tap resolves immediately when tapToStart is
108
+ // false (the preloader honours that flag) — then enter the game.
109
+ await waitCSSPreloaderTap();
110
+ await this.transitionToGame();
131
111
  }
132
112
 
133
113
  override onUpdate(dt: number): void {
134
- // Smooth progress bar fill via HTML (during active loading)
114
+ // Smooth progress bar fill (during active loading)
135
115
  if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
136
116
  this._displayedProgress = Math.min(
137
117
  this._displayedProgress + dt * 1.5,
@@ -142,107 +122,19 @@ export class LoadingScene extends Scene {
142
122
  }
143
123
 
144
124
  override onResize(_width: number, _height: number): void {
145
- // Overlay is CSS-based, auto-resizes
125
+ // The preloader overlay is CSS-based and auto-resizes.
146
126
  }
147
127
 
148
128
  override onDestroy(): void {
149
- this.removeOverlay();
150
- }
151
-
152
- // ─── HTML Overlay ──────────────────────────────────────
153
-
154
- private createOverlay(): void {
155
- const bgColor =
156
- typeof this._config.backgroundColor === 'string'
157
- ? this._config.backgroundColor
158
- : typeof this._config.backgroundColor === 'number'
159
- ? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
160
- : '#0a0a1a';
161
-
162
- const bgGradient =
163
- this._config.backgroundGradient ??
164
- `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
165
-
166
- this._overlay = document.createElement('div');
167
- this._overlay.id = '__ge-loading-overlay__';
168
- this._overlay.innerHTML = `
169
- <div class="ge-loading-content">
170
- ${buildLoadingLogoSVG()}
171
- </div>
172
- `;
173
-
174
- const style = document.createElement('style');
175
- style.id = '__ge-loading-style__';
176
- style.textContent = `
177
- #__ge-loading-overlay__ {
178
- position: absolute;
179
- top: 0; left: 0;
180
- width: 100%; height: 100%;
181
- background: ${bgGradient};
182
- display: flex;
183
- align-items: center;
184
- justify-content: center;
185
- z-index: 9999;
186
- transition: opacity 0.5s ease-out;
187
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
188
- }
189
- #__ge-loading-overlay__.ge-fade-out {
190
- opacity: 0;
191
- pointer-events: none;
192
- }
193
- .ge-loading-content {
194
- display: flex;
195
- flex-direction: column;
196
- align-items: center;
197
- width: 75%;
198
- max-width: 650px;
199
- }
200
- .ge-loading-content svg {
201
- filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
202
- cursor: default;
203
- }
204
-
205
- .ge-svg-pulse {
206
- animation: ge-tap-pulse 1.2s ease-in-out infinite;
207
- }
208
- @keyframes ge-tap-pulse {
209
- 0%, 100% { opacity: 0.5; }
210
- 50% { opacity: 1; }
211
- }
212
- `;
213
-
214
- // Get the container that holds the canvas
215
- const container = this._engine.app?.canvas?.parentElement;
216
- if (container) {
217
- container.style.position = container.style.position || 'relative';
218
- container.appendChild(style);
219
- container.appendChild(this._overlay);
220
- }
221
-
222
- // Cache the SVG loader rect for progress updates
223
- this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
224
- this._percentEl = this._overlay.querySelector('#ge-loader-pct');
225
- }
226
-
227
- private removeOverlay(): void {
228
- this._overlay?.remove();
229
- document.getElementById('__ge-loading-style__')?.remove();
230
- this._overlay = null;
231
- this._loaderRect = null;
232
- this._percentEl = null;
233
- this._tapToStartEl = null;
129
+ // Defensive: ensure the preloader is gone even if we never transitioned
130
+ // (e.g. the scene was popped externally). Idempotent.
131
+ void removeCSSPreloader(this.hostElement());
234
132
  }
235
133
 
236
134
  // ─── Progress ──────────────────────────────────────────
237
135
 
238
136
  private updateLoaderBar(progress: number): void {
239
- if (this._loaderRect) {
240
- this._loaderRect.setAttribute('width', String(LOADER_BAR_MAX_WIDTH * progress));
241
- }
242
- if (this._percentEl) {
243
- const pct = Math.round(progress * 100);
244
- (this._percentEl as SVGTextElement).textContent = `${pct}%`;
245
- }
137
+ setCSSPreloaderProgress(Math.max(0, Math.min(1, progress)));
246
138
  }
247
139
 
248
140
  /**
@@ -275,58 +167,23 @@ export class LoadingScene extends Scene {
275
167
  });
276
168
  }
277
169
 
278
- // ─── Tap to Start ─────────────────────────────────────
279
-
280
- private async showTapToStart(): Promise<void> {
281
- const tapText = this._config.tapToStartText ?? 'TAP TO START';
282
-
283
- // Reuse the same SVG text element — replace percentage with tap text
284
- if (this._percentEl) {
285
- const el = this._percentEl as SVGTextElement;
286
- el.textContent = tapText;
287
- el.setAttribute('fill', '#ffffff');
288
- el.classList.add('ge-svg-pulse');
289
- this._tapToStartEl = el;
290
- }
291
-
292
- // Make overlay clickable
293
- if (this._overlay) {
294
- this._overlay.style.cursor = 'pointer';
295
- }
296
-
297
- // Wait for tap
298
- return new Promise<void>((resolve) => {
299
- const handler = async () => {
300
- this._overlay?.removeEventListener('click', handler);
301
- await this.transitionToGame();
302
- resolve();
303
- };
170
+ // ─── Transition ────────────────────────────────────────
304
171
 
305
- // Listen on the full overlay for easier mobile tap
306
- this._overlay?.addEventListener('click', handler);
307
- });
172
+ /** The DOM element hosting the canvas + preloader overlay. */
173
+ private hostElement(): HTMLElement {
174
+ return this._engine?.app?.canvas?.parentElement ?? document.body;
308
175
  }
309
176
 
310
- // ─── Transition ────────────────────────────────────────
311
-
312
177
  private async transitionToGame(): Promise<void> {
313
- // Fade out the HTML overlay
314
- if (this._overlay) {
315
- this._overlay.classList.add('ge-fade-out');
316
- await new Promise<void>((resolve) => {
317
- this._overlay!.addEventListener('transitionend', () => resolve(), { once: true });
318
- // Safety timeout
319
- setTimeout(resolve, 600);
320
- });
321
- }
322
-
323
- // Remove overlay
324
- this.removeOverlay();
178
+ // Fade out and remove the shared CSS preloader (resolves after the fade).
179
+ await removeCSSPreloader(this.hostElement());
325
180
 
326
181
  // Navigate to the target scene, always passing the engine reference
327
182
  await this._engine.scenes.goto(this._targetScene, {
328
183
  engine: this._engine,
329
- ...(this._targetData && typeof this._targetData === 'object' ? this._targetData as Record<string, unknown> : { data: this._targetData }),
184
+ ...(this._targetData && typeof this._targetData === 'object'
185
+ ? (this._targetData as Record<string, unknown>)
186
+ : { data: this._targetData }),
330
187
  });
331
188
  }
332
189
  }