style: simplify gallery UI and rename route

This commit is contained in:
Tom Boullay
2026-05-25 17:13:21 +02:00
parent 36180279b2
commit e9fb36f9dc
6 changed files with 453 additions and 349 deletions
+2 -2
View File
@@ -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
```
+5 -4
View File
@@ -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
+132 -138
View File
@@ -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%);
}
}
-203
View File
@@ -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 (
<div className="gallery-viewer-error" role="status">
Ce modèle ne peut pas être affiché pour le moment.
</div>
);
}
return this.props.children;
}
}
function GalleryModelPreview({ model }: GalleryModelProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(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 (
<group ref={groupRef}>
<primitive object={modelScene} />
</group>
);
}
function GalleryScene({ model }: GalleryModelProps): React.JSX.Element {
return (
<>
<SkyModel
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
/>
<ambientLight intensity={0.75} />
<directionalLight position={[6, 8, 4]} intensity={2.1} />
<Bounds fit clip observe margin={1.35}>
<Center>
<GalleryModelPreview model={model} />
</Center>
</Bounds>
<OrbitControls
makeDefault
enableDamping
autoRotate
autoRotateSpeed={0.5}
/>
</>
);
}
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 (
<main className="gallery-page">
<section className="gallery-hero" aria-labelledby="gallery-title">
<p className="gallery-eyebrow">Galerie des modèles</p>
<h1 id="gallery-title">Merci aux designers de La Fabrik</h1>
<p>
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.
</p>
</section>
<section className="gallery-viewer-panel" aria-label="Viewer 3D">
<div className="gallery-viewer-header">
<div>
<p className="gallery-model-count">
{activeModelIndex + 1} / {modelCount}
</p>
<h2>{activeModel.name}</h2>
<code>{activeModel.path}</code>
</div>
<div className="gallery-controls" aria-label="Navigation des modèles">
<button
type="button"
onClick={goToPreviousModel}
aria-label="Modèle précédent"
>
</button>
<button
type="button"
onClick={goToNextModel}
aria-label="Modèle suivant"
>
</button>
</div>
</div>
<div className="gallery-canvas-frame">
<GalleryViewerErrorBoundary resetKey={activeModel.id}>
<Canvas
camera={{ position: [3.5, 2.4, 4.5], fov: 45 }}
dpr={[1, 2]}
>
<Suspense fallback={null}>
<GalleryScene key={activeModel.id} model={activeModel} />
</Suspense>
</Canvas>
</GalleryViewerErrorBoundary>
</div>
<p className="gallery-help-text">
Utilise les flèches pour changer de modèle. Tu peux tourner autour du
modèle avec la souris ou le doigt.
</p>
</section>
</main>
);
}
+312
View File
@@ -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 (
<div className="gallery-viewer-error" role="status">
Ce modèle ne peut pas être affiché pour le moment.
</div>
);
}
return this.props.children;
}
}
function GalleryModelPreview({
model,
onTextureDiagnosticReady,
}: GallerySceneProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(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 (
<group ref={groupRef}>
<primitive object={modelScene} />
</group>
);
}
function GalleryScene({
model,
onTextureDiagnosticReady,
}: GallerySceneProps): React.JSX.Element {
return (
<>
<SkyModel
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
/>
<ambientLight intensity={0.75} />
<directionalLight position={[6, 8, 4]} intensity={2.1} />
<Bounds fit clip observe margin={1.35}>
<Center>
<GalleryModelPreview
model={model}
onTextureDiagnosticReady={onTextureDiagnosticReady}
/>
</Center>
</Bounds>
<OrbitControls
makeDefault
enableDamping
autoRotate
autoRotateSpeed={0.5}
/>
</>
);
}
function TextureStatusBadge({
diagnostic,
}: {
diagnostic: TextureDiagnostic;
}): React.JSX.Element {
const hasWarning = diagnostic.status === "warning";
const Icon = hasWarning ? TriangleAlert : CheckCircle2;
return (
<div
className={`gallery-texture-status gallery-texture-status--${diagnostic.status}`}
>
<Icon aria-hidden="true" size={15} strokeWidth={2.1} />
<span>{diagnostic.summary}</span>
</div>
);
}
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<string, unknown>;
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<TextureDiagnostic>(
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 (
<main className="gallery-page">
<h1 className="gallery-title">GALERIE</h1>
<div className="gallery-canvas-frame" aria-label="Viewer 3D">
<GalleryViewerErrorBoundary resetKey={activeModel.id}>
<Canvas camera={{ position: [3.5, 2.4, 4.5], fov: 45 }} dpr={[1, 2]}>
<Suspense fallback={null}>
<GalleryScene
key={activeModel.id}
model={activeModel}
onTextureDiagnosticReady={setTextureDiagnostic}
/>
</Suspense>
</Canvas>
</GalleryViewerErrorBoundary>
</div>
<nav className="gallery-bottom-bar" aria-label="Navigation des modèles">
<button
type="button"
onClick={goToPreviousModel}
aria-label="Modèle précédent"
>
<ArrowLeft aria-hidden="true" size={22} strokeWidth={1.8} />
</button>
<div className="gallery-model-info">
<span>{activeModel.name}</span>
<small>
{activeModelIndex + 1} / {modelCount}
</small>
</div>
<button
type="button"
onClick={goToNextModel}
aria-label="Modèle suivant"
>
<ArrowRight aria-hidden="true" size={22} strokeWidth={1.8} />
</button>
</nav>
<TextureStatusBadge diagnostic={activeTextureDiagnostic} />
</main>
);
}
+2 -2
View File
@@ -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,
});