@energy8platform/game-engine 0.34.3 → 0.35.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.34.3",
3
+ "version": "0.35.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",
@@ -93,7 +93,7 @@
93
93
  "prepublishOnly": "npm run build"
94
94
  },
95
95
  "dependencies": {
96
- "@energy8platform/platform-core": ">=0.30.8",
96
+ "@energy8platform/platform-core": ">=0.31.0",
97
97
  "@energy8platform/shell": ">=0.7.0"
98
98
  },
99
99
  "peerDependencies": {
@@ -11,7 +11,14 @@ import { AudioManager } from '../audio/AudioManager';
11
11
  import { InputManager } from '../input/InputManager';
12
12
  import { ViewportManager } from '../viewport/ViewportManager';
13
13
  import { LoadingScene } from '../loading/LoadingScene';
14
- import { createCSSPreloader, removeCSSPreloader } from '@energy8platform/platform-core/loading';
14
+ import {
15
+ createCSSPreloader,
16
+ removeCSSPreloader,
17
+ adoptExternalOverlay,
18
+ advanceExternalOverlay,
19
+ releaseExternalOverlay,
20
+ hasExternalOverlay,
21
+ } from '@energy8platform/platform-core/loading';
15
22
  import { FPSOverlay } from '../debug/FPSOverlay';
16
23
 
17
24
  /**
@@ -155,17 +162,40 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
155
162
  }
156
163
 
157
164
  try {
165
+ // 0. Adopt a game-supplied loading overlay (`loading.externalOverlay`) BEFORE anything that
166
+ // can throw. Such an overlay is already on screen — Artube's is injected into index.html,
167
+ // so it paints before this bundle is even fetched — and until the engine has adopted it,
168
+ // the catch below has no way to take it down. A bad `container` selector (step 1) would
169
+ // otherwise strand it on screen forever. It needs no container of ours.
170
+ // Adoption is also where its minimum display time starts counting, which is why the
171
+ // config value is handed over here rather than read at the hand-over: this is the
172
+ // earliest moment the engine runs, and the overlay has been on screen since before it.
173
+ const external = this.config.loading?.externalOverlay;
174
+ if (external)
175
+ adoptExternalOverlay(external, this.config.loading?.externalOverlayMinDisplayTime);
176
+
158
177
  // 1. Resolve container element
159
178
  this._container = this.resolveContainer();
160
179
 
161
- // 2. Show CSS preloader immediately (before PixiJS)
162
- createCSSPreloader(this._container, this.config.loading);
180
+ // 2. Show the CSS preloader immediately (before PixiJS) — UNLESS a game-supplied overlay is
181
+ // already covering the screen. In that case the preloader is mounted later, by LoadingScene
182
+ // at its first frame, which is where the hand-over happens. Mounting it here instead would
183
+ // put our brand over theirs for the whole of Pixi init and the SDK handshake, i.e. hand
184
+ // over long before the gap the external overlay exists to cover has closed.
185
+ if (!hasExternalOverlay()) createCSSPreloader(this._container, this.config.loading);
163
186
 
164
187
  // 3. Initialize PixiJS
165
188
  await this.initPixi();
189
+ // Milestones through the pre-first-frame gap, for a game-supplied overlay only (no-ops
190
+ // otherwise, so the built-in preloader's behaviour is untouched). They are also what makes
191
+ // Artube's loader crossfade from its dark partner phase to its branded one: that transition
192
+ // fires on the first progress above zero, and without it the player would never see the
193
+ // brand the loader exists to show. Values are honest weights of what remains, not a timer.
194
+ advanceExternalOverlay(0.35);
166
195
 
167
196
  // 4. Initialize SDK (if enabled)
168
197
  await this.initSDK();
198
+ advanceExternalOverlay(0.7);
169
199
 
170
200
  // 4b. Mount the branded game shell after the SDK handshake (optional)
171
201
  if (this.config.shell) {
@@ -178,12 +208,19 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
178
208
 
179
209
  // 6. Initialize sub-systems
180
210
  this.initSubSystems();
211
+ advanceExternalOverlay(0.85);
181
212
 
182
213
  this.emit('initialized');
183
214
 
184
215
  // 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
185
216
  // its progress/tap and removes it before entering the game, so there's
186
217
  // a single continuous overlay from boot to gameplay (no logo flash).
218
+ //
219
+ // With a game-supplied overlay the sequence has one extra step at the
220
+ // front: LoadingScene MOUNTS the preloader, waits for its first painted
221
+ // frame, and only then dismisses the external overlay. From that frame
222
+ // on this path and every other are identical — same brand, same bar,
223
+ // same tap-to-start.
187
224
  await this.loadAssets(firstScene, sceneData);
188
225
 
189
226
  this.emit('loaded');
@@ -193,8 +230,13 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
193
230
  this.emit('started');
194
231
  } catch (err) {
195
232
  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);
233
+ // Tear down both possible overlays so a failure strands neither brand frame. BOTH calls run:
234
+ // a throw during the hand-over window can leave the preloader mounted AND the external
235
+ // overlay still adopted, and each call is a no-op when there is nothing to remove. The
236
+ // container may never have resolved (step 1 is inside this try), hence the `document.body`
237
+ // fallback — the external overlay ignores the element entirely.
238
+ releaseExternalOverlay();
239
+ void removeCSSPreloader(this._container ?? document.body);
198
240
  this.emit('error', err instanceof Error ? err : new Error(String(err)));
199
241
  throw err;
200
242
  }
@@ -6,12 +6,18 @@ import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-res
6
6
 
7
7
  /**
8
8
  * Pure: map host options to a GameApplicationConfig with sane defaults.
9
- * `isStakeNow` is computed by the orchestrator (kept out of here so this
10
- * stays a pure, renderer-free function).
9
+ * `isStakeNow` / `isArtubeNow` are computed by the orchestrator (kept out of
10
+ * here so this stays a pure, renderer-free function).
11
+ *
12
+ * Both host bridges run IN-PROCESS with the game, so either one means the SDK
13
+ * must be in `devMode` — that is what makes it talk over the in-memory channel
14
+ * the bridge listens on instead of postMessage-ing an outer host that isn't
15
+ * there. (`dev` is the third, unrelated reason for the same flag: DevBridge.)
11
16
  */
12
17
  export function buildAppConfig<T extends SlotSpinResultBase = SlotSpinResultBase>(
13
18
  opts: CreateSlotGameOptions<T>,
14
19
  isStakeNow: boolean,
20
+ isArtubeNow = false,
15
21
  ): GameApplicationConfig {
16
22
  return {
17
23
  container: opts.container ?? '#game',
@@ -19,11 +25,18 @@ export function buildAppConfig<T extends SlotSpinResultBase = SlotSpinResultBase
19
25
  designHeight: opts.design?.height ?? 1080,
20
26
  scaleMode: opts.scaleMode ?? ScaleMode.FILL,
21
27
  orientation: opts.orientation ?? Orientation.ANY,
22
- loading: opts.loading ?? { tapToStart: false, minDisplayTime: 600 },
28
+ // MERGED, not replaced. `opts.loading ?? {…}` looked equivalent and was not: a game that
29
+ // passes ANY loading option loses every default it did not restate, so `{ minDisplayTime: 900 }`
30
+ // silently re-armed tap-to-start (the engine's own default for that flag is `true`, for
31
+ // backwards compatibility with direct GameApplication users). The Artube target made it
32
+ // visible — a game supplying only `externalOverlay` waited for a tap there and nowhere else,
33
+ // i.e. the same source line behaved differently per platform. Spreading `opts.loading` last
34
+ // keeps every explicit value winning while unset keys stay on the host's defaults.
35
+ loading: { tapToStart: false, minDisplayTime: 600, ...opts.loading },
23
36
  manifest: opts.manifest,
24
37
  audio: opts.audio,
25
38
  pixi: opts.pixi,
26
- sdk: { devMode: isStakeNow || (opts.dev ?? false) },
39
+ sdk: { devMode: isStakeNow || isArtubeNow || (opts.dev ?? false) },
27
40
  debug: opts.dev ?? false,
28
41
  };
29
42
  }
@@ -5,13 +5,14 @@ import { buildAppConfig } from './buildConfig';
5
5
  import { loadFonts, applyTextureDefaults, bootGuard } from './preboot';
6
6
  import { showFatalError, installGlobalErrorHandlers } from './fatalError';
7
7
  import type { CreateSlotGameOptions, SlotGameHandle } from './types';
8
+ import { releaseExternalOverlay } from '@energy8platform/platform-core/loading';
8
9
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
9
10
  import type { ShellMode } from '@energy8platform/shell/pixi';
10
11
  import type { SceneApi, SlotSceneController, RenderContext } from './sceneController';
11
12
  import type { FreeSpinsView } from './freeSpinsCounter';
12
13
 
13
14
  /**
14
- * One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
15
+ * One-call slot bootstrap: preboot → (optional Stake / Artube bridge) → GameApplication
15
16
  * → register scene → start. Collapses the per-game main.ts boilerplate.
16
17
  *
17
18
  * Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless
@@ -29,6 +30,24 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
29
30
  let shell: SlotGameHandle['shell'] = null;
30
31
 
31
32
  const fatal = (message: string) => {
33
+ // Take down a game-supplied loading overlay (`loading.externalOverlay`) first. Several fatal
34
+ // paths below — a refused Artube launch, a bridge that cannot connect — happen BEFORE
35
+ // `GameApplication.start()`, so its own error path never runs and nothing else would ever
36
+ // dismiss the overlay. Artube's is already on screen from index.html at z-index 9999: leaving
37
+ // it up means the player stares at a frozen loading screen, and a custom `onFatalError`
38
+ // renderer would be hidden underneath it entirely.
39
+ //
40
+ // `releaseExternalOverlay` handles the case where the engine already adopted it (and keeps the
41
+ // dismissal idempotent, which matters now that the normal hand-over also dismisses it). Its
42
+ // `false` means the engine never got that far — those are exactly the pre-boot refusals, where
43
+ // hiding the game's overlay directly is the only thing that can work.
44
+ if (!releaseExternalOverlay()) {
45
+ try {
46
+ opts.loading?.externalOverlay?.hideLoader();
47
+ } catch {
48
+ /* the overlay is the game's; a throw here must not swallow the error we came to report */
49
+ }
50
+ }
32
51
  if (opts.onFatalError) return opts.onFatalError(message);
