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";
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"
>
<div className="scene-loading-overlay__content">
<strong>{state.currentStep}</strong>
<img
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">
<span style={{ width: `${progress}%` }} />
<em>{progress}%</em>
</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 { IntroDialogueOverlay } from "./IntroDialogueOverlay";
export { IntroRevealOverlay } from "./IntroRevealOverlay";
+1
View File
@@ -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",
+80 -39
View File
@@ -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 */
+21 -2
View File
@@ -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 <FadeToVideoOverlay />;
case "video":
return <IntroVideoPlayer />;
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") && (
<SceneLoadingOverlay state={sceneLoadingState} />
)}
{renderIntroOverlay()}
+1
View File
@@ -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
+29 -18
View File
@@ -20,6 +20,7 @@ type ModelEntry = [modelName: string, modelUrl: string];
let cachedSceneData: SceneData | null = null;
let loadingPromise: Promise<SceneData | null> | null = null;
const modelEntryCache = new Map<string, ModelEntry | null>();
export async function loadMapSceneData(): Promise<SceneData | null> {
if (cachedSceneData) {
@@ -223,24 +224,34 @@ async function loadMapModelUrls(
}
async function loadModelEntry(modelName: string): Promise<ModelEntry | null> {
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;
}
+1 -1
View File
@@ -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 {