feat(intro): polish loading transition

This commit is contained in:
Tom Boullay
2026-05-30 20:11:40 +02:00
parent 0fa7a82175
commit e6bfcbe960
9 changed files with 195 additions and 63 deletions
+47 -3
View File
@@ -1,5 +1,13 @@
import type { SceneLoadingState } from "@/types/world/sceneLoading"; 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 { interface SceneLoadingOverlayProps {
state: SceneLoadingState; state: SceneLoadingState;
} }
@@ -15,11 +23,47 @@ export function SceneLoadingOverlay({
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`} className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
aria-live="polite" aria-live="polite"
> >
<div className="scene-loading-overlay__content"> <img
<strong>{state.currentStep}</strong> alt=""
className="scene-loading-overlay__background"
src={LOADING_BACKGROUND_PATH}
/>
<div className="scene-loading-overlay__shade" />
<img
alt="La Fabrik Durable"
className="scene-loading-overlay__logo"
src={LOADING_LOGO_PATH}
/>
<div className="scene-loading-overlay__footer">
<div className="scene-loading-overlay__meta">
<div className="scene-loading-overlay__label">
<span>Loading...</span>
<svg
className="scene-loading-overlay__spinner"
viewBox="0 0 32 32"
aria-hidden="true"
>
<path
d="M16 3a13 13 0 1 1-9.2 3.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="3.5"
/>
<path
d="M6.8 6.8V2.8H2.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3.5"
/>
</svg>
</div>
<strong>{progress}%</strong>
</div>
<div className="scene-loading-overlay__track"> <div className="scene-loading-overlay__track">
<span style={{ width: `${progress}%` }} /> <span style={{ width: `${progress}%` }} />
<em>{progress}%</em>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,14 @@
export function FadeToVideoOverlay(): React.JSX.Element {
return (
<div
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
zIndex: 29,
background: "#000",
pointerEvents: "none",
}}
/>
);
}
+1
View File
@@ -1,3 +1,4 @@
export { FadeToVideoOverlay } from "./FadeToVideoOverlay";
export { IntroVideoPlayer } from "./IntroVideoPlayer"; export { IntroVideoPlayer } from "./IntroVideoPlayer";
export { IntroDialogueOverlay } from "./IntroDialogueOverlay"; export { IntroDialogueOverlay } from "./IntroDialogueOverlay";
export { IntroRevealOverlay } from "./IntroRevealOverlay"; export { IntroRevealOverlay } from "./IntroRevealOverlay";
+1
View File
@@ -15,6 +15,7 @@ export const SITE_STEPS: readonly SiteStep[] = [
*/ */
export const GAME_STEPS: readonly GameStep[] = [ export const GAME_STEPS: readonly GameStep[] = [
"loading-map", "loading-map",
"fade-to-video",
"video", "video",
"dialogue-intro", "dialogue-intro",
"reveal", "reveal",
+80 -39
View File
@@ -873,72 +873,113 @@ canvas {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 30; z-index: 30;
display: grid; display: flex;
place-items: center; align-items: center;
justify-content: center;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: #ffffff; overflow: hidden;
background: #04070d;
pointer-events: none; pointer-events: none;
opacity: 1; opacity: 1;
transition: opacity 640ms ease; transition: opacity 500ms ease;
} }
.scene-loading-overlay--ready { .scene-loading-overlay--ready {
opacity: 0; opacity: 0;
} }
.scene-loading-overlay__content { .scene-loading-overlay__background,
display: grid; .scene-loading-overlay__shade {
justify-items: center; position: absolute;
gap: 18px; inset: 0;
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 strong { .scene-loading-overlay__background {
color: #1e293b; width: 100%;
font-size: 15px; height: 100%;
font-weight: 600; object-fit: cover;
letter-spacing: 0.02em; }
line-height: 1.45;
text-align: center; .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; 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 { .scene-loading-overlay__track {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
height: 18px; height: clamp(7px, 1vw, 12px);
background: #e2e8f0; background: rgba(255, 255, 255, 0.22);
border-radius: 999px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.22);
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.04);
} }
.scene-loading-overlay__track span { .scene-loading-overlay__track span {
display: block; display: block;
height: 100%; height: 100%;
background: linear-gradient(90deg, #2563eb, #38bdf8); background: #3b82f6;
border-radius: inherit;
transition: width 180ms ease; transition: width 180ms ease;
} }
.scene-loading-overlay__track em { @keyframes scene-loading-spin {
position: absolute; to {
inset: 0; transform: rotate(360deg);
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);
} }
/* Subtitles */ /* Subtitles */
+21 -2
View File
@@ -6,12 +6,14 @@ import { DebugPerf } from "@/components/debug/DebugPerf";
import { DialogMessage } from "@/components/ui/DialogMessage"; import { DialogMessage } from "@/components/ui/DialogMessage";
import { GameUI } from "@/components/ui/GameUI"; import { GameUI } from "@/components/ui/GameUI";
import { import {
FadeToVideoOverlay,
IntroDialogueOverlay, IntroDialogueOverlay,
IntroRevealOverlay, IntroRevealOverlay,
IntroVideoPlayer, IntroVideoPlayer,
} from "@/components/ui/intro"; } from "@/components/ui/intro";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig"; import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider"; import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import type { SceneLoadingState } from "@/types/world/sceneLoading"; import type { SceneLoadingState } from "@/types/world/sceneLoading";
@@ -19,6 +21,8 @@ import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
import { logger } from "@/utils/core/Logger"; import { logger } from "@/utils/core/Logger";
import { World } from "@/world/World"; import { World } from "@/world/World";
const LOADING_TO_VIDEO_FADE_MS = 500;
export function HomePage(): React.JSX.Element | null { export function HomePage(): React.JSX.Element | null {
const navigate = useNavigate(); const navigate = useNavigate();
const introStep = useGameStore((state) => state.intro.currentStep); const introStep = useGameStore((state) => state.intro.currentStep);
@@ -67,10 +71,23 @@ export function HomePage(): React.JSX.Element | null {
useEffect(() => { useEffect(() => {
if (introStep === "loading-map" && sceneLoadingState.status === "ready") { if (introStep === "loading-map" && sceneLoadingState.status === "ready") {
setIntroStep("video"); AudioManager.getInstance().stopMusic();
setIntroStep("fade-to-video");
} }
}, [introStep, sceneLoadingState.status, setIntroStep]); }, [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( const handleCanvasCreated = useCallback(
({ gl }: { gl: THREE.WebGLRenderer }) => { ({ gl }: { gl: THREE.WebGLRenderer }) => {
const canvas = gl.domElement; const canvas = gl.domElement;
@@ -106,6 +123,8 @@ export function HomePage(): React.JSX.Element | null {
const renderIntroOverlay = () => { const renderIntroOverlay = () => {
switch (introStep) { switch (introStep) {
case "fade-to-video":
return <FadeToVideoOverlay />;
case "video": case "video":
return <IntroVideoPlayer />; return <IntroVideoPlayer />;
case "dialogue-intro": case "dialogue-intro":
@@ -142,7 +161,7 @@ export function HomePage(): React.JSX.Element | null {
onClose={hideDialog} onClose={hideDialog}
/> />
) : null} ) : null}
{introStep === "loading-map" && ( {(introStep === "loading-map" || introStep === "fade-to-video") && (
<SceneLoadingOverlay state={sceneLoadingState} /> <SceneLoadingOverlay state={sceneLoadingState} />
)} )}
{renderIntroOverlay()} {renderIntroOverlay()}
+1
View File
@@ -15,6 +15,7 @@ export type SiteStep =
*/ */
export type GameStep = export type GameStep =
| "loading-map" // Chargement des assets | "loading-map" // Chargement des assets
| "fade-to-video" // Fondu noir entre chargement et vidéo
| "video" // Vidéo intro.mp4 | "video" // Vidéo intro.mp4
| "dialogue-intro" // Dialogues post-vidéo (écran noir) | "dialogue-intro" // Dialogues post-vidéo (écran noir)
| "reveal" // Fondu noir → jeu visible | "reveal" // Fondu noir → jeu visible
+29 -18
View File
@@ -20,6 +20,7 @@ type ModelEntry = [modelName: string, modelUrl: string];
let cachedSceneData: SceneData | null = null; let cachedSceneData: SceneData | null = null;
let loadingPromise: Promise<SceneData | null> | null = null; let loadingPromise: Promise<SceneData | null> | null = null;
const modelEntryCache = new Map<string, ModelEntry | null>();
export async function loadMapSceneData(): Promise<SceneData | null> { export async function loadMapSceneData(): Promise<SceneData | null> {
if (cachedSceneData) { if (cachedSceneData) {
@@ -223,24 +224,34 @@ async function loadMapModelUrls(
} }
async function loadModelEntry(modelName: string): Promise<ModelEntry | null> { async function loadModelEntry(modelName: string): Promise<ModelEntry | null> {
for (const fileName of [...MODEL_FILE_NAMES, `${modelName}.gltf`]) { if (modelEntryCache.has(modelName)) {
const modelUrl = `/models/${modelName}/${fileName}`; return modelEntryCache.get(modelName) ?? null;
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;
}
} }
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;
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { AudioManager } from "@/managers/AudioManager"; 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; const GAME_MUSIC_VOLUME = 0.33;
export function GameMusic(): null { export function GameMusic(): null {