33
52
  // Once the shell is up, use ITS branded modal (consistent chrome, social vocabulary, fit
34
53
  // scaling) rather than the bare DOM fallback. Errors thrown before the shell boots (asset
@@ -100,7 +119,66 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
100
119
  }
101
120
  }
102
121
 
103
- const game = new GameApplication(buildAppConfig(opts, isStakeNow));
122
+ let artubeBridge: SlotGameHandle['artubeBridge'] = null;
123
+ let isArtubeNow = false;
124
+ // `!isStakeNow`: both bridges install themselves in-process on the SAME SDK memory channel, so
125
+ // whichever launch already claimed the game wins. (The two launch shapes are disjoint in practice
126
+ // — Stake's marker is `sessionID`/`replay`, Artube's is `sessionId` — so this never fires; it just
127
+ // makes the precedence explicit rather than leaving two bridges racing.)
128
+ if (opts.artube && !isStakeNow) {
129
+ // The GAME supplies the loader (see `ArtubeIntegration.load`): a bare
130
+ // `import('@energy8platform/artube-bridge')` here would be resolved statically by every bundler
131
+ // and would break — loudly or, under Vite, silently — every game that never installed the
132
+ // package. Classifier and bridge come from the same module, so this is one load.
133
+ let artube: import('./types').ArtubeModule;
134
+ try {
135
+ artube = await opts.artube.load();
136
+ } catch (err) {
137
+ fatal('Could not start the game.');
138
+ throw err;
139
+ }
140
+ // Security gate, the Artube counterpart of the Stake one above. Artube's only launch marker is
141
+ // `sessionId`, and unlike Stake there is no attacker-suppliable server address to validate
142
+ // (`apiBase` is the launch URL's own origin). What IS reachable is stripping the session: a URL
143
+ // that carries `sessionId` with an empty/blank value claims a session it doesn't have, fails the
144
+ // "is this Artube?" check, and would silently fall through to the offline/dev bridge — the
145
+ // free-play hole. 'artube' = a real launch (load the bridge); 'offline' = no marker at all, a
146
+ // genuine dev launch.
147
+ //
148
+ // What URL classification cannot catch: a marker removed ENTIRELY is indistinguishable from a
149
+ // dev launch. In a production BUILD there is nothing to fall through to anyway — the DevBridge
150
+ // bootstrapper is injected by a Vite plugin with `apply: 'serve'`, so no build carries one,
151
+ // whatever BUILD_TARGET says. Under a plain `npm run dev` the bootstrapper HAS already started a
152
+ // DevBridge before this code runs (it wraps the entry module), so there the protection is this
153
+ // gate refusing to start the game — not the absence of a bridge.
154
+ const launch = artube.classifyArtubeLaunch(location.href);
155
+ if (launch === 'blocked') {
156
+ fatal('Invalid game session. Please relaunch the game from the lobby.');
157
+ throw new Error(
158
+ 'createSlotGame: refusing to run — Artube launch with a missing or blank sessionId',
159
+ );
160
+ }
161
+ isArtubeNow = launch === 'artube';
162
+ if (isArtubeNow) {
163
+ try {
164
+ artubeBridge = new artube.ArtubeBridge({
165
+ // In-process over the SDK's MemoryChannel (see buildAppConfig's devMode).
166
+ devMode: true,
167
+ gameId: opts.model.spec.id,
168
+ url: location.href,
169
+ // Same-origin in production; both fields are dev/demo escape hatches (see ArtubeIntegration).
170
+ ...(opts.artube.apiBase ? { apiBase: opts.artube.apiBase } : {}),
171
+ ...(opts.artube.demoBalance != null ? { demoBalance: opts.artube.demoBalance } : {}),
172
+ });
173
+ await artubeBridge.ready();
174
+ } catch (err) {
175
+ fatal('Could not connect to the game server. Please reload.');
176
+ throw err;
177
+ }
178
+ }
179
+ }
180
+
181
+ const game = new GameApplication(buildAppConfig(opts, isStakeNow, isArtubeNow));
104
182
 
