Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf71148935 | |||
| 1b2241df49 | |||
| d7351e5f37 | |||
| 6a412c7b00 | |||
| e9fb36f9dc | |||
| 36180279b2 | |||
| 626dc47bbe |
@@ -25,12 +25,13 @@ The current prototype puts the player in a repair-oriented world where they prog
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --------- | --------------------------------------------------- |
|
||||
| `/` | Playable 3D experience |
|
||||
| `/?debug` | Playable scene with debug GUI and overlays |
|
||||
| `/editor` | Local map, dialogue, subtitle, and cinematic editor |
|
||||
| `/docs` | In-app documentation index |
|
||||
| Route | Purpose |
|
||||
| ---------- | --------------------------------------------------- |
|
||||
| `/` | Playable 3D experience |
|
||||
| `/?debug` | Playable scene with debug GUI and overlays |
|
||||
| `/editor` | Local map, dialogue, subtitle, and cinematic editor |
|
||||
| `/gallery` | 3D model gallery for browsing project assets |
|
||||
| `/docs` | In-app documentation index |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -98,6 +99,7 @@ Useful local URLs:
|
||||
```txt
|
||||
http://localhost:5173/?debug
|
||||
http://localhost:5173/editor
|
||||
http://localhost:5173/gallery
|
||||
http://localhost:5173/docs
|
||||
```
|
||||
|
||||
@@ -148,6 +150,7 @@ WS ws://localhost:8000/ws
|
||||
| `docs/user/features.md` | Implemented feature inventory |
|
||||
| `docs/user/main-feature.md` | User-facing repair-game walkthrough |
|
||||
| `docs/user/editor.md` | Editor user guide |
|
||||
| `docs/user/gallery.md` | Model gallery user guide |
|
||||
| `docs/code-review-preparation.md` | French code-review preparation support |
|
||||
|
||||
## Current Caveats
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
# Game States & Substates
|
||||
|
||||
Documentation technique pour le testing et debugging du flow de jeu.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
```
|
||||
intro ──► bike ──► pylone ──► ferme ──► outro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Main States
|
||||
|
||||
| State | Description |
|
||||
| -------- | ------------------------------------------------------------------ |
|
||||
| `intro` | Séquence d'introduction (cinématique, naming, premier déplacement) |
|
||||
| `bike` | Mission de réparation du vélo |
|
||||
| `pylone` | Quête du pylone (alert → searching → helped → manipulation) |
|
||||
| `ferme` | Mission de réparation de la ferme |
|
||||
| `outro` | Fin du jeu |
|
||||
|
||||
---
|
||||
|
||||
## Intro (GameStep)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ intro.currentStep │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
intro ──► sequence_video ──► naming ──► start-move ──► bike
|
||||
```
|
||||
|
||||
### Étapes
|
||||
|
||||
| Step | Trigger | Action | Passage vers |
|
||||
| ---------------- | -------- | ---------------------------------------------- | ------------------------------------------------ |
|
||||
| `intro` | Initial | État initial | Auto → `sequence_video` quand `sceneReady: true` |
|
||||
| `sequence_video` | GameFlow | Déclenche la cinématique dans `GameCinematics` | Fin cinématique → `naming` |
|
||||
| `naming` | IntroUI | Affiche l'input pour le prénom | Submit → `start-move` |
|
||||
| `start-move` | GameFlow | Active le mouvement (`canMove: true`) | Via zone "fabrikExit" → `bike` |
|
||||
|
||||
### Transition vers bike
|
||||
|
||||
- **Trigger**: Zone `fabrikExit` dans `zones.ts`
|
||||
- **Action**: `advanceGameState()` dans `ZoneDetection.tsx`
|
||||
- **Résultat**: `mainState: "bike"`, `intro.hasCompleted: true`
|
||||
|
||||
---
|
||||
|
||||
## Bike (MissionStep)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ bike.currentStep (MissionStep) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
locked ──► waiting ──► inspected ──► fragmented ──► scanning ──► repairing ──► reassembling ──► done
|
||||
```
|
||||
|
||||
### Transition vers pylone
|
||||
|
||||
- **Trigger**: `bike.currentStep: "done"`
|
||||
- **Action**: `completeBikeState()` dans useGameStore
|
||||
- **Résultat**: `mainState: "pylone"`, `pylone.currentStep: "locked"`
|
||||
|
||||
---
|
||||
|
||||
## Pylone (PyloneStep)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ pylone.currentStep (PyloneStep) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
locked (bypass) ──► alert ──► searching ──► helped ──► manipulation ──► outro
|
||||
```
|
||||
|
||||
### Étapes
|
||||
|
||||
| Step | Trigger | Action | Passage vers |
|
||||
| -------------- | ---------------------------------- | ---------------------------------- | --------------------------------------------------------- |
|
||||
| `locked` | Initial (après bike) | État initial | **Bypass automatique** → `alert` via `advanceGameState()` |
|
||||
| `alert` | `advanceGameState()` | Affiche l'alerte (à implémenter) | Via `advanceGameState()` |
|
||||
| `searching` | `advanceGameState()` | Déclenché par zone "searchingZone" | Via `advanceGameState()` |
|
||||
| `helped` | Interaction avec `NPCHelper` | Dialogue avec le villageois | Via interaction 3D |
|
||||
| `manipulation` | Interaction avec `PyloneDestroyed` | Interaction avec l'objet central | Via `advanceGameState()` → `outro` |
|
||||
|
||||
### Bypass automatique
|
||||
|
||||
```typescript
|
||||
// useGameStore.ts - advancePyloneStep()
|
||||
if (state.pylone.currentStep === "locked") {
|
||||
return { pylone: { ...state.pylone, currentStep: "alert" } };
|
||||
}
|
||||
```
|
||||
|
||||
### Transition vers outro
|
||||
|
||||
- **Trigger**: `pylone.currentStep: "manipulation"` + `advanceGameState()`
|
||||
- **Action**: `advancePyloneStep()` détecte fin de la séquence
|
||||
- **Résultat**: `mainState: "outro"`
|
||||
|
||||
---
|
||||
|
||||
## Ferme (MissionStep)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ferme.currentStep (MissionStep) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
locked ──► waiting ──► inspected ──► fragmented ──► scanning ──► repairing ──► reassembling ──► done
|
||||
```
|
||||
|
||||
### Transition vers outro
|
||||
|
||||
- **Trigger**: `ferme.currentStep: "done"`
|
||||
- **Action**: `completeFermeState()` dans useGameStore
|
||||
- **Résultat**: `mainState: "outro"`, `ferme.irrigationFixed: true`
|
||||
|
||||
---
|
||||
|
||||
## Outro
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ outro.hasStarted │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
waiting ──► started
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debug Panel
|
||||
|
||||
Le debug panel permet de tester toutes les transitions :
|
||||
|
||||
### Utilisation
|
||||
|
||||
1. Ouvrir le jeu en mode debug (`Debug: true` dans `Debug.ts`)
|
||||
2. Le panneau "Game State" apparaît en bas à gauche
|
||||
3. **Main state**: Sélectionner le state principal
|
||||
4. **Sub state**: Sélectionner le sub-state
|
||||
5. **Previous/Next step**: Avancer ou reculer d'un step
|
||||
6. **Reset**: Remettre à l'état initial
|
||||
|
||||
### Raccourcis clavier
|
||||
|
||||
| Action | Clavier |
|
||||
| ------- | ------------------ |
|
||||
| Avancer | Debug panel button |
|
||||
| Reculer | Debug panel button |
|
||||
| Reset | Debug panel button |
|
||||
|
||||
---
|
||||
|
||||
## Comment tester chaque section
|
||||
|
||||
### Tester l'intro
|
||||
|
||||
1. Vérifier que `sceneReady: false` au démarrage
|
||||
2. Attendre que le loader termine (`sceneReady: true`)
|
||||
3. Vérifier `intro.currentStep: "intro"` → auto vers `sequence_video`
|
||||
4. Si cinématique fonctionne : `sequence_video` → `naming`
|
||||
5. Entrer un prénom : `naming` → `start-move`
|
||||
6. Vérifier `canMove: true` après `start-move`
|
||||
7. Entrer dans la zone `fabrikExit` → `mainState: "bike"`
|
||||
|
||||
### Tester bike
|
||||
|
||||
1. Via debug panel, avancer jusqu'à `done`
|
||||
2. Vérifier `mainState: "pylone"`
|
||||
|
||||
### Tester pylone
|
||||
|
||||
1. Via debug panel, avancer (bypass `locked` → `alert`)
|
||||
2. Vérifier `pylone.currentStep: "alert"`
|
||||
3. Avancer : `alert` → `searching` → `helped` → `manipulation`
|
||||
4. Après `manipulation`, vérifier `mainState: "outro"`
|
||||
|
||||
### Tester ferme
|
||||
|
||||
1. Via debug panel, avancer dans bike jusqu'à `done`
|
||||
2. Vérifier `mainState: "pylone"` → `ferme` (après pylone)
|
||||
3. Avancer jusqu'à `done`
|
||||
4. Vérifier `mainState: "outro"`
|
||||
|
||||
---
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
| Fichier | Rôle |
|
||||
| ------------------------------------------------- | ------------------------------------- |
|
||||
| `src/managers/stores/useGameStore.ts` | Store Zustand avec toutes les actions |
|
||||
| `src/types/game.ts` | Définition de `GameStep` |
|
||||
| `src/types/gameplay/pylone.ts` | Définition de `PyloneStep` |
|
||||
| `src/types/gameplay/repairMission.ts` | Définition de `MissionStep` |
|
||||
| `src/components/game/GameFlow.tsx` | Logique de transition de l'intro |
|
||||
| `src/components/zone/ZoneDetection.tsx` | Déclenchement des zones |
|
||||
| `src/components/ui/debug/GameStateDebugPanel.tsx` | Outil de debug |
|
||||
|
||||
---
|
||||
|
||||
## État initial
|
||||
|
||||
```typescript
|
||||
{
|
||||
mainState: "intro",
|
||||
isCinematicPlaying: false,
|
||||
sceneReady: false,
|
||||
missionFlow: {
|
||||
activityCity: true,
|
||||
canMove: false,
|
||||
dialogMessage: null,
|
||||
playerName: "",
|
||||
},
|
||||
intro: { currentStep: "intro", dialogueAudio: null, hasCompleted: false, isBikeUnlocked: false },
|
||||
bike: { currentStep: "locked", dialogueAudio: null, isRepaired: false },
|
||||
pylone: { currentStep: "locked", dialogueAudio: null, isPowered: false },
|
||||
ferme: { currentStep: "locked", dialogueAudio: null, irrigationFixed: false },
|
||||
outro: { dialogueAudio: null, hasStarted: false },
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# Galerie des modèles
|
||||
|
||||
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
|
||||
|
||||
Cette page sert à remercier et valoriser le travail des designers du projet La Fabrik. Chaque modèle est affiché dans un canvas dédié, avec la même skybox et le même lighting que l'expérience principale.
|
||||
|
||||
## Utilisation
|
||||
|
||||
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. Utiliser le bouton de réglages à droite pour ouvrir ou fermer le panneau lumière.
|
||||
5. Lire le diagnostic texture discret pour savoir si le modèle chargé semble correct côté textures.
|
||||
|
||||
## Fonctionnement
|
||||
|
||||
- La liste des modèles est déclarée dans `src/data/galleryModels.ts`.
|
||||
- Le viewer utilise `@react-three/fiber` et `@react-three/drei`.
|
||||
- `OrbitControls` permet de manipuler la caméra autour du modèle.
|
||||
- `Bounds` et `Center` recadrent automatiquement le modèle actif.
|
||||
- `SkyModel` réutilise la skybox du jeu, avec un matériau non éclairé uniquement dans la galerie pour éviter que certaines faces deviennent noires avec une caméra orbitale libre.
|
||||
- Les lumières reprennent les valeurs par défaut du jeu, puis peuvent être ajustées dans le panneau latéral.
|
||||
- `OrbitControls` autorise une orbite verticale complète pour inspecter le dessous des modèles.
|
||||
- Le viewer désactive les normal maps dans la preview pour limiter les coutures visibles sur certains exports découpés en plusieurs meshes.
|
||||
- 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
|
||||
|
||||
1. Ajouter le dossier du modèle dans `public/models/{nom}`.
|
||||
2. Vérifier que le modèle possède un fichier chargeable, par exemple `model.gltf`, `model.glb` ou un nom explicite comme `potager.gltf`.
|
||||
3. Ajouter une entrée dans `src/data/galleryModels.ts` avec un `id`, un `name` et un `path`.
|
||||
|
||||
Exemple :
|
||||
|
||||
```ts
|
||||
{ id: "nouveau-modele", name: "Nouveau modèle", path: "/models/nouveau-modele/model.gltf" }
|
||||
```
|
||||
|
||||
## Limites connues
|
||||
|
||||
- Le navigateur ne liste pas automatiquement les dossiers de `public/models/`, donc la liste reste déclarative.
|
||||
- Les modèles très lourds peuvent prendre du temps à charger.
|
||||
- La galerie est un viewer simple : elle ne remplace pas les outils d'inspection avancée comme Blender ou le viewer d'upload.
|
||||
@@ -1,21 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"cinematics": [
|
||||
{
|
||||
"id": "intro_sequence",
|
||||
"trigger": "intro_sequence",
|
||||
"cameraKeyframes": [
|
||||
{ "time": 0, "position": [8, 5, 12], "target": [0, 2, 0] },
|
||||
{ "time": 8, "position": [12, 4, -6], "target": [10, 1.4, -8] },
|
||||
{ "time": 16, "position": [5, 6, -15], "target": [0, 3, -20] },
|
||||
{ "time": 24, "position": [0, 8, -30], "target": [0, 0, -40] }
|
||||
],
|
||||
"dialogueCues": [
|
||||
{ "time": 0, "dialogueId": "intro_welcome" },
|
||||
{ "time": 8, "dialogueId": "intro_explanation" },
|
||||
{ "time": 16, "dialogueId": "intro_mission" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "intro_overview",
|
||||
"timecode": 0,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,36 +1,64 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { AUDIO_PATHS } from "@/data/audioConfig";
|
||||
|
||||
export function GameFlow(): null {
|
||||
const step = useGameStore((state) => state.intro.currentStep);
|
||||
const setStep = useGameStore((state) => state.setIntroStep);
|
||||
const playVideo = useGameStore((state) => state.playVideo);
|
||||
const isCinematicPlaying = useGameStore((state) => state.isCinematicPlaying);
|
||||
const sceneReady = useGameStore((state) => state.sceneReady);
|
||||
const setActivityCity = useGameStore((state) => state.setActivityCity);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current && step === "intro" && sceneReady) {
|
||||
if (!hasInitialized.current && step === "intro") {
|
||||
hasInitialized.current = true;
|
||||
setStep("sequence_video");
|
||||
playVideo("/videos/intro.webm");
|
||||
setStep("start-intro");
|
||||
}
|
||||
}, [step, setStep, sceneReady, playVideo]);
|
||||
}, [step, setStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "sequence_video" && !isCinematicPlaying) {
|
||||
setStep("start-move");
|
||||
}
|
||||
}, [step, isCinematicPlaying, setStep]);
|
||||
if (step === "start-intro") {
|
||||
const audio = AudioManager.getInstance();
|
||||
audio.playSoundWithCallback(AUDIO_PATHS.intro, 0.5, () => {
|
||||
setStep("naming");
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "start-move") {
|
||||
setCanMove(true);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (step === "bienvenue") {
|
||||
const audio = AudioManager.getInstance();
|
||||
audio.playSoundWithCallback(AUDIO_PATHS.bienvenue, 0.5, () => {
|
||||
setCanMove(true);
|
||||
setStep("star-move");
|
||||
});
|
||||
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (step === "mission2") {
|
||||
setActivityCity(false);
|
||||
const audio = AudioManager.getInstance();
|
||||
audio.playSound(AUDIO_PATHS.alertCentral, 0.5);
|
||||
}
|
||||
|
||||
if (step === "searching") {
|
||||
const audio = AudioManager.getInstance();
|
||||
audio.playSound(AUDIO_PATHS.searching, 0.5);
|
||||
}
|
||||
|
||||
if (step === "helped") {
|
||||
const audio = AudioManager.getInstance();
|
||||
audio.playSound(AUDIO_PATHS.helped, 0.5);
|
||||
}
|
||||
|
||||
if (step === "manipulation") {
|
||||
setCanMove(false);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [step, setCanMove]);
|
||||
}, [step, setStep, setActivityCity, setCanMove]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { createNetShader } from "@/shaders/NetShader";
|
||||
|
||||
export function NetTest(): React.JSX.Element {
|
||||
const materialRef = useRef<THREE.ShaderMaterial>(null);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (materialRef.current) {
|
||||
materialRef.current.uniforms.uTime.value += delta;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh position={[0, 2, -5]}>
|
||||
<planeGeometry args={[2, 2, 1, 1]} />
|
||||
<primitive
|
||||
object={createNetShader()}
|
||||
ref={materialRef}
|
||||
attach="material"
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -8,17 +8,17 @@ interface NPCHelperProps {
|
||||
}
|
||||
|
||||
export function NPCHelper({ position }: NPCHelperProps): React.JSX.Element {
|
||||
const step = useGameStore((state) => state.pylone.currentStep);
|
||||
const setPyloneStep = useGameStore((state) => state.setPyloneState);
|
||||
const step = useGameStore((state) => state.intro.currentStep);
|
||||
const setStep = useGameStore((state) => state.setIntroStep);
|
||||
const debug = Debug.getInstance();
|
||||
|
||||
const handlePress = (): void => {
|
||||
if (step === "searching") {
|
||||
setPyloneStep({ currentStep: "helped" });
|
||||
setStep("helped");
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShow = step === "searching" || step === "helped" || debug.active;
|
||||
const shouldShow = step === "searching" || debug.active;
|
||||
|
||||
if (!shouldShow) {
|
||||
return <></>;
|
||||
|
||||
@@ -10,8 +10,8 @@ interface PyloneDestroyedProps {
|
||||
export function PyloneDestroyed({
|
||||
position,
|
||||
}: PyloneDestroyedProps): React.JSX.Element {
|
||||
const step = useGameStore((state) => state.pylone.currentStep);
|
||||
const setPyloneStep = useGameStore((state) => state.setPyloneState);
|
||||
const step = useGameStore((state) => state.intro.currentStep);
|
||||
const setStep = useGameStore((state) => state.setIntroStep);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
const showDialog = useGameStore((state) => state.showDialog);
|
||||
const debug = Debug.getInstance();
|
||||
@@ -19,7 +19,7 @@ export function PyloneDestroyed({
|
||||
const handlePress = (): void => {
|
||||
if (step === "helped") {
|
||||
setCanMove(false);
|
||||
setPyloneStep({ currentStep: "manipulation" });
|
||||
setStep("manipulation");
|
||||
} else if (step === "searching") {
|
||||
showDialog(
|
||||
"Cet objet est trop lourd pour le porter tout seul, trouve de l'aide",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { Component, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
|
||||
@@ -8,12 +8,16 @@ interface SkyModelProps {
|
||||
modelPath: string;
|
||||
fallbackModelPath?: string | undefined;
|
||||
fallbackScale?: number | undefined;
|
||||
materialSide?: THREE.Side | undefined;
|
||||
scale?: number | undefined;
|
||||
unlit?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface SkyModelContentProps {
|
||||
materialSide: THREE.Side;
|
||||
modelPath: string;
|
||||
scale: number;
|
||||
unlit: boolean;
|
||||
}
|
||||
|
||||
interface SkyModelErrorBoundaryProps {
|
||||
@@ -54,23 +58,37 @@ class SkyModelErrorBoundary extends Component<
|
||||
export function SkyModel({
|
||||
fallbackModelPath,
|
||||
fallbackScale = SKY_MODEL_SCALE,
|
||||
materialSide = THREE.BackSide,
|
||||
modelPath,
|
||||
scale = SKY_MODEL_SCALE,
|
||||
unlit = false,
|
||||
}: SkyModelProps): React.JSX.Element {
|
||||
const fallback = fallbackModelPath ? (
|
||||
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
|
||||
<SkyModelContent
|
||||
materialSide={materialSide}
|
||||
modelPath={fallbackModelPath}
|
||||
scale={fallbackScale}
|
||||
unlit={unlit}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
|
||||
<SkyModelContent modelPath={modelPath} scale={scale} />
|
||||
<SkyModelContent
|
||||
materialSide={materialSide}
|
||||
modelPath={modelPath}
|
||||
scale={scale}
|
||||
unlit={unlit}
|
||||
/>
|
||||
</SkyModelErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function SkyModelContent({
|
||||
materialSide,
|
||||
modelPath,
|
||||
scale,
|
||||
unlit,
|
||||
}: SkyModelContentProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
@@ -78,7 +96,16 @@ function SkyModelContent({
|
||||
scope: "SkyModel",
|
||||
scale,
|
||||
});
|
||||
const model = useMemo(() => createSkyModel(scene), [scene]);
|
||||
const model = useMemo(
|
||||
() => createSkyModel(scene, materialSide, unlit),
|
||||
[materialSide, scene, unlit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeSkyModelMaterials(model);
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
useFrame(() => {
|
||||
groupRef.current?.position.copy(camera.position);
|
||||
@@ -96,7 +123,11 @@ function SkyModelContent({
|
||||
);
|
||||
}
|
||||
|
||||
function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
|
||||
function createSkyModel(
|
||||
scene: THREE.Object3D,
|
||||
materialSide: THREE.Side,
|
||||
unlit: boolean,
|
||||
): THREE.Object3D {
|
||||
const model = scene.clone(true);
|
||||
|
||||
model.traverse((object) => {
|
||||
@@ -106,20 +137,57 @@ function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
object.material = Array.isArray(object.material)
|
||||
? object.material.map(createSkyMaterial)
|
||||
: createSkyMaterial(object.material);
|
||||
? object.material.map((material) =>
|
||||
createSkyMaterial(material, materialSide, unlit),
|
||||
)
|
||||
: createSkyMaterial(object.material, materialSide, unlit);
|
||||
});
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
function createSkyMaterial<T extends THREE.Material>(material: T): T {
|
||||
const skyMaterial = material.clone();
|
||||
skyMaterial.side = THREE.BackSide;
|
||||
function createSkyMaterial<T extends THREE.Material>(
|
||||
material: T,
|
||||
materialSide: THREE.Side,
|
||||
unlit: boolean,
|
||||
): THREE.Material {
|
||||
const skyMaterial = unlit
|
||||
? createUnlitSkyMaterial(material)
|
||||
: material.clone();
|
||||
skyMaterial.side = materialSide;
|
||||
skyMaterial.depthTest = false;
|
||||
skyMaterial.depthWrite = false;
|
||||
|
||||
return skyMaterial as T;
|
||||
return skyMaterial;
|
||||
}
|
||||
|
||||
function createUnlitSkyMaterial(
|
||||
material: THREE.Material,
|
||||
): THREE.MeshBasicMaterial {
|
||||
const sourceMaterial = material as THREE.MeshStandardMaterial;
|
||||
|
||||
return new THREE.MeshBasicMaterial({
|
||||
color: sourceMaterial.color?.clone() ?? new THREE.Color("#ffffff"),
|
||||
map: sourceMaterial.map ?? null,
|
||||
opacity: sourceMaterial.opacity,
|
||||
toneMapped: false,
|
||||
transparent: sourceMaterial.transparent,
|
||||
});
|
||||
}
|
||||
|
||||
function disposeSkyModelMaterials(model: THREE.Object3D): void {
|
||||
model.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
if (Array.isArray(object.material)) {
|
||||
for (const material of object.material) {
|
||||
material.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
object.material.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
useGLTF.preload("/models/skybox/skybox.gltf");
|
||||
|
||||
@@ -1,10 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
|
||||
export function IntroUI(): React.JSX.Element | null {
|
||||
const step = useGameStore((state) => state.intro.currentStep);
|
||||
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
||||
const setStep = useGameStore((state) => state.setIntroStep);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
if (step !== "naming") return null;
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (inputValue.trim() === "") return;
|
||||
|
||||
setPlayerName(inputValue.trim());
|
||||
setStep("bienvenue");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
padding: "2rem",
|
||||
borderRadius: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.5rem",
|
||||
minWidth: "300px",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: "#fff",
|
||||
margin: 0,
|
||||
fontSize: "1.5rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Quel est votre prenom ?
|
||||
</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Votre prenom"
|
||||
autoFocus
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
fontSize: "1rem",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #444",
|
||||
backgroundColor: "#2a2a2a",
|
||||
color: "#fff",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={inputValue.trim() === ""}
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
fontSize: "1rem",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
backgroundColor: inputValue.trim() ? "#4a9" : "#444",
|
||||
color: "#fff",
|
||||
cursor: inputValue.trim() ? "pointer" : "not-allowed",
|
||||
transition: "background-color 0.2s",
|
||||
}}
|
||||
>
|
||||
Valider
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BienvenueDisplay(): React.JSX.Element | null {
|
||||
const step = useGameStore((state) => state.intro.currentStep);
|
||||
const playerName = useGameStore((state) => state.missionFlow.playerName);
|
||||
|
||||
if (step !== "start-move") return null;
|
||||
if (step !== "bienvenue") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
|
||||
export function VideoPlayer(): null {
|
||||
const currentVideo = useGameStore((state) => state.missionFlow.currentVideo);
|
||||
const clearVideo = useGameStore((state) => state.clearVideo);
|
||||
const setCinematicPlaying = useGameStore(
|
||||
(state) => state.setCinematicPlaying,
|
||||
);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentVideo) {
|
||||
setCinematicPlaying(true);
|
||||
}
|
||||
}, [currentVideo, setCinematicPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && currentVideo) {
|
||||
closeVideo();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [currentVideo]);
|
||||
|
||||
const closeVideo = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
clearVideo();
|
||||
setCinematicPlaying(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleEnded = () => {
|
||||
closeVideo();
|
||||
};
|
||||
|
||||
video.addEventListener("ended", handleEnded);
|
||||
return () => video.removeEventListener("ended", handleEnded);
|
||||
}, []);
|
||||
|
||||
if (!currentVideo) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.95)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={currentVideo}
|
||||
autoPlay
|
||||
playsInline
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={closeVideo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/managers/stores/useGameStore";
|
||||
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
|
||||
import { GAME_STEPS, type GameStep } from "@/types/game";
|
||||
import { PYLONE_STEPS, type PyloneStep } from "@/types/gameplay/pylone";
|
||||
|
||||
const MAIN_STATES: MainGameState[] = [
|
||||
"intro",
|
||||
@@ -55,11 +54,9 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
const subStateOptions =
|
||||
mainState === "intro"
|
||||
? GAME_STEPS
|
||||
: mainState === "pylone"
|
||||
? PYLONE_STEPS
|
||||
: mainState === "outro"
|
||||
? ["waiting", "started"]
|
||||
: MISSION_STEPS;
|
||||
: mainState === "outro"
|
||||
? ["waiting", "started"]
|
||||
: MISSION_STEPS;
|
||||
|
||||
function setSubState(nextSubState: string): void {
|
||||
if (mainState === "intro") {
|
||||
@@ -67,11 +64,6 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "pylone") {
|
||||
setPyloneState({ currentStep: nextSubState as PyloneStep });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "outro") {
|
||||
setOutroState({ hasStarted: nextSubState === "started" });
|
||||
return;
|
||||
@@ -84,6 +76,11 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "pylone") {
|
||||
setPyloneState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "ferme") {
|
||||
setFermeState({ currentStep: nextSubState });
|
||||
return;
|
||||
@@ -98,6 +95,11 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextMainState === "pylone" && pyloneStep === "locked") {
|
||||
setPyloneState({ currentStep: "waiting" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextMainState === "ferme" && fermeStep === "locked") {
|
||||
setFermeState({ currentStep: "waiting" });
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@ export function ZoneDetection(): null {
|
||||
const triggeredZones = useRef<Set<string>>(new Set());
|
||||
const debug = Debug.getInstance();
|
||||
const step = useGameStore((state) => state.intro.currentStep);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const setStep = useGameStore((state) => state.setIntroStep);
|
||||
const setPyloneStep = useGameStore((state) => state.setPyloneState);
|
||||
const advanceGameState = useGameStore((state) => state.advanceGameState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debug.active) return;
|
||||
@@ -68,11 +65,7 @@ export function ZoneDetection(): null {
|
||||
const distanceSq = _playerPos.distanceToSquared(_zonePos);
|
||||
|
||||
if (distanceSq <= zone.radius * zone.radius) {
|
||||
if (zone.targetStep === "bike" && mainState === "intro") {
|
||||
advanceGameState();
|
||||
} else {
|
||||
setStep(zone.targetStep);
|
||||
}
|
||||
setStep(zone.targetStep);
|
||||
triggeredZones.current.add(zone.id);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -109,6 +109,12 @@ export const docGroups: DocGroup[] = [
|
||||
subtitle: "Components and usage",
|
||||
meta: "15",
|
||||
},
|
||||
{
|
||||
path: "/docs/gallery",
|
||||
title: "Model Gallery",
|
||||
subtitle: "Browsing 3D assets",
|
||||
meta: "16",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -118,7 +124,7 @@ export const docGroups: DocGroup[] = [
|
||||
path: "/docs/code-review",
|
||||
title: "Code Review Prep",
|
||||
subtitle: "Presentation support",
|
||||
meta: "16",
|
||||
meta: "17",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
export interface GalleryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const galleryModels: GalleryModel[] = [
|
||||
{ id: "arbre", name: "Arbre", path: "/models/arbre/model.gltf" },
|
||||
{
|
||||
id: "arbre-animated",
|
||||
name: "Arbre animé",
|
||||
path: "/models/arbre-animated/model.gltf",
|
||||
},
|
||||
{ id: "blocking", name: "Blocking", path: "/models/blocking/model.gltf" },
|
||||
{
|
||||
id: "boiteauxlettres",
|
||||
name: "Boîte aux lettres",
|
||||
path: "/models/boiteauxlettres/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "boiteimmeuble",
|
||||
name: "Boîte immeuble",
|
||||
path: "/models/boiteimmeuble/model.gltf",
|
||||
},
|
||||
{ id: "buisson", name: "Buisson", path: "/models/buisson/model.gltf" },
|
||||
{
|
||||
id: "buisson-animated",
|
||||
name: "Buisson animé",
|
||||
path: "/models/buisson-animated/model.gltf",
|
||||
},
|
||||
{ id: "cable1", name: "Câble 1", path: "/models/cable1/model.gltf" },
|
||||
{ id: "cable2", name: "Câble 2", path: "/models/cable2/model.gltf" },
|
||||
{
|
||||
id: "champdeble",
|
||||
name: "Champ de blé",
|
||||
path: "/models/champdeble/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champdeble-animated",
|
||||
name: "Champ de blé animé",
|
||||
path: "/models/champdeble-animated/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champdesoja",
|
||||
name: "Champ de soja",
|
||||
path: "/models/champdesoja/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champdesoja-animated",
|
||||
name: "Champ de soja animé",
|
||||
path: "/models/champdesoja-animated/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champsdetournesol",
|
||||
name: "Champ de tournesol",
|
||||
path: "/models/champsdetournesol/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champsdetournesol-animated",
|
||||
name: "Champ de tournesol animé",
|
||||
path: "/models/champsdetournesol-animated/model.gltf",
|
||||
},
|
||||
{ id: "chemins", name: "Chemins", path: "/models/chemins/model.gltf" },
|
||||
{ id: "cloud", name: "Nuage", path: "/models/cloud/model.glb" },
|
||||
{
|
||||
id: "createurdepluie",
|
||||
name: "Créateur de pluie",
|
||||
path: "/models/createurdepluie/model.gltf",
|
||||
},
|
||||
{ id: "ebike", name: "E-bike", path: "/models/ebike/model.gltf" },
|
||||
{ id: "ecole", name: "École", path: "/models/ecole/model.gltf" },
|
||||
{ id: "elec", name: "Électricité", path: "/models/elec/model.gltf" },
|
||||
{
|
||||
id: "electricienne",
|
||||
name: "Électricienne",
|
||||
path: "/models/electricienne/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "entreetuyaux",
|
||||
name: "Entrée tuyaux",
|
||||
path: "/models/entreetuyaux/model.gltf",
|
||||
},
|
||||
{ id: "eolienne", name: "Éolienne", path: "/models/eolienne/model.gltf" },
|
||||
{
|
||||
id: "fermeverticale",
|
||||
name: "Ferme verticale",
|
||||
path: "/models/fermeverticale/model.gltf",
|
||||
},
|
||||
{ id: "fermier", name: "Fermier", path: "/models/fermier/model.gltf" },
|
||||
{
|
||||
id: "fermier-animated",
|
||||
name: "Fermier animé",
|
||||
path: "/models/fermier-animated/model.gltf",
|
||||
},
|
||||
{ id: "galet", name: "Galet", path: "/models/galet/model.gltf" },
|
||||
{ id: "gant_l", name: "Gant gauche", path: "/models/gant_l/model.gltf" },
|
||||
{
|
||||
id: "gant_l_pad",
|
||||
name: "Pad gant gauche",
|
||||
path: "/models/gant_l_pad/model.gltf",
|
||||
},
|
||||
{ id: "gant_r", name: "Gant droit", path: "/models/gant_r/model.gltf" },
|
||||
{
|
||||
id: "gant_r_pad",
|
||||
name: "Pad gant droit",
|
||||
path: "/models/gant_r_pad/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "generateur",
|
||||
name: "Générateur",
|
||||
path: "/models/generateur/model.gltf",
|
||||
},
|
||||
{ id: "gerant", name: "Gérant", path: "/models/gerant/model.gltf" },
|
||||
{
|
||||
id: "gerant-animated",
|
||||
name: "Gérant animé",
|
||||
path: "/models/gerant-animated/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "habitant1",
|
||||
name: "Habitant 1",
|
||||
path: "/models/habitant1/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "habitant1-animated",
|
||||
name: "Habitant 1 animé",
|
||||
path: "/models/habitant1-animated/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "habitant2",
|
||||
name: "Habitant 2",
|
||||
path: "/models/habitant2/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "habitant2-animated",
|
||||
name: "Habitant 2 animé",
|
||||
path: "/models/habitant2-animated/model.gltf",
|
||||
},
|
||||
{ id: "immeuble1", name: "Immeuble", path: "/models/immeuble1/model.gltf" },
|
||||
{ id: "lafabrik", name: "La Fabrik", path: "/models/lafabrik/model.gltf" },
|
||||
{ id: "maison1", name: "Maison", path: "/models/maison1/model.gltf" },
|
||||
{
|
||||
id: "packderelance",
|
||||
name: "Pack de relance",
|
||||
path: "/models/packderelance/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "panneauaffichage",
|
||||
name: "Panneau d'affichage",
|
||||
path: "/models/panneauaffichage/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "panneauclassique",
|
||||
name: "Panneau classique",
|
||||
path: "/models/panneauclassique/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "panneaufleche",
|
||||
name: "Panneau flèche",
|
||||
path: "/models/panneaufleche/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "panneausolaire",
|
||||
name: "Panneau solaire",
|
||||
path: "/models/panneausolaire/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "parcebike",
|
||||
name: "Parc e-bike",
|
||||
path: "/models/parcebike/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "persoprincipal",
|
||||
name: "Personnage principal",
|
||||
path: "/models/persoprincipal/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "persoprincipal-animated",
|
||||
name: "Personnage principal animé",
|
||||
path: "/models/persoprincipal-animated/model.gltf",
|
||||
},
|
||||
{ id: "potager", name: "Potager", path: "/models/potager/potager.gltf" },
|
||||
{ id: "puce", name: "Puce", path: "/models/puce/model.gltf" },
|
||||
{ id: "pylone", name: "Pylône", path: "/models/pylone/model.gltf" },
|
||||
{
|
||||
id: "refroidisseur",
|
||||
name: "Refroidisseur",
|
||||
path: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
{ id: "sapin", name: "Sapin", path: "/models/sapin/model.gltf" },
|
||||
{
|
||||
id: "sapin-animated",
|
||||
name: "Sapin animé",
|
||||
path: "/models/sapin-animated/model.gltf",
|
||||
},
|
||||
{ id: "talkie", name: "Talkie", path: "/models/talkie/model.gltf" },
|
||||
{ id: "terrain", name: "Terrain", path: "/models/terrain/model.gltf" },
|
||||
{
|
||||
id: "tuyauxlac",
|
||||
name: "Tuyaux lac",
|
||||
path: "/models/tuyauxlac/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "tuyauxpuzzle",
|
||||
name: "Tuyaux puzzle",
|
||||
path: "/models/tuyauxpuzzle/model.gltf",
|
||||
},
|
||||
{ id: "vase", name: "Vase", path: "/models/vase/model.gltf" },
|
||||
];
|
||||
@@ -85,6 +85,51 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
pylone: {
|
||||
id: "pylone",
|
||||
label: "Power pylon",
|
||||
description:
|
||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
stageUiPath: "/assets/UI/centrale.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
reassemblySeconds: 1.8,
|
||||
requiredReplacementPartId: "pylone-grid-relay-replacement",
|
||||
scanPartSeconds: 1.4,
|
||||
brokenParts: [
|
||||
{
|
||||
id: "pylone-grid-relay",
|
||||
label: "Grid relay",
|
||||
nodeName: "lampe",
|
||||
placeholderName: "placeholder_1",
|
||||
},
|
||||
{
|
||||
id: "pylone-damaged-panel",
|
||||
label: "Damaged solar panel",
|
||||
nodeName: "panneau2",
|
||||
placeholderName: "placeholder_2",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "pylone-grid-relay-replacement",
|
||||
label: "Replacement grid relay",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "pylone-stone-decoy",
|
||||
label: "Stone counterweight",
|
||||
modelPath: "/models/galet/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "pylone-cooling-decoy",
|
||||
label: "Cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
ferme: {
|
||||
id: "ferme",
|
||||
label: "Vertical farm",
|
||||
|
||||
@@ -11,5 +11,5 @@ export const PLAYER_MAX_DELTA = 0.05;
|
||||
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
|
||||
export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
||||
|
||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [6.56,5,71.55];
|
||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
|
||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||
|
||||
+12
-5
@@ -1,12 +1,19 @@
|
||||
import type { Zone, GameStep } from "@/types/game";
|
||||
import type { Zone } from "@/types/game";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const ZONES: Zone[] = [
|
||||
{
|
||||
id: "fabrikExit",
|
||||
position: [18.43,0,75.3] as Vector3Tuple,
|
||||
radius: 4,
|
||||
height: 10,
|
||||
targetStep: "bike" as GameStep,
|
||||
position: [-5, 25, -15] as Vector3Tuple,
|
||||
radius: 10,
|
||||
height: 20,
|
||||
targetStep: "mission2",
|
||||
},
|
||||
{
|
||||
id: "searchingZone",
|
||||
position: [-5, 25, -30] as Vector3Tuple,
|
||||
radius: 10,
|
||||
height: 20,
|
||||
targetStep: "searching",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||
|
||||
export function useRepairMovementLocked(): boolean {
|
||||
return false;
|
||||
|
||||
return useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "bike":
|
||||
return isRepairMovementLocked(state.bike.currentStep);
|
||||
case "pylone":
|
||||
return state.pylone.currentStep === "manipulation";
|
||||
return isRepairMovementLocked(state.pylone.currentStep);
|
||||
case "ferme":
|
||||
return isRepairMovementLocked(state.ferme.currentStep);
|
||||
case "intro":
|
||||
|
||||
+271
@@ -30,6 +30,277 @@ canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Model gallery */
|
||||
.gallery-page {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #050505;
|
||||
color: #f4efe7;
|
||||
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
position: absolute;
|
||||
top: clamp(18px, 3vw, 34px);
|
||||
right: clamp(18px, 3vw, 38px);
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
color: #f4efe7;
|
||||
font-size: clamp(18px, 2vw, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.32em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gallery-canvas-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gallery-viewer-error {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
min-height: 360px;
|
||||
padding: 24px;
|
||||
color: #fecaca;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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: 2px solid #d8d0c4;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
box-shadow: none;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.gallery-bottom-bar button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #f4efe7;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.gallery-bottom-bar button:hover,
|
||||
.gallery-bottom-bar button:focus-visible {
|
||||
background: #f4efe7;
|
||||
color: #050505;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gallery-model-info {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 54px;
|
||||
padding: 0 20px;
|
||||
border-right: 2px solid #d8d0c4;
|
||||
border-left: 2px solid #d8d0c4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gallery-model-info span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: #f4efe7;
|
||||
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: #a9a196;
|
||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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: 2px solid #d8d0c4;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
color: #d8d0c4;
|
||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gallery-texture-status--ok {
|
||||
color: #d8d0c4;
|
||||
}
|
||||
|
||||
.gallery-texture-status--warning {
|
||||
color: #f4efe7;
|
||||
}
|
||||
|
||||
.gallery-texture-status--loading {
|
||||
color: #a9a196;
|
||||
}
|
||||
|
||||
.gallery-light-panel {
|
||||
position: absolute;
|
||||
top: 108px;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
transform: translateX(260px);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.gallery-light-panel.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.gallery-light-panel-toggle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 2px solid #d8d0c4;
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
color: #f4efe7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gallery-light-panel-toggle:hover,
|
||||
.gallery-light-panel-toggle:focus-visible {
|
||||
background: #f4efe7;
|
||||
color: #050505;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content {
|
||||
width: 236px;
|
||||
padding: 16px;
|
||||
border: 2px solid #d8d0c4;
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content header span {
|
||||
color: #f4efe7;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content header button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #a9a196;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content header button:hover,
|
||||
.gallery-light-panel-content header button:focus-visible {
|
||||
color: #f4efe7;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gallery-light-control {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.gallery-light-control span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #d8d0c4;
|
||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gallery-light-control strong {
|
||||
color: #f4efe7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gallery-light-control input {
|
||||
width: 100%;
|
||||
accent-color: #f4efe7;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.gallery-title {
|
||||
right: 50%;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.gallery-bottom-bar {
|
||||
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%);
|
||||
}
|
||||
|
||||
.gallery-light-panel {
|
||||
top: 78px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Docs layout */
|
||||
.docs-page {
|
||||
display: grid;
|
||||
|
||||
@@ -7,10 +7,9 @@ import {
|
||||
type MissionStep,
|
||||
type RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { type PyloneStep } from "@/types/gameplay/pylone";
|
||||
|
||||
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
||||
export type { MissionStep, RepairMissionId, PyloneStep };
|
||||
export type { MissionStep, RepairMissionId };
|
||||
|
||||
interface IntroState {
|
||||
currentStep: GameStep;
|
||||
@@ -29,21 +28,17 @@ interface MissionFlowState {
|
||||
canMove: boolean;
|
||||
dialogMessage: string | null;
|
||||
playerName: string;
|
||||
currentVideo: string | null;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
mainState: MainGameState;
|
||||
isCinematicPlaying: boolean;
|
||||
sceneReady: boolean;
|
||||
missionFlow: MissionFlowState;
|
||||
intro: IntroState;
|
||||
bike: MissionState & {
|
||||
isRepaired: boolean;
|
||||
};
|
||||
pylone: {
|
||||
currentStep: PyloneStep;
|
||||
dialogueAudio: string | null;
|
||||
pylone: MissionState & {
|
||||
isPowered: boolean;
|
||||
};
|
||||
ferme: MissionState & {
|
||||
@@ -58,7 +53,6 @@ interface GameState {
|
||||
interface GameActions {
|
||||
setMainState: (mainState: MainGameState) => void;
|
||||
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
|
||||
setSceneReady: (sceneReady: boolean) => void;
|
||||
hideDialog: () => void;
|
||||
setActivityCity: (activityCity: boolean) => void;
|
||||
setCanMove: (canMove: boolean) => void;
|
||||
@@ -72,6 +66,7 @@ interface GameActions {
|
||||
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
|
||||
completeIntro: () => void;
|
||||
completeBike: () => void;
|
||||
completePylone: () => void;
|
||||
completeFerme: () => void;
|
||||
completeMission: (mission: RepairMissionId) => void;
|
||||
startOutro: () => void;
|
||||
@@ -79,8 +74,6 @@ interface GameActions {
|
||||
rewindGameState: () => void;
|
||||
resetGame: () => void;
|
||||
showDialog: (dialogMessage: string) => void;
|
||||
playVideo: (videoSrc: string) => void;
|
||||
clearVideo: () => void;
|
||||
}
|
||||
|
||||
type GameStore = GameState & GameActions;
|
||||
@@ -111,7 +104,22 @@ function completeBikeState(state: GameState): GameStateUpdate {
|
||||
},
|
||||
pylone: {
|
||||
...state.pylone,
|
||||
currentStep: "locked",
|
||||
currentStep: "waiting",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completePyloneState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "ferme",
|
||||
pylone: {
|
||||
...state.pylone,
|
||||
currentStep: "done",
|
||||
isPowered: true,
|
||||
},
|
||||
ferme: {
|
||||
...state.ferme,
|
||||
currentStep: "waiting",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -151,48 +159,13 @@ function completeMissionState(
|
||||
switch (mission) {
|
||||
case "bike":
|
||||
return completeBikeState(state);
|
||||
case "pylone":
|
||||
return completePyloneState(state);
|
||||
case "ferme":
|
||||
return completeFermeState(state);
|
||||
}
|
||||
}
|
||||
|
||||
function getNextPyloneStep(step: PyloneStep): PyloneStep {
|
||||
switch (step) {
|
||||
case "locked":
|
||||
return "alert";
|
||||
case "alert":
|
||||
return "searching";
|
||||
case "searching":
|
||||
return "helped";
|
||||
case "helped":
|
||||
return "manipulation";
|
||||
case "manipulation":
|
||||
return "manipulation";
|
||||
}
|
||||
}
|
||||
|
||||
function advancePyloneStep(state: GameState): GameStateUpdate {
|
||||
if (state.pylone.currentStep === "locked") {
|
||||
return {
|
||||
pylone: { ...state.pylone, currentStep: "alert" },
|
||||
};
|
||||
}
|
||||
|
||||
const nextStep = getNextPyloneStep(state.pylone.currentStep);
|
||||
if (
|
||||
nextStep === "manipulation" &&
|
||||
state.pylone.currentStep === "manipulation"
|
||||
) {
|
||||
return {
|
||||
mainState: "outro",
|
||||
pylone: { ...state.pylone, currentStep: "manipulation" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
pylone: { ...state.pylone, currentStep: nextStep },
|
||||
};
|
||||
}
|
||||
|
||||
function advanceRepairMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
@@ -230,13 +203,11 @@ function createInitialGameState(): GameState {
|
||||
return {
|
||||
mainState: "intro",
|
||||
isCinematicPlaying: false,
|
||||
sceneReady: false,
|
||||
missionFlow: {
|
||||
activityCity: true,
|
||||
canMove: false,
|
||||
dialogMessage: null,
|
||||
playerName: "",
|
||||
currentVideo: null,
|
||||
},
|
||||
intro: {
|
||||
currentStep: "intro",
|
||||
@@ -270,7 +241,6 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
...createInitialGameState(),
|
||||
setMainState: (mainState) => set({ mainState }),
|
||||
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
|
||||
setSceneReady: (sceneReady) => set({ sceneReady }),
|
||||
hideDialog: () =>
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, dialogMessage: null },
|
||||
@@ -303,6 +273,7 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
set((state) => setMissionStepState(state, mission, step)),
|
||||
completeIntro: () => set(completeIntroState),
|
||||
completeBike: () => set((state) => completeMissionState(state, "bike")),
|
||||
completePylone: () => set((state) => completeMissionState(state, "pylone")),
|
||||
completeFerme: () => set((state) => completeMissionState(state, "ferme")),
|
||||
completeMission: (mission) =>
|
||||
set((state) => completeMissionState(state, mission)),
|
||||
@@ -310,19 +281,9 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
advanceGameState: () =>
|
||||
set((state) => {
|
||||
if (state.mainState === "intro") {
|
||||
if (state.intro.currentStep === "bike") {
|
||||
return {
|
||||
mainState: "bike",
|
||||
intro: { ...state.intro, hasCompleted: true },
|
||||
};
|
||||
}
|
||||
return completeIntroState(state);
|
||||
}
|
||||
|
||||
if (state.mainState === "pylone") {
|
||||
return advancePyloneStep(state);
|
||||
}
|
||||
|
||||
if (isRepairMissionId(state.mainState)) {
|
||||
return advanceRepairMissionState(state, state.mainState);
|
||||
}
|
||||
@@ -346,12 +307,4 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, dialogMessage },
|
||||
})),
|
||||
playVideo: (videoSrc) =>
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, currentVideo: videoSrc },
|
||||
})),
|
||||
clearVideo: () =>
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, currentVideo: null },
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import gallery from "../../../../docs/user/gallery.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsGalleryPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={gallery}
|
||||
frContent={gallery}
|
||||
meta="16"
|
||||
title="Model Gallery"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
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,
|
||||
SlidersHorizontal,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import * as THREE from "three";
|
||||
import { SkyModel } from "@/components/three/world/SkyModel";
|
||||
import { galleryModels, type GalleryModel } from "@/data/galleryModels";
|
||||
import {
|
||||
AMBIENT_LIGHT_COLOR,
|
||||
LIGHTING_DEFAULTS,
|
||||
SUN_LIGHT_COLOR,
|
||||
} from "@/data/world/lightingConfig";
|
||||
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";
|
||||
|
||||
interface GalleryModelProps {
|
||||
model: GalleryModel;
|
||||
}
|
||||
|
||||
interface GallerySceneProps extends GalleryModelProps {
|
||||
lighting: GalleryLightingConfig;
|
||||
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
|
||||
}
|
||||
|
||||
interface GalleryModelPreviewProps extends GalleryModelProps {
|
||||
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
|
||||
}
|
||||
|
||||
interface GalleryLightingConfig {
|
||||
ambientIntensity: number;
|
||||
sunIntensity: number;
|
||||
sunX: number;
|
||||
sunY: number;
|
||||
sunZ: number;
|
||||
}
|
||||
|
||||
interface GalleryLightControl {
|
||||
key: keyof GalleryLightingConfig;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}
|
||||
|
||||
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...",
|
||||
};
|
||||
|
||||
const GALLERY_LIGHT_CONTROLS: GalleryLightControl[] = [
|
||||
{ key: "ambientIntensity", label: "Ambiance", min: 0, max: 5, step: 0.1 },
|
||||
{ key: "sunIntensity", label: "Soleil", min: 0, max: 8, step: 0.1 },
|
||||
{ key: "sunX", label: "Soleil X", min: -100, max: 100, step: 1 },
|
||||
{ key: "sunY", label: "Soleil Y", min: -100, max: 150, step: 1 },
|
||||
{ key: "sunZ", label: "Soleil Z", min: -100, max: 100, step: 1 },
|
||||
];
|
||||
|
||||
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,
|
||||
}: GalleryModelPreviewProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { animations, scene } = useGLTF(model.path);
|
||||
const modelScene = useMemo(() => createGalleryModelScene(scene), [scene]);
|
||||
const { actions } = useAnimations(animations, groupRef);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeGalleryModelMaterials(modelScene);
|
||||
};
|
||||
}, [modelScene]);
|
||||
|
||||
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 createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D {
|
||||
const modelScene = scene.clone(true);
|
||||
|
||||
modelScene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
object.material = Array.isArray(object.material)
|
||||
? object.material.map(createGalleryMaterial)
|
||||
: createGalleryMaterial(object.material);
|
||||
});
|
||||
|
||||
return modelScene;
|
||||
}
|
||||
|
||||
function createGalleryMaterial(material: THREE.Material): THREE.Material {
|
||||
const galleryMaterial = material.clone();
|
||||
const materialWithNormalMap = galleryMaterial as THREE.Material & {
|
||||
normalMap?: THREE.Texture | null;
|
||||
};
|
||||
|
||||
galleryMaterial.side = THREE.DoubleSide;
|
||||
|
||||
if (materialWithNormalMap.normalMap) {
|
||||
materialWithNormalMap.normalMap = null;
|
||||
galleryMaterial.needsUpdate = true;
|
||||
}
|
||||
|
||||
return galleryMaterial;
|
||||
}
|
||||
|
||||
function disposeGalleryModelMaterials(modelScene: THREE.Object3D): void {
|
||||
modelScene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
if (Array.isArray(object.material)) {
|
||||
for (const material of object.material) {
|
||||
material.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
object.material.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
function GalleryScene({
|
||||
lighting,
|
||||
model,
|
||||
onTextureDiagnosticReady,
|
||||
}: GallerySceneProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SkyModel
|
||||
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
|
||||
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
|
||||
materialSide={THREE.DoubleSide}
|
||||
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
||||
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
||||
unlit
|
||||
/>
|
||||
<GalleryLighting lighting={lighting} />
|
||||
<Bounds fit clip observe margin={1.35}>
|
||||
<Center>
|
||||
<GalleryModelPreview
|
||||
model={model}
|
||||
onTextureDiagnosticReady={onTextureDiagnosticReady}
|
||||
/>
|
||||
</Center>
|
||||
</Bounds>
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableDamping
|
||||
autoRotate
|
||||
autoRotateSpeed={0.5}
|
||||
minPolarAngle={0}
|
||||
maxPolarAngle={Math.PI}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryLighting({
|
||||
lighting,
|
||||
}: {
|
||||
lighting: GalleryLightingConfig;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<ambientLight
|
||||
intensity={lighting.ambientIntensity}
|
||||
color={AMBIENT_LIGHT_COLOR}
|
||||
/>
|
||||
<directionalLight
|
||||
position={[lighting.sunX, lighting.sunY, lighting.sunZ]}
|
||||
intensity={lighting.sunIntensity}
|
||||
color={SUN_LIGHT_COLOR}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 GalleryLightingPanel({
|
||||
lighting,
|
||||
onChange,
|
||||
onReset,
|
||||
onToggle,
|
||||
open,
|
||||
}: {
|
||||
lighting: GalleryLightingConfig;
|
||||
onChange: (key: keyof GalleryLightingConfig, value: number) => void;
|
||||
onReset: () => void;
|
||||
onToggle: () => void;
|
||||
open: boolean;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<aside className={`gallery-light-panel ${open ? "is-open" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="gallery-light-panel-toggle"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-label={
|
||||
open ? "Fermer les réglages lumière" : "Ouvrir les réglages lumière"
|
||||
}
|
||||
>
|
||||
<SlidersHorizontal aria-hidden="true" size={18} strokeWidth={1.8} />
|
||||
</button>
|
||||
<div className="gallery-light-panel-content" aria-hidden={!open}>
|
||||
<header>
|
||||
<span>LIGHTS</span>
|
||||
<button type="button" onClick={onReset}>
|
||||
Reset
|
||||
</button>
|
||||
</header>
|
||||
{GALLERY_LIGHT_CONTROLS.map((control) => (
|
||||
<label key={control.key} className="gallery-light-control">
|
||||
<span>
|
||||
{control.label}
|
||||
<strong>{lighting[control.key].toFixed(1)}</strong>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
value={lighting[control.key]}
|
||||
onChange={(event) =>
|
||||
onChange(control.key, Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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 [lightPanelOpen, setLightPanelOpen] = useState(false);
|
||||
const [lighting, setLighting] = useState<GalleryLightingConfig>({
|
||||
...LIGHTING_DEFAULTS,
|
||||
});
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
const handleLightChange = (
|
||||
key: keyof GalleryLightingConfig,
|
||||
value: number,
|
||||
): void => {
|
||||
setLighting((currentLighting) => ({
|
||||
...currentLighting,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetLighting = (): void => {
|
||||
setLighting({ ...LIGHTING_DEFAULTS });
|
||||
};
|
||||
|
||||
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
|
||||
lighting={lighting}
|
||||
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} />
|
||||
<GalleryLightingPanel
|
||||
lighting={lighting}
|
||||
onChange={handleLightChange}
|
||||
onReset={resetLighting}
|
||||
onToggle={() => setLightPanelOpen((open) => !open)}
|
||||
open={lightPanelOpen}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+3
-7
@@ -4,7 +4,7 @@ import * as THREE from "three";
|
||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||
import { DialogMessage } from "@/components/ui/DialogMessage";
|
||||
import { GameUI } from "@/components/ui/GameUI";
|
||||
import { BienvenueDisplay } from "@/components/ui/IntroUI";
|
||||
import { BienvenueDisplay, IntroUI } from "@/components/ui/IntroUI";
|
||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||
@@ -19,7 +19,6 @@ export function HomePage(): React.JSX.Element {
|
||||
(state) => state.missionFlow.dialogMessage,
|
||||
);
|
||||
const hideDialog = useGameStore((state) => state.hideDialog);
|
||||
const setSceneReady = useGameStore((state) => state.setSceneReady);
|
||||
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||
INITIAL_SCENE_LOADING_STATE,
|
||||
);
|
||||
@@ -43,17 +42,13 @@ export function HomePage(): React.JSX.Element {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
if (nextState.status === "ready" && currentState.status !== "ready") {
|
||||
setSceneReady(true);
|
||||
}
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
progress: Math.max(currentState.progress, nextState.progress),
|
||||
};
|
||||
});
|
||||
},
|
||||
[setSceneReady],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -68,6 +63,7 @@ export function HomePage(): React.JSX.Element {
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
<GameUI />
|
||||
<IntroUI />
|
||||
<BienvenueDisplay />
|
||||
{dialogMessage ? (
|
||||
<DialogMessage
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@tanstack/react-router";
|
||||
import { HomePage } from "@/pages/page";
|
||||
import { EditorPage } from "@/pages/editor/page";
|
||||
import { GalleryPage } from "@/pages/gallery/page";
|
||||
import {
|
||||
DocsAnimationRoute,
|
||||
DocsAudioRoute,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
DocsCodeReviewRoute,
|
||||
DocsEditorRoute,
|
||||
DocsFeaturesRoute,
|
||||
DocsGalleryRoute,
|
||||
DocsHandTrackingRoute,
|
||||
DocsInteractionRoute,
|
||||
DocsLayoutRoute,
|
||||
@@ -43,6 +45,12 @@ const editorRoute = createRoute({
|
||||
component: EditorPage,
|
||||
});
|
||||
|
||||
const galleryRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/gallery",
|
||||
component: GalleryPage,
|
||||
});
|
||||
|
||||
const docsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/docs",
|
||||
@@ -66,6 +74,7 @@ const docsChildRoutes = [
|
||||
{ path: "main-feature", component: DocsMainFeatureRoute },
|
||||
{ path: "editor", component: DocsEditorRoute },
|
||||
{ path: "animation", component: DocsAnimationRoute },
|
||||
{ path: "gallery", component: DocsGalleryRoute },
|
||||
{ path: "code-review", component: DocsCodeReviewRoute },
|
||||
].map(({ path, component }) =>
|
||||
createRoute({
|
||||
@@ -78,6 +87,7 @@ const docsChildRoutes = [
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
editorRoute,
|
||||
galleryRoute,
|
||||
docsRoute.addChildren(docsChildRoutes),
|
||||
]);
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ const LazyDocsAnimationPage = lazyNamed(
|
||||
() => import("@/pages/docs/animation/page"),
|
||||
"DocsAnimationPage",
|
||||
);
|
||||
const LazyDocsGalleryPage = lazyNamed(
|
||||
() => import("@/pages/docs/gallery/page"),
|
||||
"DocsGalleryPage",
|
||||
);
|
||||
const LazyDocsCodeReviewPage = lazyNamed(
|
||||
() => import("@/pages/docs/code-review/page"),
|
||||
"DocsCodeReviewPage",
|
||||
@@ -119,6 +123,7 @@ export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);
|
||||
export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage);
|
||||
export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage);
|
||||
export const DocsAnimationRoute = createDocsRoute(LazyDocsAnimationPage);
|
||||
export const DocsGalleryRoute = createDocsRoute(LazyDocsGalleryPage);
|
||||
export const DocsCodeReviewRoute = createDocsRoute(LazyDocsCodeReviewPage);
|
||||
export const DocsMissionFlowRoute = createDocsRoute(LazyDocsMissionFlowPage);
|
||||
export const DocsThreeDebuggingRoute = createDocsRoute(
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ShaderMaterial } from "three";
|
||||
|
||||
export const createNetShader = (): ShaderMaterial => {
|
||||
return new ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uGridScale: { value: 11.3 },
|
||||
uPincushionStrength: { value: 0.4 },
|
||||
uBloomIntensity: { value: 0.8 },
|
||||
uGridThickness: { value: 0.05 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform float uTime;
|
||||
uniform float uGridScale;
|
||||
uniform float uPincushionStrength;
|
||||
uniform float uBloomIntensity;
|
||||
uniform float uGridThickness;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
vec2 applyPincushion(vec2 uv, float strength) {
|
||||
vec2 center = uv - 0.5;
|
||||
float dist = length(center);
|
||||
float distortion = 1.0 + dist * dist * strength;
|
||||
return center * distortion + 0.5;
|
||||
}
|
||||
|
||||
float grid(vec2 uv, float scale, float thickness) {
|
||||
vec2 gridUV = fract(uv * scale);
|
||||
|
||||
float edgeX = step(gridUV.x, thickness) + step(1.0 - thickness, gridUV.x);
|
||||
float edgeY = step(gridUV.y, thickness) + step(1.0 - thickness, gridUV.y);
|
||||
|
||||
float line = min(edgeX + edgeY, 1.0);
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = applyPincushion(vUv, uPincushionStrength);
|
||||
|
||||
float gridPattern = grid(uv, uGridScale, uGridThickness);
|
||||
|
||||
vec3 gridColor = vec3(1.0, 0.8, 0.2);
|
||||
float bloom = gridPattern * uBloomIntensity;
|
||||
vec3 col = gridColor * (0.3 + bloom);
|
||||
|
||||
gl_FragColor = vec4(col, gridPattern * 0.95);
|
||||
}
|
||||
`,
|
||||
});
|
||||
};
|
||||
@@ -14,7 +14,6 @@ export interface CinematicDialogueCue {
|
||||
export interface CinematicDefinition {
|
||||
id: string;
|
||||
timecode?: number;
|
||||
trigger?: string;
|
||||
cameraKeyframes: CinematicCameraKeyframe[];
|
||||
dialogueCues?: CinematicDialogueCue[];
|
||||
}
|
||||
|
||||
+20
-4
@@ -1,12 +1,28 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export type GameStep = "intro" | "sequence_video" | "start-move" | "bike";
|
||||
export type GameStep =
|
||||
| "intro"
|
||||
| "start-intro"
|
||||
| "naming"
|
||||
| "bienvenue"
|
||||
| "star-move"
|
||||
| "mission2"
|
||||
| "searching"
|
||||
| "helped"
|
||||
| "manipulation"
|
||||
| "outOfFabrik";
|
||||
|
||||
export const GAME_STEPS: readonly GameStep[] = [
|
||||
"intro",
|
||||
"sequence_video",
|
||||
"start-move",
|
||||
"bike",
|
||||
"start-intro",
|
||||
"naming",
|
||||
"bienvenue",
|
||||
"star-move",
|
||||
"mission2",
|
||||
"searching",
|
||||
"helped",
|
||||
"manipulation",
|
||||
"outOfFabrik",
|
||||
] as const;
|
||||
|
||||
export interface Zone {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export type PyloneStep =
|
||||
| "locked"
|
||||
| "alert"
|
||||
| "searching"
|
||||
| "helped"
|
||||
| "manipulation";
|
||||
|
||||
export const PYLONE_STEPS: readonly PyloneStep[] = [
|
||||
"locked",
|
||||
"alert",
|
||||
"searching",
|
||||
"helped",
|
||||
"manipulation",
|
||||
] as const;
|
||||
|
||||
export function isPyloneStep(value: string): value is PyloneStep {
|
||||
return PYLONE_STEPS.includes(value as PyloneStep);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type RepairMissionId = "bike" | "ferme";
|
||||
export type RepairMissionId = "bike" | "pylone" | "ferme";
|
||||
|
||||
export type MissionStep =
|
||||
| "locked"
|
||||
@@ -10,7 +10,7 @@ export type MissionStep =
|
||||
| "reassembling"
|
||||
| "done";
|
||||
|
||||
export const REPAIR_MISSION_IDS = ["bike", "ferme"] as const;
|
||||
export const REPAIR_MISSION_IDS = ["bike", "pylone", "ferme"] as const;
|
||||
|
||||
export const MISSION_STEPS = [
|
||||
"locked",
|
||||
|
||||
@@ -20,7 +20,6 @@ export function GameCinematics(): null {
|
||||
const [dialogueManifest, setDialogueManifest] =
|
||||
useState<DialogueManifest | null>(null);
|
||||
const playedCinematicsRef = useRef(new Set<string>());
|
||||
const triggeredCinematicsRef = useRef(new Set<string>());
|
||||
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
@@ -65,25 +64,7 @@ export function GameCinematics(): null {
|
||||
|
||||
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
|
||||
|
||||
const currentStep = useGameStore.getState().intro.currentStep;
|
||||
|
||||
manifest.cinematics.forEach((cinematic) => {
|
||||
if (cinematic.trigger) {
|
||||
if (triggeredCinematicsRef.current.has(cinematic.id)) return;
|
||||
if (currentStep !== cinematic.trigger) return;
|
||||
if (cinematic.dialogueCues && !dialogueManifest) return;
|
||||
|
||||
triggeredCinematicsRef.current.add(cinematic.id);
|
||||
playCinematic(camera, cinematic, timelineRef, {
|
||||
dialogueManifest,
|
||||
activeAudiosRef,
|
||||
onComplete: () => {
|
||||
triggeredCinematicsRef.current.delete(cinematic.id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (cinematic.timecode === undefined) return;
|
||||
if (cinematic.timecode > elapsedTime) return;
|
||||
if (cinematic.dialogueCues && !dialogueManifest) return;
|
||||
@@ -114,7 +95,6 @@ function playCinematic(
|
||||
dialogueOptions: {
|
||||
dialogueManifest: DialogueManifest | null;
|
||||
activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>;
|
||||
onComplete?: () => void;
|
||||
},
|
||||
): void {
|
||||
const firstKeyframe = cinematic.cameraKeyframes[0];
|
||||
@@ -133,7 +113,6 @@ function playCinematic(
|
||||
onComplete: () => {
|
||||
timelineRef.current = null;
|
||||
useGameStore.getState().setCinematicPlaying(false);
|
||||
dialogueOptions.onComplete?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ const GAME_REPAIR_ZONES = [
|
||||
mission: "bike",
|
||||
position: [8, 0, -6],
|
||||
},
|
||||
{
|
||||
mission: "pylone",
|
||||
position: [64, 0, -66],
|
||||
},
|
||||
{
|
||||
mission: "ferme",
|
||||
position: [-24, 0, 42],
|
||||
|
||||
+1
-7
@@ -28,8 +28,6 @@ import { GameMap } from "@/world/GameMap";
|
||||
import { GameStageContent } from "@/world/GameStageContent";
|
||||
import { Player } from "@/world/player/Player";
|
||||
import { TestMap } from "@/world/debug/TestMap";
|
||||
import { NetTest } from "@/components/three/NetTest";
|
||||
import { VideoPlayer } from "@/components/ui/VideoPlayer";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
|
||||
interface WorldProps {
|
||||
@@ -63,7 +61,6 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Environment />
|
||||
<VideoPlayer />
|
||||
<Lighting />
|
||||
<DebugHelpers />
|
||||
{showHandTrackingGloves ? (
|
||||
@@ -101,10 +98,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TestMap onOctreeReady={handleOctreeReady} />
|
||||
<NetTest />
|
||||
</>
|
||||
<TestMap onOctreeReady={handleOctreeReady} />
|
||||
)}
|
||||
|
||||
{sceneMode !== "game" && spawnPlayer ? (
|
||||
|
||||
Reference in New Issue
Block a user