diff --git a/src/components/ui/SceneLoadingOverlay.tsx b/src/components/ui/SceneLoadingOverlay.tsx
index d5c66c9..b58dbd8 100644
--- a/src/components/ui/SceneLoadingOverlay.tsx
+++ b/src/components/ui/SceneLoadingOverlay.tsx
@@ -1,5 +1,13 @@
import type { SceneLoadingState } from "@/types/world/sceneLoading";
+const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
+const LOADING_LOGO_PATH = "/assets/logo/logo.jpg";
+
+for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
+ const image = new Image();
+ image.src = path;
+}
+
interface SceneLoadingOverlayProps {
state: SceneLoadingState;
}
@@ -15,11 +23,47 @@ export function SceneLoadingOverlay({
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
aria-live="polite"
>
-
-
{state.currentStep}
+

+
+

+
diff --git a/src/components/ui/intro/FadeToVideoOverlay.tsx b/src/components/ui/intro/FadeToVideoOverlay.tsx
new file mode 100644
index 0000000..dfe341b
--- /dev/null
+++ b/src/components/ui/intro/FadeToVideoOverlay.tsx
@@ -0,0 +1,14 @@
+export function FadeToVideoOverlay(): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/src/components/ui/intro/index.ts b/src/components/ui/intro/index.ts
index 6031a8e..41edc5e 100644
--- a/src/components/ui/intro/index.ts
+++ b/src/components/ui/intro/index.ts
@@ -1,3 +1,4 @@
+export { FadeToVideoOverlay } from "./FadeToVideoOverlay";
export { IntroVideoPlayer } from "./IntroVideoPlayer";
export { IntroDialogueOverlay } from "./IntroDialogueOverlay";
export { IntroRevealOverlay } from "./IntroRevealOverlay";
diff --git a/src/data/game/gameStateConfig.ts b/src/data/game/gameStateConfig.ts
index be1b286..2b41a95 100644
--- a/src/data/game/gameStateConfig.ts
+++ b/src/data/game/gameStateConfig.ts
@@ -15,6 +15,7 @@ export const SITE_STEPS: readonly SiteStep[] = [
*/
export const GAME_STEPS: readonly GameStep[] = [
"loading-map",
+ "fade-to-video",
"video",
"dialogue-intro",
"reveal",
diff --git a/src/index.css b/src/index.css
index 94a3c72..c981d71 100644
--- a/src/index.css
+++ b/src/index.css
@@ -873,72 +873,113 @@ canvas {
position: fixed;
inset: 0;
z-index: 30;
- display: grid;
- place-items: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
width: 100vw;
height: 100vh;
- background: #ffffff;
+ overflow: hidden;
+ background: #04070d;
pointer-events: none;
opacity: 1;
- transition: opacity 640ms ease;
+ transition: opacity 500ms ease;
}
.scene-loading-overlay--ready {
opacity: 0;
}
-.scene-loading-overlay__content {
- display: grid;
- justify-items: center;
- gap: 18px;
- width: min(360px, calc(100vw - 48px));
- padding: 28px;
- background: rgba(255, 255, 255, 0.92);
- border: 1px solid rgba(15, 23, 42, 0.08);
- border-radius: 28px;
- box-shadow: 0 24px 80px rgba(15, 23, 42, 0.12);
+.scene-loading-overlay__background,
+.scene-loading-overlay__shade {
+ position: absolute;
+ inset: 0;
}
-.scene-loading-overlay strong {
- color: #1e293b;
- font-size: 15px;
- font-weight: 600;
- letter-spacing: 0.02em;
- line-height: 1.45;
- text-align: center;
+.scene-loading-overlay__background {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.scene-loading-overlay__shade {
+ background: rgba(4, 7, 13, 0.12);
+}
+
+.scene-loading-overlay__logo {
+ position: relative;
+ z-index: 1;
+ width: clamp(180px, 28vw, 320px);
+ max-height: min(38vh, 320px);
+ border-radius: 16px;
+ object-fit: cover;
+ box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
+}
+
+.scene-loading-overlay__footer {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1;
+ display: grid;
+ gap: 12px;
+ padding: 0 clamp(18px, 4vw, 56px) clamp(22px, 5vh, 48px);
+}
+
+.scene-loading-overlay__meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ color: #ffffff;
+ font-family: "Nersans One", "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: clamp(16px, 2.3vw, 30px);
+ line-height: 1;
+ letter-spacing: 0.12em;
+ text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
text-transform: uppercase;
}
+.scene-loading-overlay__label {
+ display: flex;
+ align-items: center;
+ gap: clamp(8px, 1.2vw, 14px);
+ min-width: 0;
+}
+
+.scene-loading-overlay__spinner {
+ flex: 0 0 auto;
+ width: clamp(18px, 2.2vw, 30px);
+ height: clamp(18px, 2.2vw, 30px);
+ color: #ffffff;
+ animation: scene-loading-spin 900ms linear infinite;
+}
+
+.scene-loading-overlay__meta strong {
+ color: inherit;
+ font: inherit;
+}
+
.scene-loading-overlay__track {
position: relative;
overflow: hidden;
width: 100%;
- height: 18px;
- background: #e2e8f0;
- border-radius: 999px;
- box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.04);
+ height: clamp(7px, 1vw, 12px);
+ background: rgba(255, 255, 255, 0.22);
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.22);
}
.scene-loading-overlay__track span {
display: block;
height: 100%;
- background: linear-gradient(90deg, #2563eb, #38bdf8);
- border-radius: inherit;
+ background: #3b82f6;
transition: width 180ms ease;
}
-.scene-loading-overlay__track em {
- position: absolute;
- inset: 0;
- display: grid;
- place-items: center;
- color: #ffffff;
- font-size: 11px;
- font-style: normal;
- font-weight: 700;
- letter-spacing: 0.04em;
- line-height: 1;
- text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35);
+@keyframes scene-loading-spin {
+ to {
+ transform: rotate(360deg);
+ }
}
/* Subtitles */
diff --git a/src/pages/page.tsx b/src/pages/page.tsx
index b821aec..8196054 100644
--- a/src/pages/page.tsx
+++ b/src/pages/page.tsx
@@ -6,12 +6,14 @@ import { DebugPerf } from "@/components/debug/DebugPerf";
import { DialogMessage } from "@/components/ui/DialogMessage";
import { GameUI } from "@/components/ui/GameUI";
import {
+ FadeToVideoOverlay,
IntroDialogueOverlay,
IntroRevealOverlay,
IntroVideoPlayer,
} from "@/components/ui/intro";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
+import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
@@ -19,6 +21,8 @@ import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
import { logger } from "@/utils/core/Logger";
import { World } from "@/world/World";
+const LOADING_TO_VIDEO_FADE_MS = 500;
+
export function HomePage(): React.JSX.Element | null {
const navigate = useNavigate();
const introStep = useGameStore((state) => state.intro.currentStep);
@@ -67,10 +71,23 @@ export function HomePage(): React.JSX.Element | null {
useEffect(() => {
if (introStep === "loading-map" && sceneLoadingState.status === "ready") {
- setIntroStep("video");
+ AudioManager.getInstance().stopMusic();
+ setIntroStep("fade-to-video");
}
}, [introStep, sceneLoadingState.status, setIntroStep]);
+ useEffect(() => {
+ if (introStep !== "fade-to-video") return undefined;
+
+ const timeoutId = window.setTimeout(() => {
+ setIntroStep("video");
+ }, LOADING_TO_VIDEO_FADE_MS);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }, [introStep, setIntroStep]);
+
const handleCanvasCreated = useCallback(
({ gl }: { gl: THREE.WebGLRenderer }) => {
const canvas = gl.domElement;
@@ -106,6 +123,8 @@ export function HomePage(): React.JSX.Element | null {
const renderIntroOverlay = () => {
switch (introStep) {
+ case "fade-to-video":
+ return ;
case "video":
return ;
case "dialogue-intro":
@@ -142,7 +161,7 @@ export function HomePage(): React.JSX.Element | null {
onClose={hideDialog}
/>
) : null}
- {introStep === "loading-map" && (
+ {(introStep === "loading-map" || introStep === "fade-to-video") && (
)}
{renderIntroOverlay()}
diff --git a/src/types/game.ts b/src/types/game.ts
index 07da6b7..a2add19 100644
--- a/src/types/game.ts
+++ b/src/types/game.ts
@@ -15,6 +15,7 @@ export type SiteStep =
*/
export type GameStep =
| "loading-map" // Chargement des assets
+ | "fade-to-video" // Fondu noir entre chargement et vidéo
| "video" // Vidéo intro.mp4
| "dialogue-intro" // Dialogues post-vidéo (écran noir)
| "reveal" // Fondu noir → jeu visible
diff --git a/src/utils/map/loadMapSceneData.ts b/src/utils/map/loadMapSceneData.ts
index 49153ed..45a6f3a 100644
--- a/src/utils/map/loadMapSceneData.ts
+++ b/src/utils/map/loadMapSceneData.ts
@@ -20,6 +20,7 @@ type ModelEntry = [modelName: string, modelUrl: string];
let cachedSceneData: SceneData | null = null;
let loadingPromise: Promise | null = null;
+const modelEntryCache = new Map();
export async function loadMapSceneData(): Promise {
if (cachedSceneData) {
@@ -223,24 +224,34 @@ async function loadMapModelUrls(
}
async function loadModelEntry(modelName: string): Promise {
- for (const fileName of [...MODEL_FILE_NAMES, `${modelName}.gltf`]) {
- const modelUrl = `/models/${modelName}/${fileName}`;
-
- try {
- const response = await fetch(modelUrl, { method: "HEAD" });
- const contentType = response.headers.get("content-type") ?? "";
- if (response.ok && !contentType.includes(HTML_CONTENT_TYPE)) {
- return [modelName, modelUrl];
- }
- } catch (error) {
- logger.warn("MapSceneData", "Failed to probe map model URL", {
- modelName,
- modelUrl,
- error: error instanceof Error ? error : String(error),
- });
- continue;
- }
+ if (modelEntryCache.has(modelName)) {
+ return modelEntryCache.get(modelName) ?? null;
}
- return null;
+ const modelUrls = [...MODEL_FILE_NAMES, `${modelName}.gltf`].map(
+ (fileName) => `/models/${modelName}/${fileName}`,
+ );
+
+ const results = await Promise.all(
+ modelUrls.map(async (modelUrl) => {
+ try {
+ const response = await fetch(modelUrl, { method: "HEAD" });
+ const contentType = response.headers.get("content-type") ?? "";
+ return response.ok && !contentType.includes(HTML_CONTENT_TYPE);
+ } catch (error) {
+ logger.warn("MapSceneData", "Failed to probe map model URL", {
+ modelName,
+ modelUrl,
+ error: error instanceof Error ? error : String(error),
+ });
+ return false;
+ }
+ }),
+ );
+
+ const modelUrl = modelUrls[results.findIndex(Boolean)] ?? null;
+ const entry = modelUrl ? ([modelName, modelUrl] satisfies ModelEntry) : null;
+
+ modelEntryCache.set(modelName, entry);
+ return entry;
}
diff --git a/src/world/GameMusic.tsx b/src/world/GameMusic.tsx
index 31d2119..c804f88 100644
--- a/src/world/GameMusic.tsx
+++ b/src/world/GameMusic.tsx
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { AudioManager } from "@/managers/AudioManager";
-const GAME_MUSIC_PATH = "/sounds/musique/test.mp3";
+const GAME_MUSIC_PATH = "/sounds/musique/musique-jeu.mp3";
const GAME_MUSIC_VOLUME = 0.33;
export function GameMusic(): null {