105
183
  // Register EVERY scene up front so any of them can navigate to any other.
106
184
  for (const { key, scene } of opts.scenes) game.scenes.register(key, scene);
@@ -174,6 +252,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
174
252
 
175
253
  const ps = game.platformSession;
176
254
  const balance = (game.initData?.balance as number | undefined) ?? 0;
255
+ // Replay is a STAKE concept (a shared link that re-plays one recorded round). Artube has no
256
+ // equivalent, so an Artube launch is always 'base' — nothing to mirror here.
177
257
  const isReplay = !!stakeBridge?.isReplay;
178
258
  const mode: ShellMode = isReplay ? 'replay' : 'base';
179
259
  // initData.config carries the Stake bridge's currency/social/disclaimer surface (GameConfigData);
@@ -189,9 +269,16 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
189
269
  stake?: { defaultBetLevel?: number; minBet?: number; maxBet?: number };
190
270
  /** Set by the Stake bridge when `/wallet/authenticate` returned a still-open round. */
191
271
  activeRound?: { bet?: number; roundId?: string; mode?: string };
272
+ /** Artube's platform block. The default bet arrives as an INDEX into `config.betLevels`
273
+ * (the platform's `allowed_bets`), where Stake states an amount. */
274
+ artube?: { defaultBetIndex?: number };
192
275
  };
