Feat/polish-intro #11
@@ -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,3 +1,4 @@
|
||||
export { FadeToVideoOverlay } from "./FadeToVideoOverlay";
|
||||
export { IntroVideoPlayer } from "./IntroVideoPlayer";
|
||||
export { IntroDialogueOverlay } from "./IntroDialogueOverlay";
|
||||
export { IntroRevealOverlay } from "./IntroRevealOverlay";
|
||||
|
||||
@@ -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
@@ -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
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user