feat(intro): polish loading transition
This commit is contained in:
@@ -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,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";
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user