From e9fb36f9dce1b802bae382312316a6e0607b8b71 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 25 May 2026 17:13:21 +0200 Subject: [PATCH] style: simplify gallery UI and rename route --- README.md | 4 +- docs/user/gallery.md | 9 +- src/index.css | 270 ++++++++++++++++---------------- src/pages/galerie/page.tsx | 203 ------------------------ src/pages/gallery/page.tsx | 312 +++++++++++++++++++++++++++++++++++++ src/router.tsx | 4 +- 6 files changed, 453 insertions(+), 349 deletions(-) delete mode 100644 src/pages/galerie/page.tsx create mode 100644 src/pages/gallery/page.tsx 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 ( +
+
+ ); +} + +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, });