style: simplify gallery UI and rename route
This commit is contained in:
@@ -30,7 +30,7 @@ The current prototype puts the player in a repair-oriented world where they prog
|
|||||||
| `/` | Playable 3D experience |
|
| `/` | Playable 3D experience |
|
||||||
| `/?debug` | Playable scene with debug GUI and overlays |
|
| `/?debug` | Playable scene with debug GUI and overlays |
|
||||||
| `/editor` | Local map, dialogue, subtitle, and cinematic editor |
|
| `/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 |
|
| `/docs` | In-app documentation index |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
@@ -99,7 +99,7 @@ Useful local URLs:
|
|||||||
```txt
|
```txt
|
||||||
http://localhost:5173/?debug
|
http://localhost:5173/?debug
|
||||||
http://localhost:5173/editor
|
http://localhost:5173/editor
|
||||||
http://localhost:5173/galerie
|
http://localhost:5173/gallery
|
||||||
http://localhost:5173/docs
|
http://localhost:5173/docs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Galerie des modèles
|
# 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
|
## Objectif
|
||||||
|
|
||||||
@@ -8,10 +8,10 @@ Cette page sert à remercier et valoriser le travail des designers du projet La
|
|||||||
|
|
||||||
## Utilisation
|
## Utilisation
|
||||||
|
|
||||||
1. Ouvrir `/galerie`.
|
1. Ouvrir `/gallery`.
|
||||||
2. Utiliser les flèches pour passer au modèle précédent ou suivant.
|
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.
|
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
|
## 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.
|
- `Bounds` et `Center` recadrent automatiquement le modèle actif.
|
||||||
- `SkyModel` réutilise la skybox du jeu.
|
- `SkyModel` réutilise la skybox du jeu.
|
||||||
- Les animations GLTF présentes dans un modèle sont lancées automatiquement.
|
- 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
|
## Ajouter un modèle
|
||||||
|
|
||||||
|
|||||||
+128
-134
@@ -32,126 +32,31 @@ canvas {
|
|||||||
|
|
||||||
/* Model gallery */
|
/* Model gallery */
|
||||||
.gallery-page {
|
.gallery-page {
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-template-columns: minmax(280px, 0.78fr) minmax(0, 1.22fr);
|
|
||||||
gap: clamp(18px, 4vw, 54px);
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
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;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(226, 232, 240, 0.18);
|
background: #05070c;
|
||||||
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);
|
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
cursor: pointer;
|
|
||||||
font-size: 24px;
|
|
||||||
transition:
|
|
||||||
background 160ms ease,
|
|
||||||
transform 160ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-controls button:hover,
|
.gallery-title {
|
||||||
.gallery-controls button:focus-visible {
|
position: absolute;
|
||||||
background: rgba(125, 211, 252, 0.24);
|
top: clamp(18px, 3vw, 34px);
|
||||||
outline: none;
|
right: clamp(18px, 3vw, 38px);
|
||||||
transform: translateY(-1px);
|
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 {
|
.gallery-canvas-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 360px;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-viewer-error {
|
.gallery-viewer-error {
|
||||||
@@ -164,43 +69,132 @@ canvas {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-help-text {
|
.gallery-bottom-bar {
|
||||||
margin: 0;
|
position: absolute;
|
||||||
padding: 16px 22px 20px;
|
right: 50%;
|
||||||
border-top: 1px solid rgba(226, 232, 240, 0.14);
|
bottom: clamp(18px, 4vw, 44px);
|
||||||
color: #cbd5e1;
|
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-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.gallery-texture-status {
|
||||||
.gallery-page {
|
position: absolute;
|
||||||
grid-template-columns: 1fr;
|
left: clamp(18px, 3vw, 38px);
|
||||||
min-height: 100vh;
|
bottom: clamp(22px, 4vw, 50px);
|
||||||
height: auto;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-hero {
|
.gallery-texture-status--ok {
|
||||||
align-self: start;
|
color: #bbf7d0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-viewer-panel {
|
.gallery-texture-status--warning {
|
||||||
min-height: 620px;
|
color: #fde68a;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
.gallery-texture-status--loading {
|
||||||
.gallery-viewer-header {
|
color: rgba(226, 232, 240, 0.72);
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-controls {
|
@media (max-width: 720px) {
|
||||||
width: 100%;
|
.gallery-title {
|
||||||
|
right: 50%;
|
||||||
|
transform: translateX(50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-controls button {
|
.gallery-bottom-bar {
|
||||||
flex: 1;
|
grid-template-columns: 48px minmax(150px, 1fr) 48px;
|
||||||
|
width: calc(100vw - 36px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { HomePage } from "@/pages/page";
|
import { HomePage } from "@/pages/page";
|
||||||
import { EditorPage } from "@/pages/editor/page";
|
import { EditorPage } from "@/pages/editor/page";
|
||||||
import { GalleryPage } from "@/pages/galerie/page";
|
import { GalleryPage } from "@/pages/gallery/page";
|
||||||
import {
|
import {
|
||||||
DocsAnimationRoute,
|
DocsAnimationRoute,
|
||||||
DocsAudioRoute,
|
DocsAudioRoute,
|
||||||
@@ -47,7 +47,7 @@ const editorRoute = createRoute({
|
|||||||
|
|
||||||
const galleryRoute = createRoute({
|
const galleryRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/galerie",
|
path: "/gallery",
|
||||||
component: GalleryPage,
|
component: GalleryPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user