diff --git a/docs/plans/splash-screen-refactor.md b/docs/plans/splash-screen-refactor.md new file mode 100644 index 0000000..8037d62 --- /dev/null +++ b/docs/plans/splash-screen-refactor.md @@ -0,0 +1,552 @@ +# Plan: Replace Lit.js Loading Screen with Three.js Splash Screen + +**Branch:** `refactor-runtime-loading` +**Packages touched:** `@jolly-pixel/runtime`, `@jolly-pixel/engine` (read-only reference) + +--- + +## 1. Goal & Motivation + +The current loading screen is a Lit.js web component (``) that lives entirely +outside Three.js — it is an HTML/CSS overlay appended to `document.body`. This creates several +problems: + +- **Extra dependency.** `lit` is a non-trivial addition to a library that otherwise has no web + component framework dependency. +- **Disconnected from the engine.** Progress, errors and the completion state are fed via + imperative method calls (`setProgress`, `error`, `complete`) with no integration into the engine's + scene/actor lifecycle. +- **Not customizable.** There is no extension point. Game developers cannot substitute their own + branded splash screen without forking the library. +- **No "click to start" gate.** The runtime starts immediately on completion, preventing browsers + (notably Chrome) from unlocking the `AudioContext` via the required user gesture. + +**Goal:** Replace `Loading.ts` and its Lit dependency with a `SplashScene` that: + +1. Renders entirely within the existing `WebGLRenderer` canvas using Three.js primitives and the + engine's `UIRenderer` / `UISprite` / `UIText` system. +2. Adds a **click-to-start** gate after loading completes. +3. Exposes a clean, stable `SplashScreen` interface so developers can inject their own branded + splash screen. +4. Removes `lit` from the runtime's production dependencies. + +--- + +## 2. Constraints & Non-Goals + +| Constraint | Reasoning | +|---|---| +| `UIText` may use `CSS2DObject` internally | Accepted — the user explicitly allowed this. | +| Logo as a `THREE.Texture` (PNG) | SVG loading via `TextureLoader` is unreliable; use a pre-rasterised PNG. | +| No 1-to-1 visual parity with the Lit design | Animations, shimmer, blur effects are not ported. A clean, simple visual is acceptable. | +| No changes to `@jolly-pixel/engine` sources | The UI system is consumed as-is; no new engine APIs are added. | +| `loadRuntime()` public signature stays compatible | `LoadRuntimeOptions` gains an optional field; no breaking change. | + +--- + +## 3. Current Architecture (summary) + +``` +loadRuntime(runtime, options) + │ + ├─ getGPUTier() (kicks off async) + ├─ runtime.canvas.style.opacity = "0" ← hides the WebGL canvas + ├─ created and appended to document.body ← Lit web component + ├─ runtime.manager.onProgress → loadingComponent.setProgress() + │ + ├─ await loadingDelay (850 ms) + ├─ await gpuTierPromise + ├─ await Systems.Assets.loadAssets(...) + │ onStart → loadingComponent.setAsset() + │ + ├─ await loadingComponent.complete() ← CSS fade-out, remove from DOM + ├─ runtime.canvas.style.opacity = "1" ← reveals WebGL canvas + └─ runtime.start() ← starts the animation loop +``` + +Key issue: `runtime.start()` (and therefore `world.connect()` + the `setAnimationLoop`) is called +**at the very end**, so there is no engine lifecycle available during loading. + +--- + +## 4. Proposed Architecture + +### 4.1. Central idea: start the loop early + +`Runtime.start()` calls `world.connect()` then `renderer.setAnimationLoop(...)`. Its `#isRunning` +guard makes it idempotent — a second call is a no-op. The refactored `loadRuntime()` calls +`runtime.start()` **at the beginning**, so the engine's actor lifecycle is available for the +splash scene. When `loadRuntime()` resolves the loop is already running; no change is needed in +user code. + +Because the splash scene fills the entire canvas, the canvas no longer needs to be hidden via +`style.opacity = "0"`. That workaround is removed. + +### 4.2. High-level flow + +``` +loadRuntime(runtime, options) + │ + ├─ getGPUTier() (kicks off async) + │ + ├─ splashScreen = options.splashScreen ?? new DefaultSplashScreen() + ├─ world.sceneManager.appendScene(splashScreen.scene) ← overlay on top of any existing scene + ├─ runtime.start() ← world.connect() + animation loop START + │ First frame: SplashScene actors awake, UIRenderer mounts CSS2DRenderer + │ + ├─ await timers.setTimeout(loadingDelay) (≥ 1 frame for awake/start to have run) + ├─ await gpuTierPromise + │ → splashScreen.onProgress() / splashScreen.onError() + │ + ├─ await Systems.Assets.loadAssets(...) + │ onStart → splashScreen.onAssetStart() + │ runtime.manager.onProgress → splashScreen.onProgress() + │ + ├─ splashScreen.onLoadComplete() ← shows "Click to start" + ├─ await splashScreen.waitForUserGesture() ← resolves on first pointer-down + │ + ├─ await splashScreen.complete() ← fade-out, scene removed from SceneManager + └─ (runtime loop continues with game scene) +``` + +### 4.3. `SplashScreen` interface + +Defined in `packages/runtime/src/splash/SplashScreen.ts`. + +```ts +export interface SplashScreen { + /** + * The Scene instance that the splash screen manages. + * loadRuntime() appends this to the SceneManager as an overlay. + */ + readonly scene: Systems.Scene; + + /** + * Called once after the Scene has been appended and runtime.start() has been + * called. Use this to perform any setup that requires the world to be connected + * (e.g. resolving world bounds, loading textures via LoadingManager). + */ + onSetup(world: Systems.World): void; + + /** Update the loading bar. */ + onProgress(loaded: number, total: number): void; + + /** Update the current-asset label. */ + onAssetStart(asset: Systems.Asset): void; + + /** Switch to the error state. */ + onError(error: Error): void; + + /** + * Called when all assets are loaded. The implementation should now display + * a "click / tap to start" prompt and resolve waitForUserGesture(). + */ + onLoadComplete(): void; + + /** + * Returns a Promise that resolves after the user has produced a pointer-down + * or keydown event (i.e. the required user gesture for AudioContext unlock). + */ + waitForUserGesture(): Promise; + + /** + * Trigger the outro animation and remove the scene. + * Resolves when the transition is fully complete. + */ + complete(): Promise; +} +``` + +### 4.4. `DefaultSplashScreen` + +New file: `packages/runtime/src/splash/DefaultSplashScreen.ts` + +Extends `Systems.Scene` and also implements `SplashScreen`. + +#### Actor / component layout + +``` +World.SceneManager (overlay) +└── SplashScene (DefaultSplashScreen extends Scene) + │ + ├── Actor "UIRoot" + │ └── UIRenderer ← one CSS2DRenderer, ortho camera + │ + ├── Actor "Background" + │ └── UISprite size=screenSize color=#1a1a2e anchor=center/center + │ + ├── Actor "Logo" + │ └── UISprite size=320x120 map=logoTexture anchor=center/center offset.y=+60 + │ + ├── Actor "ProgressTrack" + │ └── UISprite size=400x8 color=#333344 anchor=center/center offset.y=-30 + │ + ├── Actor "ProgressFill" + │ └── UISprite size=400x8 color=#4a8fd8 anchor=center/center offset.y=-30 + │ pivot.x=0 (left-anchored) + │ mesh.scale.x is set to progress [0..1] each frame + │ + ├── Actor "AssetLabel" + │ └── UISprite (invisible, 400x20, anchor=center, offset.y=-48) + │ └── UIText "Loading runtime…" fontSize=11px color=#aaaacc + │ + ├── Actor "ClickPrompt" (hidden until onLoadComplete()) + │ └── UISprite (invisible, 400x30, anchor=center/center, offset.y=-60) + │ └── UIText "Click anywhere to start" fontSize=16px color=#ffffff + │ + └── Actor "ErrorPanel" (hidden until onError()) + └── UISprite (invisible, 500x200, color=#1c0a00, anchor=center/center) + ├── UIText (message) color=#ef5350 fontSize=13px + └── UIText (stack) color=#90a4ae fontSize=10px +``` + +> **Logo texture:** `THREE.TextureLoader` with `transparent: true`. +> The existing `jollypixel-full-logo-min.svg` should be exported to +> `jollypixel-full-logo.png` (≥ 480×140 px) and placed alongside the current SVG. +> The path is resolved relative to the HTML page, same as today. + +#### Progress bar scaling trick + +`ProgressFill` uses `UINode` with `pivot = { x: 0, y: 0.5 }` and `anchor = { x: "left" }`. +Its `UISprite.mesh` is a `PlaneGeometry(1, 8)` with `mesh.scale.x = progress * 400`. +This avoids rebuilding geometry and does not require a canvas texture. + +The `UINode` offset is set so that the left edge of the fill aligns with the left edge of the track. + +#### Visibility control + +Rather than removing actors, visibility is toggled via: +- `UISprite.mesh.visible = false/true` +- `UIText` element `style.display = "none"/"block"` + +State transitions are therefore frame-instant (no awaits inside state changes). + +#### State machine + +``` + ┌──────────────┐ + │ LOADING │ (initial state) + │ progress bar │ + │ asset label │ + └──────┬────────┘ + │ all assets loaded (onLoadComplete) + ▼ + ┌──────────────┐ + │ READY │ + │ progress=100% │ + │ click prompt │ + └──────┬────────┘ + │ pointer-down or keydown anywhere + ▼ + ┌──────────────┐ + │ FADING OUT │ + │ alpha 1→0 │ + │ over 600 ms │ + └──────┬────────┘ + │ + ▼ + COMPLETE (scene removed, complete() resolves) + +LOADING or READY → ERROR (on any onError() call) + ┌──────────────┐ + │ ERROR │ + │ error panel │ + │ (no exit) │ + └──────────────┘ +``` + +#### Fade-out technique + +There is no `THREE.Scene.fog`-based fade. Instead, the `Background` UISprite's `MeshBasicMaterial` +alpha is animated from 1 to 0 over 600 ms using the scene's `update(deltaTime)` method, driving a +`#fadeAlpha` accumulator. All other sprites share the same `MeshBasicMaterial` alpha. + +Because `UIText` is a `CSS2DObject` (a DOM element), its opacity is driven via `element.style.opacity`. + +#### `waitForUserGesture()` implementation + +```ts +waitForUserGesture(): Promise { + return new Promise((resolve) => { + // UISprite signals are only available after awake; background sprite receives all clicks + this.#backgroundSprite.onClick.once(() => resolve()); + // Keyboard fallback + this.#keydownHandler = () => resolve(); + window.addEventListener("keydown", this.#keydownHandler, { once: true }); + }); +} +``` + +The "Click anywhere" prompt is attached to the `Background` sprite (full-screen), so any click +resolves the promise. + +### 4.5. `loadRuntime()` refactor + +``` +packages/runtime/src/index.ts (modified) +``` + +Key changes vs today: + +| Area | Old | New | +|---|---|---| +| Canvas hidden | `canvas.style.opacity = "0"` immediately | Removed entirely | +| Loading UI | `document.createElement("jolly-loading")` | `new DefaultSplashScreen()` (or custom) | +| Loop start | At the very end, after `complete()` | **At the beginning**, after appendScene | +| Click gate | None | `await splashScreen.waitForUserGesture()` | +| Error display | `loadingComponent.error(error)` | `splashScreen.onError(error)` | +| Canvas reveal | `canvas.style.opacity = "1"` | Removed (canvas always visible) | +| Runtime.start() | Called once at end | Called once at start; idempotent guard prevents double-call | + +Simplified pseudo-code: + +```ts +export async function loadRuntime( + runtime: Runtime, + options: LoadRuntimeOptions = {} +) { + const { loadingDelay = 850, splashScreen: splashFactory } = options; + + const gpuTierPromise = getGPUTier(); + const splash: SplashScreen = splashFactory + ? (typeof splashFactory === "function" ? splashFactory() : splashFactory) + : new DefaultSplashScreen(); + + runtime.world.sceneManager.appendScene(splash.scene); + runtime.start(); + // world.connect() + setAnimationLoop() called here; + // first frame will awake/start all SplashScene actors. + + splash.onSetup(runtime.world); + + // Prevent keypress events from leaking to a parent window + runtime.canvas.addEventListener("keypress", (event) => event.preventDefault()); + // Focus canvas on any document click (separate from click-to-start) + document.addEventListener("click", () => runtime.canvas.focus()); + + let loadingComplete = false; + const loadingCompletePromise = new Promise((resolve) => { + runtime.manager.onProgress = (_, loaded, total) => { + splash.onProgress(loaded, total); + if (loaded >= total && !loadingComplete) { + loadingComplete = true; + setTimeout(() => resolve(), 100); + } + }; + }); + + try { + if (loadingDelay > 0) { + await timers.setTimeout(loadingDelay); + } + + const { fps, isMobile = false, tier } = await gpuTierPromise; + runtime.world.setFps(fps ?? 60); + runtime.world.renderer.getSource().setPixelRatio(getDevicePixelRatio(isMobile)); + if (tier < 1) { + throw new Error("GPU is not powerful enough to run this game"); + } + + const context = { manager: runtime.manager }; + setTimeout(() => { + Systems.Assets.autoload = true; + Systems.Assets.scheduleAutoload(context); + }); + + if (Systems.Assets.waiting.size > 0) { + await Systems.Assets.loadAssets(context, { + onStart: (asset) => splash.onAssetStart(asset) + }); + + if (loadingComplete) { + await loadingCompletePromise; + } + else { + splash.onProgress(1, 1); + await timers.setTimeout(100); + } + } + + splash.onLoadComplete(); + await splash.waitForUserGesture(); + await splash.complete(); + } + catch (error: any) { + splash.onError(error); + // Loop keeps running; splash stays visible with error panel. + // loadRuntime() does NOT resolve on error — game never starts. + } +} +``` + +### 4.6. `LoadRuntimeOptions` extension + +```ts +export interface LoadRuntimeOptions { + /** @default 850 */ + loadingDelay?: number; + + /** + * Custom splash screen to use instead of the built-in DefaultSplashScreen. + * Pass either an SplashScreen instance or a factory function. + * + * @example + * // Instance + * await loadRuntime(runtime, { splashScreen: new MyBrandedSplash() }); + * + * // Factory (created lazily, after Runtime is constructed) + * await loadRuntime(runtime, { splashScreen: () => new MyBrandedSplash() }); + */ + splashScreen?: SplashScreen | (() => SplashScreen); +} +``` + +--- + +## 5. File Changes + +### New files + +``` +packages/runtime/src/splash/ + SplashScreen.ts ← interface (exported from public barrel) + DefaultSplashScreen.ts ← extends Scene, implements SplashScreen +``` + +``` +packages/runtime/public/images/ + jollypixel-full-logo.png ← PNG export of the existing SVG (≥ 480×140 px, transparent bg) +``` + +### Modified files + +``` +packages/runtime/src/index.ts ← loadRuntime() refactored, SplashScreen re-exported +packages/runtime/package.json ← remove "lit" dependency +packages/runtime/src/exports.ts ← (if barrel exists) add SplashScreen export +``` + +### Deleted files + +``` +packages/runtime/src/components/Loading.ts ← Lit web component, fully replaced +``` + +--- + +## 6. Implementation Steps (ordered) + +### Step 1 — Export PNG logo + +Export `jollypixel-full-logo-min.svg` to `jollypixel-full-logo.png` at 480×140 px with +transparent background. Place it in `packages/runtime/public/images/`. + +### Step 2 — Define `SplashScreen` + +Create `packages/runtime/src/splash/SplashScreen.ts` with the interface described in §4.3. +Export it from the runtime's public barrel. + +### Step 3 — Implement `DefaultSplashScreen` + +Create `packages/runtime/src/splash/DefaultSplashScreen.ts`: + +1. Extend `Systems.Scene`. +2. In `awake()`: + - Create the "UIRoot" actor; add `UIRenderer` component. + - Create "Background", "Logo", "ProgressTrack", "ProgressFill", "AssetLabel", + "ClickPrompt", "ErrorPanel" actors with their `UISprite` / `UIText` components. + - Store references to all components that need runtime updates. +3. Implement `update(dt)`: + - If state is `FADING_OUT`, increment `#fadeAlpha` accumulator and apply opacity to + all sprites and UIText elements. When alpha reaches 0, set state `COMPLETE` and + resolve `#completeResolve`. +4. Implement all `SplashScreen` methods (onProgress, onAssetStart, onError, etc.). +5. `complete()` sets state to `FADING_OUT` and returns `#completePromise`. +6. `waitForUserGesture()` as described in §4.4. +7. After `complete()` resolves, call + `this.world.sceneManager.removeScene(this)` to clean up. + +### Step 4 — Refactor `loadRuntime()` + +Apply the changes described in §4.5: +- Remove canvas opacity manipulation. +- Remove Lit component creation/querying. +- Add splash screen wiring. +- Move `runtime.start()` to before the async work. +- Add `waitForUserGesture()` await between asset loading and `complete()`. +- Update `LoadRuntimeOptions` (§4.6). + +### Step 5 — Remove Lit dependency + +```bash +npm uninstall lit -w @jolly-pixel/runtime +``` + +Remove the `Loading` import from `index.ts`. +Delete `packages/runtime/src/components/Loading.ts`. + +### Step 6 — Test + +- Verify progress bar fills correctly when assets load. +- Verify error panel appears on GPU tier failure. +- Verify "Click to start" prompt appears after loading. +- Verify that clicking starts the game and `AudioContext` is unlocked. +- Verify that a custom `SplashScreen` can be injected and renders correctly. +- Verify `loadRuntime()` resolves correctly with no assets. + +--- + +## 7. Customization Guide (future developers) + +To create a custom splash screen: + +```ts +import type { SplashScreen } from "@jolly-pixel/runtime"; +import { Systems } from "@jolly-pixel/engine"; + +export class MyBrandedSplash extends Systems.Scene implements SplashScreen { + readonly scene = this; + // ...implement SplashScreen methods +} + +await loadRuntime(runtime, { + splashScreen: new MyBrandedSplash() +}); +``` + +The `DefaultSplashScreen` can also be subclassed to override only specific visual elements +(e.g. changing logo texture or progress bar colour) without reimplementing the whole state machine. + +--- + +## 8. Trade-offs & Known Limitations + +| Trade-off | Notes | +|---|---| +| `UIText` still uses a DOM element via `CSS2DObject` | Accepted per user instructions. This means text is not affected by WebGL post-processing. | +| Progress bar uses `mesh.scale.x` | Simple and performant but requires careful pivot/offset math on `ProgressFill` UINode. | +| `runtime.start()` is called early | The world's actor lifecycle starts during loading. Any scene the user has set up before calling `loadRuntime()` will also start ticking. For most use-cases this is fine (user sets scene after `loadRuntime()` resolves). Add a note to the docs. | +| No CSS shimmer/blur animation | The Lit design had a blue shimmer on the progress bar and a speed-blur class. These are not ported. Can be added later via a `ShimmerBehavior` or a canvas texture. | +| Logo must be a raster PNG | Three.js `TextureLoader` does not reliably parse SVG. A PNG must be generated at build time. | +| `complete()` may resolve before `SceneManager.removeScene` fully processes | `removeScene` queues destruction; actors are destroyed at end-of-frame. `complete()` resolves at the start of this sequence. The game scene begins rendering in the following frame. | + +--- + +## 9. Open Questions + +1. **`appendScene` vs `setScene`:** Using `appendScene` (overlay) means any scene the developer + sets before `loadRuntime()` is also active during loading. `setScene` would replace it. + `appendScene` is safer for the common pattern where the game scene is set after `loadRuntime()` + resolves, but needs verification. + +2. **`SceneManager.removeScene` API:** Verify that `removeScene(scene)` accepts a `Scene` instance + (not just a name/id) and that it correctly disposes all actors and the `UIRenderer`'s + `CSS2DRenderer` DOM element. + +3. **`UIRenderer` + `appendScene`:** If the game also uses `UIRenderer` on its own actors, there + will be two `CSS2DRenderer` DOM elements during the transition frame. Confirm this does not + produce z-fighting or duplicate renders. + +4. **Audio context unlock timing:** Browsers require the user gesture to happen within the same + call stack as `AudioContext.resume()`. Confirm that the `onClick` signal from `UISprite` + and the `once` keydown listener satisfy this requirement for the major browsers. diff --git a/packages/runtime/package.json b/packages/runtime/package.json index acafd16..beeb195 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -35,7 +35,6 @@ "dependencies": { "@jolly-pixel/engine": "^2.2.0", "detect-gpu": "^5.0.70", - "lit": "^3.3.1", "stats.js": "^0.17.0" }, "peerDependencies": { diff --git a/packages/runtime/src/assets/logo.ts b/packages/runtime/src/assets/logo.ts new file mode 100644 index 0000000..1ef58b1 --- /dev/null +++ b/packages/runtime/src/assets/logo.ts @@ -0,0 +1,64 @@ +/* eslint-disable @stylistic/max-len */ +// Inline SVG source for the JollyPixel logo. +// Shipped as a string constant so the runtime carries no external file dependency. +// The XML declaration is intentionally omitted — it can break data-URI rendering +// in some browsers. Explicit width/height attributes are added so the image has +// known natural dimensions when drawn to an offscreen canvas. +export const LOGO_SVG = + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/runtime/src/components/Loading.ts b/packages/runtime/src/components/Loading.ts deleted file mode 100644 index 21cf27c..0000000 --- a/packages/runtime/src/components/Loading.ts +++ /dev/null @@ -1,474 +0,0 @@ -// Import Third-party Dependencies -import { Systems } from "@jolly-pixel/engine"; -import { LitElement, css, html } from "lit"; -import { classMap } from "lit/directives/class-map.js"; -import { property, state } from "lit/decorators.js"; - -// Import Internal Dependencies -import * as timers from "../utils/timers.ts"; - -// CONSTANTS -const kProgressAnimationDurationMs = 400; -const kFadeOutDurationMs = 500; -const kVelocityThreshold = 0.1; - -export class Loading extends LitElement { - #lastProgressUpdate = 0; - #progressVelocity = 0; - - @property({ type: Boolean, reflect: true }) - declare started: boolean; - - @property({ type: Boolean, reflect: true }) - declare completed: boolean; - - @state() - declare progress: number; - - @state() - declare maxProgress: number; - - @state() - declare assetName: string; - - @state() - declare errorMessage: string; - - @state() - declare errorStack: string; - - @state() - declare imageError: boolean; - - static styles = css` - :host { - display: block; - transition: opacity 0.5s ease-out; - } - - :host([completed]) { - opacity: 0; - } - - #loading { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - color: #444; - font-size: 24px; - font-family: sans-serif; - display: flex; - flex-flow: column; - align-items: center; - justify-content: center; - background: #eee; - } - - :host(:not([started])) #loading { - opacity: 0; - } - - #loading a { - transition: opacity 0.3s ease-out; - position: relative; - text-decoration: none; - color: inherit; - display: flex; - flex-direction: column; - } - - #loading a > * { - pointer-events: none; - } - - #loading img { - width: 480px; - height: 280px; - max-width: 100%; - opacity: 0; - transform: translateY(-20px) scale(0.95); - animation: logo-fade-in 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; - animation-delay: 0.2s; - } - - #loading img.hidden { - display: none; - } - - @keyframes logo-fade-in { - 0% { - opacity: 0; - transform: translateY(-20px) scale(0.95); - } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } - } - - :host([completed]) #loading img { - animation: logo-fade-out 0.4s ease-out forwards; - } - - @keyframes logo-fade-out { - 0% { - opacity: 1; - transform: translateY(0) scale(1); - } - 100% { - opacity: 0; - transform: translateY(-10px) scale(0.98); - } - } - - #loading .asset { - margin-top: 20px; - text-align: center; - font-size: 13px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 2px; - color: #282e38ff; - opacity: 0; - animation: fade-slide-in 0.6s ease-out forwards; - animation-delay: 0.5s; - padding: 0 2em; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - /* Transition douce lors du changement d'asset */ - transition: opacity 0.2s ease-out; - } - - /* Effet subtil de "pulse" pendant le chargement */ - @keyframes fade-slide-in { - 0% { - opacity: 0; - transform: translateY(10px); - } - 100% { - opacity: 0.8; - transform: translateY(0); - } - } - - #loading .progress-container { - width: 100%; - height: 6px; - background: linear-gradient( - 180deg, - #b8bfb0 0%, - #d0d4c3 50%, - #b8bfb0 100% - ); - overflow: hidden; - position: relative; - border-radius: 3px; - box-shadow: - inset 0 1px 2px rgba(0, 0, 0, 0.1), - 0 1px 0 rgba(255, 255, 255, 0.5); - opacity: 0; - animation: fade-slide-in 0.6s ease-out forwards; - animation-delay: 0.7s; - transform: translateZ(0); - backface-visibility: hidden; - } - - #loading .progress-bar { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient( - 90deg, - #2a5d8f 0%, - #3E7CB8 50%, - #4a8fd8 100% - ); - transform: scaleX(var(--progress, 0)); - transform-origin: left center; - transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); - will-change: transform; - box-shadow: - 0 0 10px rgba(62, 124, 184, 0.5), - 0 0 20px rgba(62, 124, 184, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.3); - animation: progress-pulse 1.5s ease-in-out infinite; - backface-visibility: hidden; - } - - #loading .progress-bar.speed-blur { - animation: speed-blur 0.3s ease; - } - - #loading .progress-bar::before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient( - 90deg, - transparent 0%, - rgba(255, 255, 255, 0.15) 30%, - rgba(255, 255, 255, 0.4) 50%, - rgba(255, 255, 255, 0.15) 70%, - transparent 100% - ); - animation: shimmer 2s cubic-bezier(0.4, 0, 0.2, 1) infinite; - } - - #loading .progress-bar::after { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.2) 0%, - transparent 50%, - rgba(0, 0, 0, 0.1) 100% - ); - } - - :host([completed]) .progress-bar { - animation: none; - box-shadow: - 0 0 10px rgba(62, 124, 184, 0.5), - 0 0 20px rgba(62, 124, 184, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.3); - } - - :host([completed]) .progress-bar::before { - animation: none; - } - - @keyframes shimmer { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(100%); - } - } - - @keyframes progress-pulse { - 0%, 100% { - box-shadow: - 0 0 10px rgba(62, 124, 184, 0.5), - 0 0 20px rgba(62, 124, 184, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.3); - } - 50% { - box-shadow: - 0 0 15px rgba(62, 124, 184, 0.7), - 0 0 30px rgba(62, 124, 184, 0.5), - inset 0 1px 0 rgba(255, 255, 255, 0.4); - } - } - - #loading div.error { - text-align: center; - padding: 0 2em; - font-size: 18px; - font-weight: bold; - letter-spacing: 0.5px; - font-family: Monaco, "DejaVu Sans Mono", "Lucida Console", "Andale Mono", monospace; - color: #BF360C; - text-transform: uppercase; - } - - #loading pre.error { - text-align: left; - overflow: auto; - padding: 1em; - margin-top: 1em; - background: #CFD8DC; - color: #182024ff; - font-size: 15px; - border-radius: 4px; - } - - /* Media queries pour mobile */ - @media (max-width: 600px) { - #loading { - padding: 15px; - } - - #loading .asset { - font-size: 11px; - letter-spacing: 1px; - margin-top: 15px; - } - - #loading div.error { - font-size: 16px; - padding: 0 1em; - } - - #loading pre.error { - font-size: 13px; - padding: 0.8em; - } - } - - @media (max-width: 400px) { - #loading { - padding: 10px; - } - - #loading .asset { - font-size: 10px; - letter-spacing: 0.5px; - margin-top: 12px; - } - - #loading .progress-container { - height: 5px; - } - } - `; - - constructor() { - super(); - this.started = false; - this.completed = false; - this.progress = 0; - this.maxProgress = 100; - this.errorMessage = ""; - this.errorStack = ""; - this.assetName = "Loading runtime..."; - this.imageError = false; - } - - updated( - changedProperties: Map - ): void { - if ( - changedProperties.has("progress") || - changedProperties.has("maxProgress") - ) { - const percentage = this.getProgressPercentage() / 100; - this.style.setProperty( - "--progress", - String(percentage) - ); - - this.#updateProgressVelocity(changedProperties); - } - } - - #updateProgressVelocity( - changedProperties: Map - ): void { - const now = performance.now(); - const deltaTime = now - this.#lastProgressUpdate; - const previousProgress = (changedProperties.get("progress") as number) || 0; - - this.#progressVelocity = (this.progress - previousProgress) / deltaTime; - this.#lastProgressUpdate = now; - } - - async start() { - await this.updateComplete; - - requestAnimationFrame(() => { - this.started = true; - }); - } - - async complete(callback?: () => void): Promise { - this.progress = this.maxProgress; - await this.updateComplete; - - // progression animation end (400ms) - await timers.setTimeout(kProgressAnimationDurationMs); - - this.completed = true; - - // fade-out (500ms) - await timers.setTimeout(kFadeOutDurationMs); - - this.remove(); - callback?.(); - } - - error( - error: Error - ) { - this.errorMessage = error.message || "An error occurred"; - - const causeStackTrace = (error?.cause as Error)?.stack ?? ""; - this.errorStack = causeStackTrace === "" ? (error.stack || "") : causeStackTrace; - this.started = true; - this.completed = false; - } - - setAsset( - asset: Systems.Asset - ) { - this.assetName = asset.toString(); - } - - setProgress( - value: number, - max: number - ) { - this.progress = Math.max(0, Math.min(value, max)); - this.maxProgress = max; - } - - getProgressPercentage(): number { - if (this.maxProgress === 0) { - return 0; - } - - return (this.progress / this.maxProgress) * 100; - } - - #handleImageError(): void { - this.imageError = true; - } - - render() { - const progressBarClasses = classMap({ - "progress-bar": true, - "speed-blur": this.#progressVelocity > kVelocityThreshold - }); - - const imageClasses = classMap({ - hidden: this.imageError - }); - - return html` -
- ${this.errorMessage ? html` -
${this.errorMessage}
-
${this.errorStack}
- ` : html` - - -

${this.assetName}

-
-
-
-
- `} -
- `; - } -} - -customElements.define("jolly-loading", Loading); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 0d8e090..d5acf2d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -3,16 +3,17 @@ import { Systems } from "@jolly-pixel/engine"; import { getGPUTier } from "detect-gpu"; // Import Internal Dependencies -import { Loading } from "./components/Loading.ts"; import * as timers from "./utils/timers.ts"; import { getDevicePixelRatio } from "./utils/getDevicePixelRatio.ts"; - import { Runtime, type RuntimeOptions } from "./Runtime.ts"; +import { DefaultSplashScreen } from "./splash/DefaultSplashScreen.ts"; +import type { SplashScreen } from "./splash/SplashScreen.ts"; export interface LoadRuntimeOptions { /** * @default 850 - * Minimum delay (ms) before starting asset loading. Gives the loading UI time to render. + * Minimum delay (ms) before starting asset loading. Gives the splash screen + * at least one full render cycle before any heavy work begins. */ loadingDelay?: number; /** @@ -22,6 +23,20 @@ export interface LoadRuntimeOptions { * @default true */ focusCanvas?: boolean; + + /** + * Custom splash screen to use instead of the built-in DefaultSplashScreen. + * Pass either a SplashScreen instance or a factory function (created lazily, + * after Runtime is constructed). + * + * @example + * // Instance + * await loadRuntime(runtime, { splashScreen: new MyBrandedSplash() }); + * + * // Factory (created lazily) + * await loadRuntime(runtime, { splashScreen: () => new MyBrandedSplash() }); + */ + splashScreen?: SplashScreen | (() => SplashScreen); } export async function loadRuntime( @@ -30,38 +45,34 @@ export async function loadRuntime( ) { const { loadingDelay = 850, - focusCanvas = true + focusCanvas = true, + splashScreen: splashFactory } = options; const gpuTierPromise = getGPUTier(); - runtime.canvas.style.opacity = "0"; - runtime.canvas.style.transition = "opacity 0.5s ease-in"; - - let loadingElement = document.querySelector("jolly-loading"); - if (loadingElement === null) { - loadingElement = document.createElement("jolly-loading"); - document.body.appendChild(loadingElement); + let splash: SplashScreen; + if (splashFactory === undefined) { + splash = new DefaultSplashScreen(); + } + else if (typeof splashFactory === "function") { + splash = splashFactory(); + } + else { + splash = splashFactory; } - const loadingComponent = loadingElement as Loading; - loadingComponent.start(); - - let loadingComplete = false; - const loadingCompletePromise = new Promise((resolve) => { - runtime.manager.onProgress = (_, loaded, total) => { - loadingComponent.setProgress(loaded, total); - if (loaded >= total && !loadingComplete) { - loadingComplete = true; + // Append the splash scene as an overlay so the engine lifecycle is available + // throughout loading, then start the animation loop immediately. + runtime.world.sceneManager.appendScene(splash.scene); + runtime.start(); - // Attendre un petit délai pour s'assurer que le DOM est mis à jour - setTimeout(() => void resolve(undefined), 100); - } - }; - }); + // Allow the splash scene's start() to run and let onSetup perform any + // world-connected setup (e.g. texture loading). + splash.onSetup(runtime.world); // Prevent keypress events from leaking out to a parent window - // They might trigger scrolling for instance + // (they might trigger scrolling for instance). runtime.canvas.addEventListener("keypress", (event) => { event.preventDefault(); }); @@ -74,7 +85,20 @@ export async function loadRuntime( } document.addEventListener("click", focusCanvasHandler); - let initialized = false; + let loadingComplete = false; + const loadingCompletePromise = new Promise((resolve) => { + runtime.manager.onProgress = (_, loaded, total) => { + splash.onProgress(loaded, total); + + if (loaded >= total && !loadingComplete) { + loadingComplete = true; + + // Short delay to ensure the DOM has updated before resolving. + setTimeout(() => resolve(), 100); + } + }; + }); + try { if (loadingDelay > 0) { await timers.setTimeout(loadingDelay); @@ -100,43 +124,41 @@ export async function loadRuntime( Systems.Assets.autoload = true; Systems.Assets.scheduleAutoload(context); }); - const waitingAssetsCount = Systems.Assets.waiting.size; - if (waitingAssetsCount > 0) { + + if (Systems.Assets.waiting.size > 0) { await Systems.Assets.loadAssets( context, { - onStart: loadingComponent.setAsset.bind(loadingComponent) + onStart: (asset) => splash.onAssetStart(asset) } ); - // loadingCompletePromise resolves when the Three.js manager reports 100%. + // loadingCompletePromise resolves when the Three.js manager reports 100 %. // Loaders that bypass the manager (e.g. bare fetch) never trigger onProgress, // so we force completion manually rather than hanging indefinitely. if (loadingComplete) { await loadingCompletePromise; } else { - loadingComponent.setProgress(1, 1); + splash.onProgress(1, 1); await timers.setTimeout(100); } } - await loadingComponent.complete(); - runtime.canvas.style.opacity = "1"; - initialized = true; + splash.onLoadComplete(); + await splash.waitForUserGesture(); + await splash.complete(); } catch (error: any) { - loadingComponent.error(error); + splash.onError(error); } finally { if (!focusCanvas) { document.removeEventListener("click", focusCanvasHandler); } } - - if (initialized) { - runtime.start(); - } } export { Runtime, type RuntimeOptions }; +export type { SplashScreen }; +export { DefaultSplashScreen }; diff --git a/packages/runtime/src/splash/DefaultSplashScreen.ts b/packages/runtime/src/splash/DefaultSplashScreen.ts new file mode 100644 index 0000000..39230e2 --- /dev/null +++ b/packages/runtime/src/splash/DefaultSplashScreen.ts @@ -0,0 +1,329 @@ +// Import Third-party Dependencies +import * as THREE from "three"; +import { Systems, UIRenderer, UISprite, UIText } from "@jolly-pixel/engine"; + +// Import Internal Dependencies +import { LOGO_SVG } from "../assets/logo.ts"; +import type { SplashScreen } from "./SplashScreen.ts"; + +// CONSTANTS +const kFadeDurationSec = 0.6; +const kProgressBarWidth = 400; +const kProgressBarHalfWidth = kProgressBarWidth / 2; +const kProgressBarHeight = 8; +const kBackgroundSize = 9999; +// The SVG viewBox is 252×252 (square); keep the sprite square to avoid distortion. +const kLogoSize = 140; +const kLogoCanvasSize = 256; +const kLogoOffsetY = 80; +const kProgressOffsetY = -30; +const kAssetLabelOffsetY = -48; +const kClickPromptOffsetY = -60; + +type SplashState = "LOADING" | "READY" | "FADING_OUT" | "COMPLETE" | "ERROR"; + +export class DefaultSplashScreen extends Systems.Scene implements SplashScreen { + readonly scene: Systems.Scene = this; + + #uiRenderer!: UIRenderer; + #bgSprite!: UISprite; + #logoSprite!: UISprite; + #trackSprite!: UISprite; + #fillSprite!: UISprite; + #assetSprite!: UISprite; + #assetText!: UIText; + #promptSprite!: UISprite; + #promptText!: UIText; + #errorSprite!: UISprite; + #errorMsgText!: UIText; + #errorStackText!: UIText; + + #state: SplashState = "LOADING"; + #fadeElapsed = 0; + + #allSprites: UISprite[] = []; + #allTexts: UIText[] = []; + + #completeResolve?: () => void; + readonly #completePromise = new Promise((resolve) => { + this.#completeResolve = resolve; + }); + + constructor() { + super("DefaultSplashScreen"); + } + + override awake(): void { + const { world } = this; + + const uiRootActor = world.createActor("SplashUIRoot"); + this.#uiRenderer = uiRootActor.addComponentAndGet(UIRenderer); + + const bgActor = world.createActor("SplashBackground"); + this.#bgSprite = bgActor.addComponentAndGet(UISprite, { + size: { width: kBackgroundSize, height: kBackgroundSize }, + anchor: { x: "center", y: "center" }, + style: { color: new THREE.Color("#FFF") } + }); + + const logoActor = world.createActor("SplashLogo"); + this.#logoSprite = logoActor.addComponentAndGet(UISprite, { + size: { width: kLogoSize, height: kLogoSize }, + anchor: { x: "center", y: "center" }, + offset: { y: kLogoOffsetY }, + style: { opacity: 0 } + }); + + const trackActor = world.createActor("SplashProgressTrack"); + this.#trackSprite = trackActor.addComponentAndGet(UISprite, { + size: { width: kProgressBarWidth, height: kProgressBarHeight }, + anchor: { x: "center", y: "center" }, + offset: { y: kProgressOffsetY }, + style: { color: 0x333344 } + }); + + const fillActor = world.createActor("SplashProgressFill"); + this.#fillSprite = fillActor.addComponentAndGet(UISprite, { + size: { width: kProgressBarWidth, height: kProgressBarHeight }, + anchor: { x: "center", y: "center" }, + offset: { y: kProgressOffsetY }, + style: { color: 0x4a8fd8 } + }); + + const assetActor = world.createActor("SplashAssetLabel"); + this.#assetSprite = assetActor.addComponentAndGet(UISprite, { + size: { width: kProgressBarWidth, height: 20 }, + anchor: { x: "center", y: "center" }, + offset: { y: kAssetLabelOffsetY }, + style: { opacity: 0 } + }); + this.#assetText = new UIText(this.#assetSprite, { + textContent: "Loading runtime\u2026", + style: { + fontSize: "11px", + color: "#aaaacc", + textAlign: "center", + opacity: "1" + } + }); + this.#allTexts.push(this.#assetText); + + const promptActor = world.createActor("SplashClickPrompt"); + this.#promptSprite = promptActor.addComponentAndGet(UISprite, { + size: { width: kProgressBarWidth, height: 30 }, + anchor: { x: "center", y: "center" }, + offset: { y: kClickPromptOffsetY }, + style: { opacity: 0 } + }); + this.#promptText = new UIText(this.#promptSprite, { + textContent: "Click anywhere to start", + style: { + fontSize: "16px", + color: "#ffffff", + textAlign: "center", + opacity: "0" + } + }); + this.#allTexts.push(this.#promptText); + + const errorActor = world.createActor("SplashErrorPanel"); + this.#errorSprite = errorActor.addComponentAndGet(UISprite, { + size: { width: 500, height: 200 }, + anchor: { x: "center", y: "center" }, + style: { color: 0x1c0a00, opacity: 0 } + }); + this.#errorMsgText = new UIText(this.#errorSprite, { + textContent: "", + style: { + fontSize: "13px", + color: "#ef5350", + textAlign: "center", + whiteSpace: "normal", + opacity: "0" + } + }); + this.#errorStackText = new UIText(this.#errorSprite, { + textContent: "", + style: { + fontSize: "10px", + color: "#90a4ae", + textAlign: "left", + whiteSpace: "pre-wrap", + opacity: "0" + } + }); + this.#allTexts.push(this.#errorMsgText, this.#errorStackText); + } + + override start(): void { + this.#allSprites.push( + this.#bgSprite, + this.#logoSprite, + this.#trackSprite, + this.#fillSprite, + this.#assetSprite, + this.#promptSprite, + this.#errorSprite + ); + this.#promptSprite.mesh.visible = false; + this.#errorSprite.mesh.visible = false; + this.#fillSprite.mesh.scale.x = 0.001; + this.#fillSprite.mesh.position.x = -kProgressBarHalfWidth; + } + + override update( + deltaTime: number + ): void { + if (this.#state !== "FADING_OUT") { + return; + } + + this.#fadeElapsed += deltaTime; + const alpha = Math.max(0, 1 - (this.#fadeElapsed / kFadeDurationSec)); + this.#applyAlpha(alpha); + + if (alpha <= 0) { + this.#state = "COMPLETE"; + this.#completeResolve?.(); + } + } + + override destroy(): void { + this.#uiRenderer.clear(); + for (const text of this.#allTexts) { + text.destroy(); + } + } + + onSetup(): void { + // Rasterise the inline SVG to an offscreen canvas so Three.js gets a + // reliable bitmap texture regardless of browser SVG-in-WebGL quirks. + const img = new Image(); + img.onload = () => { + if (this.#state === "COMPLETE" || this.#state === "ERROR") { + return; + } + + const canvas = document.createElement("canvas"); + canvas.width = kLogoCanvasSize; + canvas.height = kLogoCanvasSize; + canvas.getContext("2d")?.drawImage(img, 0, 0, kLogoCanvasSize, kLogoCanvasSize); + + const mat = this.#logoSprite.mesh.material as THREE.MeshBasicMaterial; + mat.map = new THREE.CanvasTexture(canvas); + mat.opacity = 1; + mat.needsUpdate = true; + this.#logoSprite.mesh.visible = true; + }; + img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(LOGO_SVG)}`; + } + + onProgress( + loaded: number, + total: number + ): void { + if (this.#state === "ERROR") { + return; + } + + const progress = total > 0 ? Math.min(loaded / total, 1) : 0; + this.#fillSprite.mesh.scale.x = Math.max(0.001, progress); + + // Shift the mesh left so its left edge stays aligned with the track's left edge. + this.#fillSprite.mesh.position.x = (progress - 1) * kProgressBarHalfWidth; + } + + onAssetStart( + asset: Systems.Asset + ): void { + if (this.#state === "ERROR") { + return; + } + this.#assetText.text = asset.toString(); + } + + onError( + error: Error + ): void { + this.#state = "ERROR"; + + this.#trackSprite.mesh.visible = false; + this.#fillSprite.mesh.visible = false; + this.#assetText.element.style.display = "none"; + this.#promptText.element.style.display = "none"; + + this.#errorSprite.mesh.visible = true; + const errorMat = this.#errorSprite.mesh.material as THREE.MeshBasicMaterial; + errorMat.opacity = 1; + + this.#errorMsgText.text = error.message || "An error occurred"; + this.#errorMsgText.element.style.opacity = "1"; + + const cause = (error.cause as Error | undefined); + this.#errorStackText.text = cause?.stack ?? error.stack ?? ""; + this.#errorStackText.element.style.opacity = "1"; + } + + onLoadComplete(): void { + if (this.#state !== "LOADING") { + return; + } + this.#state = "READY"; + + // Fill to 100 % + this.#fillSprite.mesh.scale.x = 1; + this.#fillSprite.mesh.position.x = 0; + + // Hide progress UI + this.#trackSprite.mesh.visible = false; + this.#fillSprite.mesh.visible = false; + this.#assetText.element.style.display = "none"; + + // Show click prompt + this.#promptSprite.mesh.visible = true; + this.#promptText.element.style.opacity = "1"; + this.#promptText.element.style.display = "block"; + } + + async waitForUserGesture(): Promise { + return new Promise((resolve) => { + let resolved = false; + + const onGesture = () => { + if (resolved) { + return; + } + resolved = true; + this.#bgSprite.onClick.disconnect(onGesture); + window.removeEventListener("keydown", onGesture); + resolve(); + }; + + this.#bgSprite.onClick.connect(onGesture); + window.addEventListener("keydown", onGesture); + }); + } + + async complete(): Promise { + this.#state = "FADING_OUT"; + await this.#completePromise; + + this.world.sceneManager.removeScene(this); + } + + #applyAlpha( + alpha: number + ): void { + for (const sprite of this.#allSprites) { + if (sprite.mesh.visible) { + (sprite.mesh.material as THREE.MeshBasicMaterial).opacity = alpha; + } + } + + for (const text of this.#allTexts) { + if (text.element.style.display !== "none") { + text.element.style.opacity = String(alpha); + } + } + } +} diff --git a/packages/runtime/src/splash/SplashScreen.ts b/packages/runtime/src/splash/SplashScreen.ts new file mode 100644 index 0000000..caeeef6 --- /dev/null +++ b/packages/runtime/src/splash/SplashScreen.ts @@ -0,0 +1,48 @@ +// Import Third-party Dependencies +import type * as THREE from "three"; + +// Import Internal Dependencies +import type { Systems } from "@jolly-pixel/engine"; + +export interface SplashScreen { + /** + * The Scene instance that the splash screen manages. + * loadRuntime() appends this to the SceneManager as an overlay. + */ + readonly scene: Systems.Scene; + + /** + * Called once after the Scene has been appended and runtime.start() has been + * called. Use this to perform any setup that requires the world to be connected + * (e.g. loading textures via TextureLoader). + */ + onSetup(world: Systems.World): void; + + /** Update the loading bar (loaded/total item counts). */ + onProgress(loaded: number, total: number): void; + + /** Update the current-asset label. */ + onAssetStart(asset: Systems.Asset): void; + + /** Switch to the error state. */ + onError(error: Error): void; + + /** + * Called when all assets are loaded. + * The implementation should display a "click / tap to start" prompt and + * begin resolving waitForUserGesture(). + */ + onLoadComplete(): void; + + /** + * Returns a Promise that resolves after the user has produced a pointer-down + * or keydown event (the required user gesture for AudioContext unlock). + */ + waitForUserGesture(): Promise; + + /** + * Trigger the outro animation and remove the scene. + * Resolves when the transition is fully complete. + */ + complete(): Promise; +}