diff --git a/README.md b/README.md
index f92fa29..9c56a97 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ The current prototype puts the player in a repair-oriented world where they prog
| `/` | Playable 3D experience |
| `/?debug` | Playable scene with debug GUI and overlays |
| `/editor` | Local map, dialogue, subtitle, and cinematic editor |
-| `/galerie` | 3D model gallery for browsing project assets |
+| `/gallery` | 3D model gallery for browsing project assets |
| `/docs` | In-app documentation index |
## Tech Stack
@@ -99,7 +99,7 @@ Useful local URLs:
```txt
http://localhost:5173/?debug
http://localhost:5173/editor
-http://localhost:5173/galerie
+http://localhost:5173/gallery
http://localhost:5173/docs
```
diff --git a/docs/user/gallery.md b/docs/user/gallery.md
index 86fea7d..1d94b5f 100644
--- a/docs/user/gallery.md
+++ b/docs/user/gallery.md
@@ -1,6 +1,6 @@
# Galerie des modèles
-La galerie est disponible sur `/galerie`. Elle permet de parcourir les modèles 3D présents dans `public/models/` sans lancer la boucle de gameplay principale.
+La galerie est disponible sur `/gallery`. Elle permet de parcourir les modèles 3D présents dans `public/models/` sans lancer la boucle de gameplay principale.
## Objectif
@@ -8,10 +8,10 @@ Cette page sert à remercier et valoriser le travail des designers du projet La
## Utilisation
-1. Ouvrir `/galerie`.
-2. Utiliser les flèches pour passer au modèle précédent ou suivant.
+1. Ouvrir `/gallery`.
+2. Utiliser les flèches en bas de l'écran pour passer au modèle précédent ou suivant.
3. Tourner autour du modèle avec la souris ou le doigt.
-4. Lire le chemin affiché pour retrouver le fichier source dans `public/models/`.
+4. Lire le diagnostic texture discret pour savoir si le modèle chargé semble correct côté textures.
## Fonctionnement
@@ -21,6 +21,7 @@ Cette page sert à remercier et valoriser le travail des designers du projet La
- `Bounds` et `Center` recadrent automatiquement le modèle actif.
- `SkyModel` réutilise la skybox du jeu.
- Les animations GLTF présentes dans un modèle sont lancées automatiquement.
+- Un diagnostic simple inspecte les matériaux chargés pour signaler les textures absentes ou non exploitables.
## Ajouter un modèle
diff --git a/src/index.css b/src/index.css
index 7ba0e98..cc7bc18 100644
--- a/src/index.css
+++ b/src/index.css
@@ -32,126 +32,31 @@ canvas {
/* Model gallery */
.gallery-page {
- display: grid;
- grid-template-columns: minmax(280px, 0.78fr) minmax(0, 1.22fr);
- gap: clamp(18px, 4vw, 54px);
+ position: relative;
width: 100vw;
height: 100vh;
- padding: clamp(18px, 4vw, 56px);
- box-sizing: border-box;
- overflow: auto;
- background:
- radial-gradient(
- circle at 22% 18%,
- rgba(96, 165, 250, 0.2),
- transparent 32%
- ),
- radial-gradient(
- circle at 86% 8%,
- rgba(52, 211, 153, 0.16),
- transparent 28%
- ),
- #05070c;
- color: #f8fafc;
-}
-
-.gallery-hero {
- align-self: center;
- max-width: 580px;
-}
-
-.gallery-eyebrow,
-.gallery-model-count {
- margin: 0 0 12px;
- color: #7dd3fc;
- font-size: 12px;
- font-weight: 800;
- letter-spacing: 0.16em;
- text-transform: uppercase;
-}
-
-.gallery-hero h1 {
- margin: 0;
- font-size: clamp(44px, 8vw, 92px);
- line-height: 0.94;
- letter-spacing: -0.075em;
-}
-
-.gallery-hero p:last-child {
- max-width: 44rem;
- margin: 24px 0 0;
- color: #cbd5e1;
- font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
- font-size: clamp(16px, 2vw, 20px);
- line-height: 1.65;
-}
-
-.gallery-viewer-panel {
- display: grid;
- grid-template-rows: auto minmax(360px, 1fr) auto;
- min-height: min(760px, calc(100vh - 112px));
overflow: hidden;
- border: 1px solid rgba(226, 232, 240, 0.18);
- border-radius: 28px;
- background: rgba(8, 13, 24, 0.74);
- box-shadow: 0 24px 90px rgba(0, 0, 0, 0.42);
- backdrop-filter: blur(18px);
-}
-
-.gallery-viewer-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 18px;
- padding: 22px;
- border-bottom: 1px solid rgba(226, 232, 240, 0.14);
-}
-
-.gallery-viewer-header h2 {
- margin: 0 0 8px;
- font-size: clamp(26px, 3vw, 42px);
- line-height: 1;
- letter-spacing: -0.055em;
-}
-
-.gallery-viewer-header code {
- color: #94a3b8;
- font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
- font-size: 12px;
- word-break: break-word;
-}
-
-.gallery-controls {
- display: flex;
- gap: 10px;
-}
-
-.gallery-controls button {
- display: grid;
- place-items: center;
- width: 48px;
- height: 48px;
- border: 1px solid rgba(248, 250, 252, 0.24);
- border-radius: 999px;
- background: rgba(248, 250, 252, 0.08);
+ background: #05070c;
color: #f8fafc;
- cursor: pointer;
- font-size: 24px;
- transition:
- background 160ms ease,
- transform 160ms ease;
}
-.gallery-controls button:hover,
-.gallery-controls button:focus-visible {
- background: rgba(125, 211, 252, 0.24);
- outline: none;
- transform: translateY(-1px);
+.gallery-title {
+ position: absolute;
+ top: clamp(18px, 3vw, 34px);
+ right: clamp(18px, 3vw, 38px);
+ z-index: 2;
+ margin: 0;
+ color: rgba(248, 250, 252, 0.92);
+ font-size: clamp(18px, 2vw, 26px);
+ font-weight: 700;
+ letter-spacing: 0.32em;
+ line-height: 1;
}
.gallery-canvas-frame {
position: relative;
- min-height: 360px;
+ width: 100%;
+ height: 100%;
}
.gallery-viewer-error {
@@ -164,43 +69,132 @@ canvas {
text-align: center;
}
-.gallery-help-text {
- margin: 0;
- padding: 16px 22px 20px;
- border-top: 1px solid rgba(226, 232, 240, 0.14);
- color: #cbd5e1;
+.gallery-bottom-bar {
+ position: absolute;
+ right: 50%;
+ bottom: clamp(18px, 4vw, 44px);
+ z-index: 2;
+ display: grid;
+ grid-template-columns: 54px minmax(190px, 340px) 54px;
+ align-items: center;
+ overflow: hidden;
+ border: 1px solid rgba(248, 250, 252, 0.18);
+ border-radius: 999px;
+ background: rgba(3, 7, 18, 0.72);
+ box-shadow: 0 18px 52px rgba(0, 0, 0, 0.32);
+ transform: translateX(50%);
+ backdrop-filter: blur(18px);
+}
+
+.gallery-bottom-bar button {
+ display: grid;
+ place-items: center;
+ width: 54px;
+ height: 54px;
+ border: 0;
+ background: transparent;
+ color: rgba(248, 250, 252, 0.82);
+ cursor: pointer;
+ transition:
+ background 160ms ease,
+ color 160ms ease;
+}
+
+.gallery-bottom-bar button:hover,
+.gallery-bottom-bar button:focus-visible {
+ background: rgba(248, 250, 252, 0.1);
+ color: #f8fafc;
+ outline: none;
+}
+
+.gallery-model-info {
+ display: grid;
+ place-items: center;
+ min-height: 54px;
+ padding: 0 20px;
+ border-right: 1px solid rgba(248, 250, 252, 0.14);
+ border-left: 1px solid rgba(248, 250, 252, 0.14);
+ text-align: center;
+}
+
+.gallery-model-info span {
+ max-width: 100%;
+ overflow: hidden;
+ color: #f8fafc;
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-overflow: ellipsis;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.gallery-model-info small {
+ margin-top: 2px;
+ color: rgba(203, 213, 225, 0.62);
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
- font-size: 14px;
- line-height: 1.5;
+ font-size: 11px;
+ font-weight: 600;
}
-@media (max-width: 900px) {
- .gallery-page {
- grid-template-columns: 1fr;
- min-height: 100vh;
- height: auto;
- }
-
- .gallery-hero {
- align-self: start;
- }
-
- .gallery-viewer-panel {
- min-height: 620px;
- }
+.gallery-texture-status {
+ position: absolute;
+ left: clamp(18px, 3vw, 38px);
+ bottom: clamp(22px, 4vw, 50px);
+ z-index: 2;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ max-width: min(320px, calc(100vw - 36px));
+ padding: 10px 13px;
+ border: 1px solid rgba(248, 250, 252, 0.14);
+ border-radius: 999px;
+ background: rgba(3, 7, 18, 0.58);
+ color: rgba(226, 232, 240, 0.86);
+ font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 12px;
+ font-weight: 700;
+ backdrop-filter: blur(16px);
}
-@media (max-width: 560px) {
- .gallery-viewer-header {
- flex-direction: column;
+.gallery-texture-status--ok {
+ color: #bbf7d0;
+}
+
+.gallery-texture-status--warning {
+ color: #fde68a;
+}
+
+.gallery-texture-status--loading {
+ color: rgba(226, 232, 240, 0.72);
+}
+
+@media (max-width: 720px) {
+ .gallery-title {
+ right: 50%;
+ transform: translateX(50%);
}
- .gallery-controls {
- width: 100%;
+ .gallery-bottom-bar {
+ grid-template-columns: 48px minmax(150px, 1fr) 48px;
+ width: calc(100vw - 36px);
}
- .gallery-controls button {
- flex: 1;
+ .gallery-bottom-bar button,
+ .gallery-model-info {
+ min-height: 50px;
+ }
+
+ .gallery-bottom-bar button {
+ width: 48px;
+ height: 50px;
+ }
+
+ .gallery-texture-status {
+ right: 50%;
+ bottom: calc(clamp(18px, 4vw, 44px) + 66px);
+ left: auto;
+ transform: translateX(50%);
}
}
diff --git a/src/pages/galerie/page.tsx b/src/pages/galerie/page.tsx
deleted file mode 100644
index 997ef1a..0000000
--- a/src/pages/galerie/page.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import {
- Bounds,
- Center,
- OrbitControls,
- useAnimations,
- useGLTF,
-} from "@react-three/drei";
-import { Canvas } from "@react-three/fiber";
-import {
- Component,
- Suspense,
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactNode,
-} from "react";
-import * as THREE from "three";
-import { SkyModel } from "@/components/three/world/SkyModel";
-import {
- GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
- GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
- GAME_SCENE_SKY_MODEL_PATH,
- GAME_SCENE_SKY_MODEL_SCALE,
-} from "@/data/world/environmentConfig";
-import { galleryModels, type GalleryModel } from "@/data/galleryModels";
-
-interface GalleryModelProps {
- model: GalleryModel;
-}
-
-interface GalleryViewerErrorBoundaryProps {
- children: ReactNode;
- resetKey: string;
-}
-
-interface GalleryViewerErrorBoundaryState {
- hasError: boolean;
-}
-
-class GalleryViewerErrorBoundary extends Component<
- GalleryViewerErrorBoundaryProps,
- GalleryViewerErrorBoundaryState
-> {
- constructor(props: GalleryViewerErrorBoundaryProps) {
- super(props);
- this.state = { hasError: false };
- }
-
- static getDerivedStateFromError(): GalleryViewerErrorBoundaryState {
- return { hasError: true };
- }
-
- componentDidUpdate(previousProps: GalleryViewerErrorBoundaryProps): void {
- if (previousProps.resetKey !== this.props.resetKey && this.state.hasError) {
- this.setState({ hasError: false });
- }
- }
-
- render(): ReactNode {
- if (this.state.hasError) {
- return (
-
- Ce modèle ne peut pas être affiché pour le moment.
-
- );
- }
-
- return this.props.children;
- }
-}
-
-function GalleryModelPreview({ model }: GalleryModelProps): React.JSX.Element {
- const groupRef = useRef(null);
- const { animations, scene } = useGLTF(model.path);
- const modelScene = useMemo(() => scene.clone(true), [scene]);
- const { actions } = useAnimations(animations, groupRef);
-
- useEffect(() => {
- const animationActions = Object.values(actions).filter(
- (action): action is THREE.AnimationAction => Boolean(action),
- );
-
- for (const action of animationActions) {
- action.reset().play();
- }
-
- return () => {
- for (const action of animationActions) {
- action.stop();
- }
- };
- }, [actions]);
-
- return (
-
-
-
- );
-}
-
-function GalleryScene({ model }: GalleryModelProps): React.JSX.Element {
- return (
- <>
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
-export function GalleryPage(): React.JSX.Element {
- const [activeModelIndex, setActiveModelIndex] = useState(0);
- const activeModel = galleryModels[activeModelIndex] ?? galleryModels[0]!;
- const modelCount = galleryModels.length;
-
- const goToPreviousModel = (): void => {
- setActiveModelIndex((currentIndex) =>
- currentIndex === 0 ? modelCount - 1 : currentIndex - 1,
- );
- };
-
- const goToNextModel = (): void => {
- setActiveModelIndex((currentIndex) =>
- currentIndex === modelCount - 1 ? 0 : currentIndex + 1,
- );
- };
-
- return (
-
-
- Galerie des modèles
- Merci aux designers de La Fabrik
-
- Une vitrine simple pour parcourir les modèles 3D du projet dans leur
- propre canvas, avec la même skybox que l'expérience principale.
-
-
-
-
-
-
-
- {activeModelIndex + 1} / {modelCount}
-
-
{activeModel.name}
-
{activeModel.path}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Utilise les flèches pour changer de modèle. Tu peux tourner autour du
- modèle avec la souris ou le doigt.
-
-
-
- );
-}
diff --git a/src/pages/gallery/page.tsx b/src/pages/gallery/page.tsx
new file mode 100644
index 0000000..3614351
--- /dev/null
+++ b/src/pages/gallery/page.tsx
@@ -0,0 +1,312 @@
+import {
+ Bounds,
+ Center,
+ OrbitControls,
+ useAnimations,
+ useGLTF,
+} from "@react-three/drei";
+import { Canvas } from "@react-three/fiber";
+import {
+ Component,
+ Suspense,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ ArrowLeft,
+ ArrowRight,
+ CheckCircle2,
+ TriangleAlert,
+} from "lucide-react";
+import * as THREE from "three";
+import { SkyModel } from "@/components/three/world/SkyModel";
+import {
+ GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
+ GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
+ GAME_SCENE_SKY_MODEL_PATH,
+ GAME_SCENE_SKY_MODEL_SCALE,
+} from "@/data/world/environmentConfig";
+import { galleryModels, type GalleryModel } from "@/data/galleryModels";
+
+interface GalleryModelProps {
+ model: GalleryModel;
+}
+
+interface GallerySceneProps extends GalleryModelProps {
+ onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
+}
+
+interface TextureDiagnostic {
+ modelId: string | null;
+ status: "loading" | "ok" | "warning";
+ summary: string;
+}
+
+interface GalleryViewerErrorBoundaryProps {
+ children: ReactNode;
+ resetKey: string;
+}
+
+interface GalleryViewerErrorBoundaryState {
+ hasError: boolean;
+}
+
+const TEXTURE_SLOTS = [
+ "map",
+ "normalMap",
+ "roughnessMap",
+ "metalnessMap",
+ "aoMap",
+ "emissiveMap",
+ "alphaMap",
+] as const;
+
+const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = {
+ modelId: null,
+ status: "loading",
+ summary: "Analyse des textures...",
+};
+
+class GalleryViewerErrorBoundary extends Component<
+ GalleryViewerErrorBoundaryProps,
+ GalleryViewerErrorBoundaryState
+> {
+ constructor(props: GalleryViewerErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(): GalleryViewerErrorBoundaryState {
+ return { hasError: true };
+ }
+
+ componentDidUpdate(previousProps: GalleryViewerErrorBoundaryProps): void {
+ if (previousProps.resetKey !== this.props.resetKey && this.state.hasError) {
+ this.setState({ hasError: false });
+ }
+ }
+
+ render(): ReactNode {
+ if (this.state.hasError) {
+ return (
+
+ Ce modèle ne peut pas être affiché pour le moment.
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+function GalleryModelPreview({
+ model,
+ onTextureDiagnosticReady,
+}: GallerySceneProps): React.JSX.Element {
+ const groupRef = useRef(null);
+ const { animations, scene } = useGLTF(model.path);
+ const modelScene = useMemo(() => scene.clone(true), [scene]);
+ const { actions } = useAnimations(animations, groupRef);
+
+ useEffect(() => {
+ onTextureDiagnosticReady(getTextureDiagnostic(model.id, modelScene));
+ }, [model.id, modelScene, onTextureDiagnosticReady]);
+
+ useEffect(() => {
+ const animationActions = Object.values(actions).filter(
+ (action): action is THREE.AnimationAction => Boolean(action),
+ );
+
+ for (const action of animationActions) {
+ action.reset().play();
+ }
+
+ return () => {
+ for (const action of animationActions) {
+ action.stop();
+ }
+ };
+ }, [actions]);
+
+ return (
+
+
+
+ );
+}
+
+function GalleryScene({
+ model,
+ onTextureDiagnosticReady,
+}: GallerySceneProps): React.JSX.Element {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function TextureStatusBadge({
+ diagnostic,
+}: {
+ diagnostic: TextureDiagnostic;
+}): React.JSX.Element {
+ const hasWarning = diagnostic.status === "warning";
+ const Icon = hasWarning ? TriangleAlert : CheckCircle2;
+
+ return (
+
+
+ {diagnostic.summary}
+
+ );
+}
+
+function getTextureDiagnostic(
+ modelId: string,
+ modelScene: THREE.Object3D,
+): TextureDiagnostic {
+ let textureCount = 0;
+ let missingTextureImageCount = 0;
+
+ modelScene.traverse((object) => {
+ if (!(object instanceof THREE.Mesh)) return;
+
+ const materials = Array.isArray(object.material)
+ ? object.material
+ : [object.material];
+
+ for (const material of materials) {
+ const materialRecord = material as unknown as Record;
+
+ for (const textureSlot of TEXTURE_SLOTS) {
+ const texture = materialRecord[textureSlot];
+ if (!(texture instanceof THREE.Texture)) continue;
+
+ textureCount += 1;
+
+ if (!texture.image) {
+ missingTextureImageCount += 1;
+ }
+ }
+ }
+ });
+
+ if (missingTextureImageCount > 0) {
+ return {
+ modelId,
+ status: "warning",
+ summary: `${missingTextureImageCount} texture(s) à vérifier`,
+ };
+ }
+
+ if (textureCount === 0) {
+ return {
+ modelId,
+ status: "warning",
+ summary: "Aucune texture détectée",
+ };
+ }
+
+ return {
+ modelId,
+ status: "ok",
+ summary: `${textureCount} texture(s) OK`,
+ };
+}
+
+export function GalleryPage(): React.JSX.Element {
+ const [activeModelIndex, setActiveModelIndex] = useState(0);
+ const [textureDiagnostic, setTextureDiagnostic] = useState(
+ LOADING_TEXTURE_DIAGNOSTIC,
+ );
+ const activeModel = galleryModels[activeModelIndex] ?? galleryModels[0]!;
+ const modelCount = galleryModels.length;
+ const activeTextureDiagnostic =
+ textureDiagnostic.modelId === activeModel.id
+ ? textureDiagnostic
+ : LOADING_TEXTURE_DIAGNOSTIC;
+
+ const goToPreviousModel = (): void => {
+ setActiveModelIndex((currentIndex) =>
+ currentIndex === 0 ? modelCount - 1 : currentIndex - 1,
+ );
+ };
+
+ const goToNextModel = (): void => {
+ setActiveModelIndex((currentIndex) =>
+ currentIndex === modelCount - 1 ? 0 : currentIndex + 1,
+ );
+ };
+
+ return (
+
+ GALERIE
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/router.tsx b/src/router.tsx
index d1a009c..5a06cbf 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -6,7 +6,7 @@ import {
} from "@tanstack/react-router";
import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page";
-import { GalleryPage } from "@/pages/galerie/page";
+import { GalleryPage } from "@/pages/gallery/page";
import {
DocsAnimationRoute,
DocsAudioRoute,
@@ -47,7 +47,7 @@ const editorRoute = createRoute({
const galleryRoute = createRoute({
getParentRoute: () => rootRoute,
- path: "/galerie",
+ path: "/gallery",
component: GalleryPage,
});