193
- /** Present only on a resume — the bridge synthesises it from the open round. */
276
+ /** Present only on a resume — the bridge synthesises it from the open round. Both the Stake
277
+ * and the Artube bridge fill it the same way, so the resumed-bet path below is shared. */
194
278
  session?: { betAmount?: number };
279
+ /** Session currency CODE. The Artube bridge's only currency surface (the platform picks it
280
+ * per session; demo sessions are 'FUN'); Stake sends full meta on `config.currency` instead. */
281
+ currency?: string;
195
282
  lang?: string;
196
283
  } | null;
197
284
  const config = initData?.config;
@@ -203,33 +290,44 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
203
290
  // carrier for the same value (INIT only ever has a session on a resume). Both are ignored when
204
291
  // absent or 0 so an ordinary launch still starts on the default.
205
292
  const resumedBet = config?.activeRound?.bet || initData?.session?.betAmount || undefined;
293
+ // Artube states its per-session default bet as an INDEX into the platform's ladder; resolve it
294
+ // against that SAME ladder so `runtime.defaultBet` is an amount on both platforms.
295
+ const artubeDefaultBet = isArtubeNow
296
+ ? config?.betLevels?.[config?.artube?.defaultBetIndex ?? -1]
297
+ : undefined;
206
298
  const { resolveCurrency } = await import('./shellConfig');
207
299
  // SINGLE source of truth for the symbol: the Stake bridge already puts a full CurrencyMetaData
