@energy8platform/platform-core 0.30.10 → 0.31.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.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * A loading overlay the GAME owns, covering the gap the engine cannot: from the
3
+ * browser's first paint until the engine's own loading screen is on screen.
4
+ *
5
+ * The case this exists for is Artube. Their platform ships a branded loader
6
+ * whose markup a Vite plugin injects into `index.html`, so it is painted before
7
+ * the game bundle has even been fetched — earlier than any engine code can
8
+ * possibly run. That window (bundle download → Pixi init → SDK handshake) is
9
+ * otherwise a blank page.
10
+ *
11
+ * ── Where it stops ──────────────────────────────────────────────────────────
12
+ * It stops once BOTH are true: the engine's own loading screen has painted its
13
+ * first frame, and the overlay has had its guaranteed time on screen
14
+ * ({@link DEFAULT_EXTERNAL_MIN_DISPLAY_MS}, `loading.externalOverlayMinDisplayTime`).
15
+ * The gap alone is often only a few hundred milliseconds, which is not long
16
+ * enough for a partner's branding to register — let alone for their two-phase
17
+ * screen to reach its second phase.
18
+ *
19
+ * From there the player gets the engine's brand, the engine's progress bar and
20
+ * the engine's tap-to-start — exactly as on every other target. The external
21
+ * overlay covers the gap; it does not replace the loading screen, and it has no
22
+ * say in the tap gate: by then the player is looking at OUR screen.
23
+ *
24
+ * (This inverts an earlier design in which the external overlay REPLACED the
25
+ * CSS preloader for the whole boot. Handing over at the first frame keeps the
26
+ * game's own loading identity on every platform and leaves the built-in
27
+ * preloader's code path untouched — `CSSPreloader.ts` knows nothing about any
28
+ * of this, so non-Artube targets cannot be affected by a change here.)
29
+ *
30
+ * ── Structural, not nominal ─────────────────────────────────────────────────
31
+ * Nothing in `@energy8platform` names Artube's controller. The game passes an
32
+ * instance; we describe the shape (`ExternalLoadingOverlay`). Artube's vendored
33
+ * `LoaderViewController` (`@energy8platform/artube-bridge/loader`) satisfies it
34
+ * with no adapter.
35
+ *
36
+ * ── Module-level state ──────────────────────────────────────────────────────
37
+ * Singleton, like the CSS preloader next door, and for the same reason: the
38
+ * three call sites (boot, hand-over, failure path) are in different files and
39
+ * there is exactly one loading screen per page.
40
+ */
41
+ import type { ExternalLoadingOverlay } from '../types';
42
+
43
+ let adopted: ExternalLoadingOverlay | null = null;
44
+ /** Last value pushed, so a repeated milestone cannot walk the bar backwards. */
45
+ let lastProgress = 0;
46
+ /** When the overlay was adopted — the origin the minimum display time is measured from. */
47
+ let adoptedAt = 0;
48
+ /** How long this overlay is owed on screen; see {@link DEFAULT_EXTERNAL_MIN_DISPLAY_MS}. */
49
+ let minDisplayMs = 0;
50
+ /** When the first progress went out, i.e. when their phase crossfade started. 0 = not yet. */
51
+ let crossfadeStartedAt = 0;
52
+ /** The one pending timer: the hand-over waiting out the floor below. */
53
+ let holdTimer: ReturnType<typeof setTimeout> | null = null;
54
+ let holdResolve: (() => void) | null = null;
55
+
56
+ /**
57
+ * How long a game-supplied overlay is guaranteed on screen, measured from
58
+ * adoption (which is the first thing the boot does, so in practice from the
59
+ * page's first paint).
60
+ *
61
+ * Without a floor the overlay lives exactly as long as the gap it covers, and
62
+ * that gap is short: a warm local boot handed over at ~500ms and dismissed
63
+ * their screen at ~840ms. A partner's branding that flashes past in under a
64
+ * second has not been shown. 1500ms is the user's number, and it is also the
65
+ * smallest one that fits their two-phase screen whole — a 500ms crossfade into
66
+ * the branded phase plus time to read it — with room for the 300ms dismissal
67
+ * fade on top.
68
+ *
69
+ * Overridable per game: `loading.externalOverlayMinDisplayTime`.
70
+ */
71
+ export const DEFAULT_EXTERNAL_MIN_DISPLAY_MS = 1500;
72
+
73
+ /**
74
+ * Artube's partner→branded crossfade, `transition: opacity 0.5s ease` in the
75
+ * markup their Vite plugin injects (vendored verbatim in
76
+ * `@energy8platform/artube-server/vite`). It is triggered by the first
77
+ * `updateProgress` above zero.
78
+ *
79
+ * The floor below is extended to cover it because a crossfade that starts and
80
+ * then gets cut is worse than one that never starts: the player sees a
81
+ * half-formed gradient and a bar that never fills — the three-screens-at-once
82
+ * artefact this integration hit once and rejected (evidence:
83
+ * `rejected-immediate-progress-01-Lartube_P_C.png`). Extending the floor is
84
+ * bounded by construction: only the FIRST progress starts it, and every
85
+ * progress value comes from a boot milestone that precedes the hand-over.
86
+ */
87
+ const PHASE_CROSSFADE_MS = 500;
88
+
89
+ /**
90
+ * Call into the game's overlay without letting it break the boot. A third-party
91
+ * controller is outside our control (Artube's throws if its markup is missing,
92
+ * for one), and none of these calls is worth failing a game over — least of all
93
+ * `hideLoader`, where a throw escaping the teardown path is exactly how an
94
+ * overlay ends up stranded on screen forever.
95
+ */
96
+ function guard(method: string, fn: () => void): void {
97
+ try {
98
+ fn();
99
+ } catch (err) {
100
+ console.warn(`[GameEngine] loading overlay ${method}() threw`, err);
101
+ }
102
+ }
103
+
104
+ /** Whether a game-supplied overlay is currently on screen and owned by us. */
105
+ export function hasExternalOverlay(): boolean {
106
+ return adopted !== null;
107
+ }
108
+
109
+ /**
110
+ * Take ownership of the game's overlay: from here on the engine is responsible
111
+ * for taking it down, including when the boot fails.
112
+ *
113
+ * Called BEFORE anything in the boot that can throw — notably before the
114
+ * container selector is resolved. Until the engine has adopted it, nothing can
115
+ * dismiss it, and Artube's is already on screen from `index.html`.
116
+ *
117
+ * `showLoader()` is Artube's "reveal the progress affordance"; it does not
118
+ * switch their two-phase screen to the branded phase — only progress does, see
119
+ * {@link advanceExternalOverlay}.
120
+ *
121
+ * @param minDisplayMillis how long the overlay is owed on screen before the
122
+ * hand-over may dismiss it (`loading.externalOverlayMinDisplayTime`); defaults
123
+ * to {@link DEFAULT_EXTERNAL_MIN_DISPLAY_MS}. Adoption is where the clock
124
+ * starts, so it is taken here rather than read at the hand-over.
125
+ */
126
+ export function adoptExternalOverlay(
127
+ overlay: ExternalLoadingOverlay,
128
+ minDisplayMillis?: number,
129
+ ): void {
130
+ if (adopted) return; // idempotent: boot step 0 and step 2 both reach here
131
+ adopted = overlay;
132
+ lastProgress = 0;
133
+ crossfadeStartedAt = 0;
134
+ minDisplayMs =
135
+ typeof minDisplayMillis === 'number' && Number.isFinite(minDisplayMillis)
136
+ ? Math.max(0, minDisplayMillis)
137
+ : DEFAULT_EXTERNAL_MIN_DISPLAY_MS;
138
+ adoptedAt = Date.now();
139
+ guard('showLoader', () => overlay.showLoader());
140
+ }
141
+
142
+ /**
143
+ * Report boot progress, as a 0..1 fraction, to a game-supplied overlay.
144
+ *
145
+ * NOT the asset-loading progress the built-in preloader shows — that belongs to
146
+ * the engine's own loading screen, which by then has taken over. This is the
147
+ * pre-first-frame boot: bundle up, Pixi up, SDK handshake done.
148
+ *
149
+ * Sending it is deliberate, not decorative: Artube's loader only crossfades to
150
+ * its BRANDED phase on the first value above zero, so without progress the
151
+ * player never sees the half of their screen the integration exists to show.
152
+ * Values go out as they arrive — the guarantee that the crossfade has room to
153
+ * finish is the dismissal floor ({@link externalOverlayHold}), not a delay
154
+ * here. An earlier design held the first value back for 800ms instead; the
155
+ * floor makes that redundant, and one timer on this overlay is the whole point.
156
+ *
157
+ * Values are clamped to 0..1, kept monotonic, and converted to the PERCENTAGE
158
+ * (0–100) that `ILoaderViewController.updateProgress` documents.
159
+ */
160
+ export function advanceExternalOverlay(fraction: number): void {
161
+ const overlay = adopted;
162
+ if (!overlay) return;
163
+ const clamped = Number.isFinite(fraction) ? Math.max(0, Math.min(1, fraction)) : 0;
164
+ if (clamped <= lastProgress) return;
165
+ lastProgress = clamped;
166
+ // The first non-zero value is what starts their partner→branded crossfade, so
167
+ // it is also what the floor has to cover.
168
+ if (clamped > 0 && crossfadeStartedAt === 0) crossfadeStartedAt = Date.now();
169
+ guard('updateProgress', () => overlay.updateProgress(clamped * 100));
170
+ }
171
+
172
+ /**
173
+ * Milliseconds still owed to the overlay before the hand-over may dismiss it:
174
+ * the minimum display time, extended when needed so a phase crossfade that has
175
+ * started can finish. Zero once both are satisfied — and zero when there is no
176
+ * overlay, so nothing on a normal target ever waits.
177
+ */
178
+ function remainingHoldMs(): number {
179
+ if (!adopted) return 0;
180
+ const now = Date.now();
181
+ const floor = Math.max(
182
+ adoptedAt + minDisplayMs,
183
+ crossfadeStartedAt === 0 ? 0 : crossfadeStartedAt + PHASE_CROSSFADE_MS,
184
+ );
185
+ return Math.max(0, floor - now);
186
+ }
187
+
188
+ /**
189
+ * Wait until the overlay has had its guaranteed time on screen. The hand-over
190
+ * awaits this BEFORE it mounts the engine's own loading screen, so the two
191
+ * timelines do not overlap: their screen is whole and undisturbed for the whole
192
+ * window, then ours mounts, paints, and only then takes over.
193
+ *
194
+ * Resolves immediately when no overlay is adopted (every non-Artube target) or
195
+ * when the floor has already passed (any boot slower than it, which is the
196
+ * normal case in production — this costs nothing there).
197
+ *
198
+ * NOT part of the failure path on purpose: {@link releaseExternalOverlay} is
199
+ * synchronous and immediate, so a boot that throws takes the overlay down at
200
+ * once instead of stranding the player behind a courtesy delay. A release also
201
+ * settles a wait already in progress.
202
+ */
203
+ export function externalOverlayHold(): Promise<void> {
204
+ // Reaching here means the boot is done and the only thing left is the wait, so the bar is
205
+ // finished too. Without this it freezes at the last boot milestone (0.85 live) and their screen
206
+ // is taken away with the bar still short — a bar that never fills, which is the same artefact in
207
+ // a different costume. Only when a bar is actually on screen: if no progress ever went out the
208
+ // player is still on the first phase, and starting a crossfade at the hand-over is precisely
209
+ // what must not happen.
210
+ if (crossfadeStartedAt !== 0) advanceExternalOverlay(1);
211
+ const remaining = remainingHoldMs();
212
+ if (remaining <= 0) return Promise.resolve();
213
+ return new Promise<void>((resolve) => {
214
+ const finish = () => {
215
+ if (holdTimer !== null) clearTimeout(holdTimer);
216
+ holdTimer = null;
217
+ holdResolve = null;
218
+ resolve();
219
+ };
220
+ holdResolve = finish;
221
+ holdTimer = setTimeout(finish, remaining);
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Dismiss the overlay and forget it. Idempotent — this is reached both on the
227
+ * normal hand-over and defensively from the failure paths, and a second
228
+ * `hideLoader()` must not fire.
229
+ *
230
+ * @returns whether this call is the one that dismissed it. Callers that can run
231
+ * BEFORE adoption (`createSlotGame`'s `fatal`, which refuses some launches
232
+ * before `GameApplication` exists) use the `false` to fall back to hiding the
233
+ * game's overlay directly.
234
+ */
235
+ export function releaseExternalOverlay(): boolean {
236
+ // Settle a hand-over that is still waiting out the floor, even if there is
237
+ // nothing to release. This is the failure path: a boot that threw must not
238
+ // leave a timer holding the process open (Node-side tests) or a promise that
239
+ // resolves into a teardown that already happened.
240
+ if (holdResolve) holdResolve();
241
+
242
+ const overlay = adopted;
243
+ if (!overlay) return false;
244
+ adopted = null;
245
+ lastProgress = 0;
246
+ crossfadeStartedAt = 0;
247
+ guard('hideLoader', () => overlay.hideLoader());
248
+ return true;
249
+ }
@@ -4,6 +4,19 @@ export {
4
4
  waitCSSPreloaderTap,
5
5
  removeCSSPreloader,
6
6
  } from './CSSPreloader';
7
+ /**
8
+ * A game-supplied overlay covering the gap before the engine's own loading
9
+ * screen paints (Artube's branded loader). Deliberately NOT part of
10
+ * `CSSPreloader.ts`: the built-in preloader must have no branch for it.
11
+ */
12
+ export {
13
+ adoptExternalOverlay,
14
+ advanceExternalOverlay,
15
+ externalOverlayHold,
16
+ releaseExternalOverlay,
17
+ hasExternalOverlay,
18
+ DEFAULT_EXTERNAL_MIN_DISPLAY_MS,
19
+ } from './ExternalOverlay';
7
20
  export { buildLogoSVG, LOADER_BAR_MAX_WIDTH } from './logo';
8
21
  export { VARIANTS, DEFAULT_VARIANT_NAME } from './variants';
9
22
  export type {
@@ -11,4 +24,10 @@ export type {
11
24
  PreloaderVariantHandle,
12
25
  PreloaderVariantName,
13
26
  } from './variants';
14
- export type { LoadingScreenConfig, AssetManifest, AssetBundle, AssetEntry } from '../types';
27
+ export type {
28
+ LoadingScreenConfig,
29
+ ExternalLoadingOverlay,
30
+ AssetManifest,
31
+ AssetBundle,
32
+ AssetEntry,
33
+ } from '../types';
@@ -4,41 +4,50 @@ import type { PreloaderVariant, PreloaderVariantHandle } from './types';
4
4
  const RECT_ID = 'ge-vm-loader-rect';
5
5
  const TEXT_ID = 'ge-vm-loader-text';
6
6
 
7
- /** Max width (SVG units) of the voidmoon loader bar fill. Spans the first 'o' → end of the crescent. */
8
- const LOADER_BAR_MAX_WIDTH = 751;
7
+ /** Left edge (SVG units) of the loader bar aligned with the "V" of the wordmark. */
8
+ const LOADER_BAR_X = 249.79;
9
+
10
+ /** Max width (SVG units) of the voidmoon loader bar fill. Spans the wordmark: "V" → end of the final "N". */
11
+ const LOADER_BAR_MAX_WIDTH = 519;
9
12
 
10
13
  /**
11
- * "voidmoon" wordmark — the official logo, embedded verbatim as SVG outlines:
12
- * thin white letters with the final "o" of "moon" rendered as a purple crescent
13
- * (#9D63FE). The glyphs live in a flipped group (`translate(0,941) scale(1,-1)`)
14
- * exactly as exported; the loader bar + status text are added beneath it in the
15
- * outer (un-flipped) viewBox space.
14
+ * voidmoon logo — the official lockup, embedded verbatim as SVG outlines:
15
+ * the crescent-moon-and-spark mark on the left, the "VOIDMOON" wordmark on the
16
+ * right, all in #f5f5f5 (filled *and* stroked, exactly as exported the stroke
17
+ * is what gives the thin glyphs their weight). The export's `.st0` / `.st1`
18
+ * classes are inlined as presentation attributes on two wrapper groups so the
19
+ * logo carries no global CSS into the host page.
20
+ *
21
+ * The loader bar + status text are added beneath the lockup in the same
22
+ * viewBox space; the viewBox is taller than the artwork (140.2) to make room.
16
23
  */
17
- const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="301 339 1075 335" class="ge-vm-logo-svg" style="overflow:visible" role="img">
24
+ const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 769.28 215" class="ge-vm-logo-svg" style="overflow:visible" role="img">
18
25
  <title>voidmoon</title>
19
- <g transform="translate(0,941) scale(1,-1)">
20
- <g fill="#ffffff" fill-rule="evenodd">
21
- <path d="M627 562 c-4 -2 -7 -6 -7 -12 0 -13 14 -18 23 -10 3 3 3 5 4 9 0 7 -2 11 -7 13 -5 2 -9 2 -13 0z"/>
22
- <path d="M780 531 l0 -31 -6 5 c-14 14 -35 17 -56 10 -24 -8 -40 -28 -42 -53 0 -11 1 -19 6 -30 8 -16 22 -27 40 -32 8 -2 23 -2 31 0 18 4 34 17 42 33 2 5 4 11 5 14 1 3 1 24 1 61 l0 55 -10 0 -11 0 0 -32z m-24 -38 c20 -10 28 -31 20 -50 -3 -6 -13 -16 -19 -19 -21 -10 -45 -2 -56 18 -2 4 -2 7 -3 14 -1 14 4 24 15 33 8 6 15 8 27 8 9 -1 10 -1 16 -4z"/>
23
- <path d="M520 518 c-26 -6 -45 -25 -50 -51 -1 -9 -1 -11 0 -19 3 -12 7 -21 15 -30 8 -8 16 -13 28 -17 6 -2 9 -2 19 -2 10 0 13 0 20 2 21 8 36 24 41 46 1 8 1 13 0 22 -5 23 -21 40 -44 47 -6 2 -23 3 -29 2z m29 -25 c9 -4 16 -11 20 -19 2 -5 3 -6 3 -15 0 -10 -1 -11 -3 -16 -8 -15 -23 -24 -40 -23 -9 1 -15 3 -22 8 -22 15 -21 48 2 63 7 4 14 6 24 6 8 -1 10 -1 16 -4z"/>
24
- <path d="M869 518 c-21 -4 -35 -18 -40 -38 -1 -6 -1 -14 -1 -43 l1 -35 10 0 11 0 0 37 c0 33 1 38 2 41 3 7 7 11 13 14 5 2 8 3 13 3 11 0 20 -5 25 -16 l3 -5 0 -37 1 -37 10 0 10 0 0 37 c0 35 1 37 3 42 5 10 14 16 26 16 11 0 21 -7 25 -17 2 -5 2 -7 2 -41 0 -19 0 -36 1 -37 0 -1 3 -1 11 -1 l10 1 0 35 c0 23 0 38 -1 42 -2 9 -8 21 -14 27 -20 17 -51 17 -68 -1 l-5 -5 -5 5 c-9 9 -19 13 -31 14 -5 0 -10 0 -12 -1z"/>
25
- <path d="M1077 517 c-9 -1 -20 -7 -27 -12 -7 -6 -14 -16 -18 -25 -4 -11 -5 -25 -3 -35 5 -21 21 -37 42 -44 38 -12 78 14 81 53 1 15 -4 31 -14 43 -14 17 -38 25 -61 20z m25 -22 c19 -5 31 -24 28 -42 -4 -26 -34 -41 -59 -29 -20 10 -27 32 -17 52 8 16 29 25 48 19z"/>
26
- <path d="M1282 516 c-18 -5 -34 -21 -38 -39 -1 -4 -1 -18 -1 -40 l1 -35 10 -1 10 0 0 32 c0 20 0 35 1 38 2 11 8 18 18 23 7 3 18 3 26 0 6 -3 12 -9 15 -16 2 -4 3 -6 3 -40 l1 -36 10 0 11 0 0 33 c0 38 0 43 -6 54 -7 14 -19 24 -34 28 -7 1 -20 1 -27 -1z"/>
27
- <path d="M329 515 c0 -1 2 -5 4 -9 2 -5 13 -28 24 -53 12 -24 21 -45 22 -46 2 -4 10 -7 15 -7 4 0 11 3 13 6 3 2 50 105 50 108 0 1 -3 1 -11 1 l-11 0 -7 -14 c-3 -8 -12 -28 -20 -45 -7 -17 -13 -31 -14 -31 -1 -1 -3 5 -17 35 -19 43 -23 53 -24 54 -1 1 -5 1 -13 1 -6 0 -11 0 -11 0z"/>
28
- <path d="M623 514 c0 -1 0 -26 0 -57 l1 -55 10 0 10 0 0 56 0 57 -10 0 c-7 0 -10 0 -11 -1z"/>
29
- </g>
30
- <g fill="#9D63FE" fill-rule="evenodd">
31
- <path d="M1150 515 c-3 0 -6 -1 -6 -1 0 -1 2 -2 5 -2 10 -4 26 -17 31 -27 11 -21 8 -44 -7 -62 -5 -7 -17 -16 -24 -18 -3 -1 -5 -2 -4 -2 0 -2 16 -3 24 -3 35 3 59 40 49 74 -2 8 -7 18 -13 24 -5 6 -15 13 -23 16 -8 3 -24 4 -32 1z"/>
32
- </g>
26
+ <g fill="#f5f5f5" stroke="#f5f5f5" stroke-miterlimit="10">
27
+ <polygon points="274.76 98.02 249.79 48.07 256.03 48.07 274.76 85.54 293.49 48.07 299.74 48.07 274.76 98.02"/>
28
+ <path d="M325.51,70.2h6.24c1.12-8.22,7.63-14.65,15.83-15.85v-6.24c-11.99,1.24-20.85,10.36-22.07,22.1ZM353.82,48.1v6.24c9.03,1.32,15.48,9.27,15.48,18.67,0,10.33-8.4,18.73-18.73,18.73-9.33,0-17.41-6.36-18.82-15.3h-6.24c1.49,12.45,11.94,21.55,25.06,21.55,14.14,0,24.97-11.14,24.97-24.97,0-12.92-8.88-23.58-21.72-24.91Z"/>
29
+ <rect x="400.39" y="48.08" width="6.24" height="49.95"/>
30
+ <path d="M437.62,54.32l-6.14-6.24h19.14c13.15,0,23.81,10.85,23.81,24.22v.63c0,13.91-10.99,25.1-24.54,25.1-9.62,0-18.41,0-18.41,0l6.14-6.24h12.27c10.17,0,18.41-8.39,18.41-18.73h0c0-10.34-8.24-18.73-18.41-18.73h-12.27Z"/>
31
+ <polygon points="499.59 98.02 505.83 98.02 505.85 63.24 524.56 79.29 543.29 64.35 543.29 98.02 549.54 98.02 549.54 48.07 524.62 70.2 499.59 48.07 499.59 98.02"/>
32
+ <polygon points="725.07 68.91 731.32 74.56 731.32 98.02 725.07 98.02 725.07 68.91"/>
33
+ <polygon points="768.78 48.07 768.78 98.01 762.53 92.38 753.31 84.06 753.31 84.05 725.07 58.51 725.07 48.1 731.32 53.73 762.53 81.87 762.53 48.07 768.78 48.07"/>
34
+ <path d="M602.55,48.03v6.24c8.22,1.12,14.65,7.63,15.85,15.83h6.24c-1.24-11.99-10.36-20.85-22.1-22.07ZM624.65,76.34h-6.24c-1.32,9.03-9.27,15.48-18.67,15.48-10.33,0-18.73-8.4-18.73-18.73,0-9.33,6.36-17.41,15.3-18.82v-6.24c-12.45,1.49-21.55,11.94-21.55,25.06,0,14.14,11.14,24.97,24.97,24.97,12.92,0,23.58-8.88,24.91-21.72Z"/>
35
+ <path d="M699.92,75.89h-6.24c-1.12,8.22-7.63,14.65-15.83,15.85v6.24c11.99-1.24,20.85-10.36,22.07-22.1ZM671.6,97.99v-6.24c-9.03-1.32-15.48-9.27-15.48-18.67,0-10.33,8.4-18.73,18.73-18.73,9.33,0,17.41,6.36,18.82,15.3h6.24c-1.49-12.45-11.94-21.55-25.06-21.55-14.14,0-24.97,11.14-24.97,24.97,0,12.92,8.88,23.58,21.72,24.91Z"/>
36
+ </g>
37
+ <g fill="#f5f5f5" stroke="#f5f5f5" stroke-miterlimit="10" stroke-width="2">
38
+ <path d="M199.39,73.47h.01c-1.76,36.6-31.98,65.73-69.01,65.73s-67.52-29.39-69.04-66.2c-.9-.53-1.53-1.47-1.63-2.57l-22.61,2.67c-1.75.2-3.12,1.6-3.3,3.35l-2.47,24.86-3.11-24.95c-.21-1.7-1.56-3.06-3.28-3.25L.12,70.11l25.29-1.97c1.83-.14,3.28-1.57,3.46-3.38l2.47-25.88,2.47,25.88c.18,1.81,1.63,3.24,3.45,3.38l22.45,1.74c.06-1.14.7-2.13,1.64-2.68C62.87,30.38,93.2,1,130.39,1s67.25,29.12,69.01,65.72h0s-.01,0-.01,0c-1.74-30.76-27.24-55.15-58.43-55.15s-58.52,26.2-58.52,58.52,26.2,58.52,58.52,58.52,56.69-24.4,58.43-55.15Z"/>
39
+ <path d="M202.84,70.1c0,1.86-1.51,3.38-3.37,3.38-.02,0-.05,0-.07,0,.02-.25.02-.49.03-.74-.01.25-.03.49-.04.74-1.83-.04-3.3-1.53-3.3-3.37s1.47-3.34,3.3-3.37c.01.25.03.49.04.74-.01-.25-.01-.49-.03-.74.02,0,.05,0,.07,0,1.86,0,3.37,1.51,3.37,3.38Z"/>
40
+ <line x1="232.26" y1="70.1" x2="202.84" y2="70.1"/>
41
+ <line x1="196.09" y1="70.1" x2="166.58" y2="70.1"/>
33
42
  </g>
34
43
 
35
- <rect x="469" y="600" width="751" height="9" rx="4.5" fill="rgba(255,255,255,0.12)"/>
44
+ <rect x="${LOADER_BAR_X}" y="170" width="${LOADER_BAR_MAX_WIDTH}" height="6.5" rx="3.25" fill="rgba(255,255,255,0.12)"/>
36
45
  <clipPath id="vm-loader-clip">
37
- <rect id="${RECT_ID}" x="469" y="600" width="0" height="9" rx="4.5" class="ge-vm-clip-rect"/>
46
+ <rect id="${RECT_ID}" x="${LOADER_BAR_X}" y="170" width="0" height="6.5" rx="3.25" class="ge-vm-clip-rect"/>
38
47
  </clipPath>
39
- <rect x="469" y="600" width="751" height="9" rx="4.5" fill="#9D63FE" clip-path="url(#vm-loader-clip)"/>
48
+ <rect x="${LOADER_BAR_X}" y="170" width="${LOADER_BAR_MAX_WIDTH}" height="6.5" rx="3.25" fill="#9D63FE" clip-path="url(#vm-loader-clip)"/>
40
49
 
41
- <text id="${TEXT_ID}" x="844.5" y="650" text-anchor="middle" class="ge-vm-text">Loading...</text>
50
+ <text id="${TEXT_ID}" x="${LOADER_BAR_X + LOADER_BAR_MAX_WIDTH / 2}" y="205" text-anchor="middle" class="ge-vm-text">Loading...</text>
42
51
  </svg>`;
43
52
 
44
53
  export const voidmoonVariant: PreloaderVariant = {
@@ -72,7 +81,7 @@ export const voidmoonVariant: PreloaderVariant = {
72
81
 
73
82
  @keyframes ge-vm-fill {
74
83
  0% { width: 0; }
75
- 50% { width: 751; }
84
+ 50% { width: 519px; }
76
85
  100% { width: 0; }
77
86
  }
78
87
 
@@ -84,9 +93,9 @@ export const voidmoonVariant: PreloaderVariant = {
84
93
  .ge-vm-text {
85
94
  fill: rgba(255, 255, 255, 0.6);
86
95
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
87
- font-size: 20px;
96
+ font-size: 14px;
88
97
  font-weight: 600;
89
- letter-spacing: 3px;
98
+ letter-spacing: 2.2px;
90
99
  animation: ge-vm-pulse 1.5s ease-in-out infinite;
91
100
  }
92
101
 
package/src/types.ts CHANGED
@@ -44,7 +44,62 @@ export interface AssetManifest {
44
44
  // Used by the Energy8 CSS preloader (in this package) and by
45
45
  // engine-specific loading scenes (in @energy8platform/game-engine etc.).
46
46
 
47
+ /**
48
+ * A loading overlay the GAME owns, covering the gap BEFORE the engine's own loading screen exists:
49
+ * from the browser's first paint to the first frame the engine paints. It does not replace the
50
+ * loading screen — see `LoadingScreenConfig.externalOverlay`.
51
+ *
52
+ * Described structurally on purpose. The implementation this exists for is Artube's
53
+ * `LoaderViewController` (vendored at `@energy8platform/artube-bridge/loader`), whose markup their
54
+ * Vite plugin injects into `index.html` so the overlay is painted before our bundle is even
55
+ * fetched. Naming that type here would tie every consumer of platform-core to the Artube packages;
56
+ * the shape is satisfied with no adapter, and nothing in `@energy8platform/platform-core` knows
57
+ * Artube exists.
58
+ *
59
+ * The engine's use of it, in order:
60
+ * - `showLoader()` once, at the very start of boot, before anything that can throw;
61
+ * - `updateProgress(0..100)` at boot milestones (Pixi up, SDK handshake done, subsystems up) —
62
+ * real progress through the gap, not the asset loading that follows;
63
+ * - `hideLoader()` the moment the engine's loading screen has painted its first frame — AND on
64
+ * every failure path, so a boot that throws can never leave the overlay on screen forever.
65
+ */
66
+ export interface ExternalLoadingOverlay {
67
+ /** Reveal the overlay / its progress affordance. Called once, at the start of boot. */
68
+ showLoader(): void;
69
+ /** Loading progress as a PERCENTAGE, 0–100 (what `ILoaderViewController` expects — the engine's
70
+ * internal 0..1 fraction is converted at the seam). */
71
+ updateProgress(value: number): void;
72
+ /** Dismiss the overlay. Called once; must tolerate being called after a failed boot. */
73
+ hideLoader(): void;
74
+ }
75
+
47
76
  export interface LoadingScreenConfig {
77
+ /**
78
+ * A game-supplied loading overlay covering ONLY the gap before the engine's own loading screen
79
+ * paints — Artube's branded loader is the case this exists for. It is not a replacement: once the
80
+ * loading screen has painted its first frame, the overlay is dismissed and the rest of the boot
81
+ * (brand, progress bar, tap-to-start, minimum display time) is exactly what it is on every other
82
+ * target. Every other option in this object therefore still applies.
83
+ *
84
+ * The two never stack visibly: the built-in preloader is mounted only at the hand-over, and it
85
+ * covers the external overlay (z-index) from the frame it appears in, so there is no gap, no
86
+ * flash and no bare background at the seam.
87
+ */
88
+ externalOverlay?: ExternalLoadingOverlay;
89
+ /**
90
+ * How long {@link externalOverlay} is guaranteed on screen, in ms, measured from the boot's very
91
+ * first step. Default 1500.
92
+ *
93
+ * The gap this overlay covers is short — a warm boot hands over in a few hundred milliseconds —
94
+ * and a partner's branding that flashes past in under a second has not been shown. The floor also
95
+ * gives a two-phase overlay (Artube's) room to reach its second phase and settle there instead of
96
+ * being cut mid-crossfade. It costs a slow boot nothing: the hand-over happens later than this
97
+ * anyway.
98
+ *
99
+ * Distinct from {@link minDisplayTime}, which is the minimum for the engine's OWN loading screen
100
+ * and is measured from the hand-over. A game that sets both is asking for the sum.
101
+ */
102
+ externalOverlayMinDisplayTime?: number;
48
103
  /**
49
104
  * Which visual identity the CSS preloader renders. Defaults to `'energy8'`;
50
105
  * an unknown value falls back to the default. Ignored when `cssPreloaderHTML`