208
300
  // (symbol + placement) on initData.config.currency. In the non-stake/devBridge path that meta
209
301
  // is absent and we only have the spec's currency CODE — resolve it through the SAME table
210
302
  // (stake-bridge's lookupCurrency) so e.g. 'EUR' renders as '€', not the literal text "EUR".
211
303
  // stake-bridge ships with every scaffold; if it's somehow absent we degrade to the code.
304
+ // On Artube the session currency is the PLATFORM's (per player, and 'FUN' for demo sessions) and
305
+ // arrives as a bare code on initData — there is no meta object. It outranks the spec's static
306
+ // code, which would otherwise show every Artube player the spec's currency symbol.
307
+ const currencyCode = (isArtubeNow ? initData?.currency : undefined) || opts.model.spec.currency;
212
308
  let currencyMeta = config?.currency;
213
- if (!currencyMeta?.symbol && opts.model.spec.currency) {
309
+ if (!currencyMeta?.symbol && currencyCode) {
214
310
  try {
215
311
  const { lookupCurrency } = await import('@energy8platform/stake-bridge');
216
- currencyMeta = lookupCurrency(opts.model.spec.currency);
312
+ currencyMeta = lookupCurrency(currencyCode);
217
313
  } catch {
218
314
  /* stake-bridge not installed — resolveCurrency falls back to the code */
219
315
  }
220
316
  }
221
317
  const runtime = {
222
318
  balance,
223
- currency: resolveCurrency(currencyMeta, opts.model.spec.currency),
319
+ currency: resolveCurrency(currencyMeta, currencyCode),
224
320
  language: initData?.lang,
225
321
  mode,
226
322
  social: config?.socialMode,
227
323
  disclaimerLines: config?.disclaimerLines,
228
324
  jurisdiction: config?.jurisdiction,
229
- // Currency-specific ladder + per-currency default from /wallet/authenticate (Stake);
230
- // absent on dev/devBridge → buildShellConfig falls back to the spec.
325
+ // Currency-specific ladder + per-currency default from /wallet/authenticate (Stake) or the
326
+ // backend's `allowed_bets` (Artube); absent on dev/devBridge → buildShellConfig falls back to
327
+ // the spec.
231
328
  betLevels: config?.betLevels,
232
- defaultBet: resumedBet ?? config?.stake?.defaultBetLevel ?? config?.defaultBet,
329
+ defaultBet:
330
+ resumedBet ?? config?.stake?.defaultBetLevel ?? artubeDefaultBet ?? config?.defaultBet,
233
331
  // Hard stake window; the bridge rejects anything outside it before /bet/play.
234
332
  minBet: config?.stake?.minBet,
235
333
  maxBet: config?.stake?.maxBet,
@@ -238,10 +336,14 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
238
336
  // spec's (EUR-shaped) ladder here would put the game on bets the wallet can't honour — every
239
337
  // spin rejected on a high-denomination currency (ARS minBet 50), or silently mispriced. Fail
240
338
  // where the cause is visible instead of at the first spin.
241
- if (isStakeNow && !runtime.betLevels?.length) {
339
+ // Artube is the same requirement by a different route: the wire carries a bet INDEX, not an
340
+ // amount, and the bridge maps the amount the game plays to the NEAREST rung of the platform's
341
+ // ladder — so a spec-shaped ladder wouldn't be rejected, it would silently charge a different
342
+ // price than the bar shows. Refuse there too.
343
+ if ((isStakeNow || isArtubeNow) && !runtime.betLevels?.length) {
242
344
  fatal('Could not load the bet levels for your currency. Please relaunch the game.');
243
345
  throw new Error(
244
- 'createSlotGame: Stake launch returned no config.betLevels — refusing to fall back to the spec ladder',
346
+ `createSlotGame: ${isStakeNow ? 'Stake' : 'Artube'} launch returned no config.betLevels — refusing to fall back to the spec ladder`,
245
347
  );
246
348
  }
247
349
  if (opts.dev) {
@@ -811,5 +913,5 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
811
913
  }
812
914
  }
813
915
 
814
- return { game, stakeBridge, shell };
916
+ return { game, stakeBridge, artubeBridge, shell };
815
917
  }
package/src/host/index.ts CHANGED
@@ -4,6 +4,9 @@ export type {
4
4
  CreateSlotGameOptions,
5
5
  SlotGameHandle,
6
6
  StakeIntegration,
7
+ ArtubeIntegration,
8
+ ArtubeModule,
9
+ ArtubeBridgeLike,
7
10
  SceneRegistration,
8
11
  SceneNavData,
9
12
  ShellFactory,
package/src/host/types.ts CHANGED
@@ -34,6 +34,58 @@ export interface StakeIntegration {
34
34
  adapter: BookAdapter | AdapterModule;
35
35
  }
36
36
 
37
+ /** The live Artube bridge, structurally. Kept minimal so game-engine never has to import
38
+ * `@energy8platform/artube-bridge` — see `ArtubeIntegration.load`. */
39
+ export interface ArtubeBridgeLike {
40
+ /** Resolves once the backend has sent its init. */
41
+ ready(): Promise<void>;
42
+ destroy(): void;
43
+ }
44
+
45
+ /** What `ArtubeIntegration.load()` must resolve to. `@energy8platform/artube-bridge`'s own entry
46
+ * satisfies this structurally, so `load: () => import('@energy8platform/artube-bridge')` typechecks
47
+ * with no adapter. The launch CLASSIFIER comes from the same module as the bridge: one import, and
48
+ * no chicken-and-egg where the host would have to load something to decide whether to load. */
49
+ export interface ArtubeModule {
50
+ classifyArtubeLaunch: (url: string) => 'artube' | 'blocked' | 'offline';
51
+ ArtubeBridge: new (options: {
52
+ devMode?: boolean;
53
+ gameId?: string;
54
+ url?: string;
55
+ apiBase?: string;
56
+ demoBalance?: number;
57
+ }) => ArtubeBridgeLike;
58
+ }
59
+
60
+ /** Artube host integration. There is no per-game artifact to pass: the game's own BACKEND
61
+ * (`@energy8platform/artube-server`) owns the round shape, so the bridge is a pure protocol
62
+ * translator — `load` is the only required field. `gameId` comes from the model, the launch params
63
+ * from the URL. */
64
+ export interface ArtubeIntegration {
65
+ /** REQUIRED. How the host reaches the bridge:
66
+ * `load: () => import('@energy8platform/artube-bridge')`.
67
+ *
68
+ * Why the GAME supplies this instead of the host importing the package itself: a bare
69
+ * `import('@energy8platform/artube-bridge')` inside this always-shipped `/host` entry is resolved
70
+ * STATICALLY by every bundler, so it would have to resolve in games that never opted into Artube
71
+ * and therefore never installed the package — esbuild/webpack fail the build outright, and Vite
72
+ * silently substitutes an empty module (it stubs uninstalled optional peers), which is worse:
73
+ * the game builds and then dies at runtime. With the loader, the specifier only ever appears in
74
+ * the bundle of a game that took the dependency.
75
+ *
76
+ * The loaded chunk is fetched on every launch of such a game (the classifier lives in it), which
77
+ * is the point of the split: only Artube-targeted builds pay for it. */
78
+ load: () => Promise<ArtubeModule>;
79
+ /** Starting virtual balance for a DEMO session (the platform doesn't keep one — the bridge does,
80
+ * client-side). Default: the backend's own configured demo balance. Ignored for real sessions. */
81
+ demoBalance?: number;
82
+ /** Origin of the game's backend. Default (and the only supported PRODUCTION value) is the launch
83
+ * URL's own origin: Artube serves frontend and backend on one domain, split by path (`/api/**`).
84
+ * Override only for local dev against a backend on another port — prefer proxying `/api` from the
85
+ * dev server (what the `BUILD_TARGET=artube` target does) so dev matches production. */
86
+ apiBase?: string;
87
+ }
88
+
37
89
  /** One scene registered with the host: a key + its constructor. The list order matters — the
38
90
  * first scene that is eligible for the current launch mode is the start scene (unless an explicit
39
91
  * `startScene` overrides it). */
@@ -77,6 +129,10 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
77
129
  textureDefaults?: boolean;
78
130
  dev?: boolean;
79
131
  stake?: StakeIntegration;
132
+ /** Run on Artube when the launch URL says so (`?sessionId=…`):
133
+ * `artube: { load: () => import('@energy8platform/artube-bridge') }`. See `ArtubeIntegration`
134
+ * for why the game supplies the loader. Build with `BUILD_TARGET=artube`. */
135
+ artube?: ArtubeIntegration;
80
136
  shell?: SlotShellOptions;
81
137
  /** Customise the bonus bar readout for games whose bonus isn't plain free spins (adventure,
82
138
  * hold-and-spin, respins). Omit for the free-spins default. See `BonusReadoutConfig`. */
@@ -101,5 +157,7 @@ export type ShellFactory = (config: PixiShellConfig) => Shell;
101
157
  export interface SlotGameHandle {
102
158
  game: GameApplication;
103
159
  stakeBridge: StakeBridge | null;
160
+ /** The live Artube bridge — non-null only on a real Artube launch (`opts.artube` + `?sessionId=…`). */
161
+ artubeBridge: ArtubeBridgeLike | null;
104
162
  shell: Shell | null;
105
163
  }
@@ -1,9 +1,13 @@
1
1
  import { Scene } from '../core/Scene';
2
2
  import type { LoadingScreenConfig } from '../types';
3
3
  import {
4
+ createCSSPreloader,
4
5
  setCSSPreloaderProgress,
5
6
  waitCSSPreloaderTap,
6
7
  removeCSSPreloader,
8
+ hasExternalOverlay,
9
+ externalOverlayHold,
10
+ releaseExternalOverlay,
7
11
  } from '@energy8platform/platform-core/loading';
8
12
 
9
13
  interface LoadingSceneData {
@@ -21,6 +25,14 @@ interface LoadingSceneData {
21
25
  * tap-to-start → `waitCSSPreloaderTap`, then fades it out via
22
26
  * `removeCSSPreloader` before entering the game. One continuous overlay from
23
27
  * boot to gameplay — no second logo, no mid-load flash.
28
+ *
29
+ * When the game supplied its own overlay (`loading.externalOverlay`, e.g.
30
+ * Artube's `LoaderViewController`), this scene is also the HAND-OVER point: that
31
+ * overlay covered the gap this scene's existence ends — the bundle download,
32
+ * Pixi init and the SDK handshake, none of which the engine can paint over. The
33
+ * first thing `onEnter` does is mount the preloader, wait for it to be painted,
34
+ * and dismiss the game's overlay. Everything after that line is identical on
35
+ * every platform.
24
36
  */
25
37
  export class LoadingScene extends Scene {
26
38
  private _engine!: any;
@@ -40,6 +52,12 @@ export class LoadingScene extends Scene {
40
52
  this._targetScene = targetScene;
41
53
  this._targetData = targetData;
42
54
  this._config = engine.config.loading ?? {};
55
+
56
+ // Take the screen from a game-supplied loading overlay, if there is one. Before any awaited
57
+ // work: from here on the player is looking at OUR loading screen, and `_startTime` (which
58
+ // `minDisplayTime` is measured from) must start when that becomes true.
59
+ await this.takeOverFromExternalOverlay();
60
+
43
61
  this._startTime = Date.now();
44
62
 
45
63
  // Initialize asset manager
@@ -104,8 +122,10 @@ export class LoadingScene extends Scene {
104
122
  this._displayedProgress = 1;
105
123
  this.updateLoaderBar(1);
106
124
 
107
- // Wait for the player's tap — resolves immediately when tapToStart is
108
- // false (the preloader honours that flag) then enter the game.
125
+ // Wait for the player's tap — resolves immediately when tapToStart is false — then enter the
126
+ // game. This is the preloader's gate and it reads the preloader's config, so it means the same
127
+ // thing on every target: a game-supplied overlay has no say in it, and by now no part in the
128
+ // screen either. It was dismissed at the hand-over above; the player is looking at ours.
109
129
  await waitCSSPreloaderTap();
110
130
  await this.transitionToGame();
111
131
  }
@@ -131,6 +151,60 @@ export class LoadingScene extends Scene {
131
151
  void removeCSSPreloader(this.hostElement());
132
152
  }
133
153
 
154
+ // ─── Hand-over from a game-supplied overlay ────────────
155
+
156
+ /**
157
+ * Swap a game-supplied loading overlay for the engine's own loading screen.
158
+ *
159
+ * The overlay (Artube's) has been on screen since before this bundle was fetched, covering a gap
160
+ * nothing of ours could. Its job ends here, at the first frame the engine paints; the player then
161
+ * gets the game's own brand, progress bar and tap-to-start, exactly as on every other target.
162
+ *
163
+ * The order of the four steps is the whole design, and each is wrong on its own:
164
+ *
165
+ * 0. Wait out whatever the overlay is still owed on screen (`externalOverlayMinDisplayTime`,
166
+ * default 1.5s, plus room for a phase crossfade already in flight). The gap this overlay
167
+ * covers can be under half a second, which is not long enough for a partner's brand to
168
+ * register. Waiting here — BEFORE mounting ours — rather than after is what keeps the two
169
+ * screens' timelines from overlapping: our splash and brand floor start when the player can
170
+ * actually see them, not behind someone else's overlay. On any boot slower than the floor
171
+ * this step costs nothing, and on a non-Artube target it is not reached at all.
172
+ * 1. Mount the preloader, opaque and full-bleed, while theirs is still up. Both are on screen
173
+ * together for a few frames, so there is never a moment with neither, whatever happens next.
174
+ * 2. Wait for that frame to actually be PAINTED — mounting only queues it. Dismissing theirs
175
+ * before the paint is precisely the flash of bare background this ordering exists to avoid.
176
+ * Two `requestAnimationFrame`s: the first callback runs before the frame it belongs to is
177
+ * composited, the second after. Two frames is also enough for Pixi's own rAF-driven ticker
178
+ * to have rendered this scene at least once, so "the loading scene has painted" is literally
179
+ * true by the time step 3 runs.
180
+ * 3. Only then dismiss theirs. Their `hideLoader()` plays a 0.3s fade and removes the element.
181
+ * Not waiting for that fade is deliberate — it is an animation on someone else's element,
182
+ * and blocking a boot on it would be a hang waiting to happen.
183
+ *
184
+ * Which of the two is visually on top is the host page's business, not ours, and it does NOT
185
+ * change the guarantee. On a typical game page (`#game { position: fixed; inset: 0 }`) the fixed
186
+ * container establishes a stacking context, so the preloader's z-index is scoped inside it and
187
+ * Artube's `position: fixed; z-index: 9999` sits above — their fade then crossfades onto our
188
+ * loading screen, which is what was observed live and looks right. On a page where ours wins
189
+ * instead, their fade simply plays underneath, unseen. Either way the seam is covered, because
190
+ * what step 2 buys is that OUR screen is already painted before theirs starts going away.
191
+ */
192
+ private async takeOverFromExternalOverlay(): Promise<void> {
193
+ if (!hasExternalOverlay()) return;
194
+ await externalOverlayHold();
195
+ createCSSPreloader(this.hostElement(), this._config);
196
+ await this.nextPaint();
197
+ releaseExternalOverlay();
198
+ }
199
+
200
+ /** Resolves after the browser has composited at least one frame (see step 2 above). */
201
+ private nextPaint(): Promise<void> {
202
+ if (typeof requestAnimationFrame !== 'function') return Promise.resolve();
203
+ return new Promise<void>((resolve) => {
204
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
205
+ });
206
+ }
207
+
134
208
  // ─── Progress ──────────────────────────────────────────
135
209
 
136
210
  private updateLoaderBar(progress: number): void {
@@ -7,6 +7,12 @@ export {
7
7
  setCSSPreloaderProgress,
8
8
  waitCSSPreloaderTap,
9
9
  removeCSSPreloader,
10
+ adoptExternalOverlay,
11
+ advanceExternalOverlay,
12
+ externalOverlayHold,
13
+ releaseExternalOverlay,
14
+ hasExternalOverlay,
15
+ DEFAULT_EXTERNAL_MIN_DISPLAY_MS,
10
16
  buildLogoSVG,
11
17
  LOADER_BAR_MAX_WIDTH,
12
18
  } from '@energy8platform/platform-core/loading';
package/src/types.ts CHANGED
@@ -37,6 +37,7 @@ export enum Orientation {
37
37
  // existing game-engine consumers keep their imports.
38
38
  import type {
39
39
  LoadingScreenConfig,
40
+ ExternalLoadingOverlay,
40
41
  AssetManifest,
41
42
  AssetBundle,
42
43
  AssetEntry,
@@ -44,6 +45,7 @@ import type {
44
45
 
45
46
  export type {
46
47
  LoadingScreenConfig,
48
+ ExternalLoadingOverlay,
47
49
  AssetManifest,
48
50
  AssetBundle,
49
51
  AssetEntry,