Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7b4a07e41 | |||
| 89044a18ec | |||
| 95ca1bbfde | |||
| 093ffd726d | |||
| 4728690a11 | |||
| 343a122c06 | |||
| d5675fe82c | |||
| fcdbf7270c | |||
| 0b3d49e8d1 | |||
| 9bbed06ddc | |||
| ba50224e6e | |||
| 1a91b1d7ae | |||
| d9cf87d2d6 | |||
| d654565f87 | |||
| fb466a63cb | |||
| a75c3fd896 | |||
| 603e521714 | |||
| 49ef8f58b4 | |||
| 947025cbf5 | |||
| 0a322acf88 | |||
| a397febd52 | |||
| c15cad2ab0 | |||
| 011e7815a2 | |||
| 970253801a | |||
| 246da0019a | |||
| 09a9471814 | |||
| 6e9318457a | |||
| 54a353de03 | |||
| 8b619bfc28 | |||
| 4faa226326 | |||
| dd66966507 | |||
| 5893afe42a | |||
| 1ead7ab3a7 | |||
| 047c58678b | |||
| ed9051b0dc | |||
| 08be6bee48 | |||
| ce0eb90321 | |||
| 96d7ec7fc0 | |||
| 9ab4b4a002 | |||
| d13dd0fda0 | |||
| fbedb90bca | |||
| cff7744ad9 |
+13
-49
@@ -15,12 +15,9 @@ export class SomeManager {
|
|||||||
return SomeManager._instance;
|
return SomeManager._instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {}
|
||||||
// init logic
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
// cleanup logic
|
|
||||||
SomeManager._instance = null;
|
SomeManager._instance = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,42 +26,11 @@ export class SomeManager {
|
|||||||
## Managers in this project
|
## Managers in this project
|
||||||
|
|
||||||
| Manager | File | Role |
|
| Manager | File | Role |
|
||||||
| -------------------- | ------------------------------------ | ----------------------------------------------------------------------------- |
|
| -------------------- | ------------------------------------ | -------------------------------------------------------------- |
|
||||||
| `AudioManager` | `src/managers/AudioManager.ts` | Music and SFX playback. |
|
| `AudioManager` | `src/managers/AudioManager.ts` | Music and SFX playback. |
|
||||||
| `InteractionManager` | `src/managers/InteractionManager.ts` | Focus, nearby, trigger, grab, and hand-grab interaction state. |
|
| `InteractionManager` | `src/managers/InteractionManager.ts` | Focus, nearby, trigger, grab, and hand-grab interaction state. |
|
||||||
| `GameManager` | target-state only | Future single source of truth for phase, zone, mission, input lock, dialogue. |
|
|
||||||
| `CinematicManager` | target-state only | Future GSAP timeline orchestrator. |
|
|
||||||
| `ZoneManager` | target-state only | Future zone entry/exit detection and LOD triggers. |
|
|
||||||
|
|
||||||
## Target-State GameManager
|
## Subscribe Pattern
|
||||||
|
|
||||||
`GameManager` does not exist in the current implementation. The following pattern is target-state guidance only and should not be applied until the manager exists in code.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export class GameManager {
|
|
||||||
cinematic!: CinematicManager;
|
|
||||||
audio!: AudioManager;
|
|
||||||
zone!: ZoneManager;
|
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
this.cinematic = CinematicManager.getInstance();
|
|
||||||
this.audio = AudioManager.getInstance();
|
|
||||||
this.zone = ZoneManager.getInstance();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When a `GameManager` exists, components and hooks should access other managers through it:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Correct
|
|
||||||
GameManager.getInstance().cinematic.play("intro");
|
|
||||||
|
|
||||||
// Wrong — never import sub-managers directly in components
|
|
||||||
CinematicManager.getInstance().play("intro");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Target-State Subscribe Pattern
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
private listeners = new Set<() => void>()
|
private listeners = new Set<() => void>()
|
||||||
@@ -79,28 +45,26 @@ private emit(): void {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
In that target-state manager, every `set*()` method calls `this.emit()` to notify subscribers.
|
Managers that expose state to React call `this.emit()` from every `set*()` method that changes subscribed state.
|
||||||
|
|
||||||
## Target-State React Bridge Hook
|
## React Bridge Hook
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// hooks/useGameState.ts
|
// hooks/interaction/useInteraction.ts
|
||||||
export function useGameState() {
|
const manager = InteractionManager.getInstance();
|
||||||
const game = GameManager.getInstance();
|
|
||||||
const [state, setState] = useState(game.getState());
|
|
||||||
|
|
||||||
useEffect(() => {
|
export function useInteraction(): InteractionSnapshot {
|
||||||
return game.subscribe(() => setState({ ...game.getState() }));
|
return useSyncExternalStore(
|
||||||
}, [game]);
|
manager.subscribe.bind(manager),
|
||||||
|
manager.getState.bind(manager),
|
||||||
return state;
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Do not add a `GameManager` unless the feature requires a real shared gameplay state owner.
|
- Do not add a `GameManager` unless the feature requires a real shared gameplay state owner.
|
||||||
- Current managers may be imported directly until the target-state orchestrator exists.
|
- Current managers may be imported directly.
|
||||||
- Keep singleton managers limited to side-effect services or shared interaction state.
|
- Keep singleton managers limited to side-effect services or shared interaction state.
|
||||||
- Always call `destroy()` on cleanup when a manager owns external resources.
|
- Always call `destroy()` on cleanup when a manager owns external resources.
|
||||||
- Never create manager instances with `new` — always use `.getInstance()`.
|
- Never create manager instances with `new` — always use `.getInstance()`.
|
||||||
|
|||||||
@@ -23,3 +23,6 @@
|
|||||||
# Video (cinematics)
|
# Video (cinematics)
|
||||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||||
*.webm filter=lfs diff=lfs merge=lfs -text
|
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
||||||
|
# ML models
|
||||||
|
*.task filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
@@ -69,16 +69,21 @@ jobs:
|
|||||||
- name: 📥 Install
|
- name: 📥 Install
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: 🧹 Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: 🎨 Format check
|
||||||
|
run: npm run format:check
|
||||||
|
|
||||||
- name: 📦 Build
|
- name: 📦 Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: 📏 Check bundle size
|
- name: 📏 Check bundle size
|
||||||
run: |
|
run: |
|
||||||
# Check generated app assets only; public/ model files are runtime assets copied to dist.
|
# Check generated JS/CSS bundles only; public runtime assets are copied to dist/assets too.
|
||||||
SIZE=$(du -k dist/assets | cut -f1)
|
SIZE=$(node -e "const fs=require('fs');const path=require('path');function walk(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap((entry)=>{const file=path.join(dir,entry.name);return entry.isDirectory()?walk(file):file;});}const bytes=walk('dist/assets').filter((file)=>/\.(js|css)$/.test(file)).reduce((sum,file)=>sum+fs.statSync(file).size,0);console.log(Math.ceil(bytes/1024));")
|
||||||
echo "Bundle size: ${SIZE}KB"
|
echo "Bundle size: ${SIZE}KB"
|
||||||
|
|
||||||
# Threshold: 5000KB (configurable)
|
|
||||||
THRESHOLD=5000
|
THRESHOLD=5000
|
||||||
|
|
||||||
if [ "$SIZE" -gt "$THRESHOLD" ]; then
|
if [ "$SIZE" -gt "$THRESHOLD" ]; then
|
||||||
|
|||||||
-187
@@ -1,187 +0,0 @@
|
|||||||
# Game Flow - La Fabrik
|
|
||||||
|
|
||||||
## Étapes du jeu
|
|
||||||
|
|
||||||
```
|
|
||||||
intro → start-intro → naming → bienvenue → star-move → mission2 → searching_problem → preparation → outOfFabrik
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Détail des étapes
|
|
||||||
|
|
||||||
### 1. `intro` (initial)
|
|
||||||
|
|
||||||
- État initial au chargement du jeu
|
|
||||||
- Aucune action, juste une étape de départ
|
|
||||||
- Transition automatique vers `start-intro`
|
|
||||||
|
|
||||||
### 2. `start-intro`
|
|
||||||
|
|
||||||
- **Déclenchement** : Auto-transition depuis `intro` quand la scène est chargée
|
|
||||||
- **Action** : Joue l'audio d'intro (`intro`)
|
|
||||||
- **Attente** : Attend que l'audio se termine
|
|
||||||
- **Transition** : Vers `naming` quand l'audio se termine
|
|
||||||
|
|
||||||
### 3. `naming`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand l'audio d'intro se termine
|
|
||||||
- **Action** : Affiche un input pour demander le prénom du joueur
|
|
||||||
- **Attente** : L'utilisateur entre son prénom et valide
|
|
||||||
- **Transition** : Vers `bienvenue` quand l'utilisateur valide
|
|
||||||
|
|
||||||
### 4. `bienvenue`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand l'utilisateur valide son prénom
|
|
||||||
- **Actions** :
|
|
||||||
- Affiche "Bienvenue {prénom} !" à l'écran
|
|
||||||
- Joue l'audio de bienvenue
|
|
||||||
- **Attente** : Attend que l'audio se termine
|
|
||||||
- **Transition** : Vers `star-move` quand l'audio se termine
|
|
||||||
|
|
||||||
### 5. `star-move`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand l'audio de bienvenue se termine
|
|
||||||
- **Action** : Active le mouvement du joueur (`setCanMove(true)`)
|
|
||||||
- **État** : Le joueur peut maintenant se déplacer librement
|
|
||||||
- **Zone** : La détection de zone devient active (ZoneDetection)
|
|
||||||
|
|
||||||
### 6. `mission2`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand le joueur entre dans la zone `fabrikExit` (position: `[-5, 25, -15]`)
|
|
||||||
- **Actions** :
|
|
||||||
- Stocke `activityCity: false` dans le store Zustand
|
|
||||||
- Joue l'audio `alertCentral`
|
|
||||||
- **État** : Les objets avec hook `useActivityCity()` détectent le changement et jouent leurs animations
|
|
||||||
- **Attente** : Le joueur atteint la zone de trigger pour `searching_problem`
|
|
||||||
|
|
||||||
### 7. `searching_problem`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand le joueur entre dans la zone `searchingProblemZone` (position: `[-5, 25, -30]`)
|
|
||||||
- **Actions** :
|
|
||||||
- Joue l'audio `searchingProblem`
|
|
||||||
- Affiche l'objet "central" (position: `[1, 15, -45]`)
|
|
||||||
- **Attente** : Le joueur interagit avec l'objet "central"
|
|
||||||
|
|
||||||
### 8. `preparation`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand le joueur interagit avec l'objet "central"
|
|
||||||
- **Actions** :
|
|
||||||
- Bloque le mouvement (`setCanMove(false)`)
|
|
||||||
- Cache l'objet "central"
|
|
||||||
|
|
||||||
### 9. `outOfFabrik`
|
|
||||||
|
|
||||||
- **Déclenchement** : (non implémenté pour le moment)
|
|
||||||
- **Action** : Transition vers l'étape finale
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fichiers clés
|
|
||||||
|
|
||||||
| Fichier | Rôle |
|
|
||||||
| --------------------------------------- | --------------------------------------------------------- |
|
|
||||||
| `src/stores/gameStore.ts` | Store Zustand pour l'état global du jeu |
|
|
||||||
| `src/stateManager/GameStepManager.ts` | Synchronise avec le store Zustand |
|
|
||||||
| `src/components/game/GameFlow.tsx` | Gère les transitions automatiques et la lecture audio |
|
|
||||||
| `src/components/ui/IntroUI.tsx` | Affiche l'input pour le prénom et le message de bienvenue |
|
|
||||||
| `src/components/zone/ZoneDetection.tsx` | Détecte quand le joueur entre dans une zone |
|
|
||||||
| `src/components/3d/CentralObject.tsx` | Objet interactif "central" pour la mission 2 |
|
|
||||||
| `src/data/audioConfig.ts` | Chemins des fichiers audio |
|
|
||||||
| `src/data/zones.ts` | Configuration des zones de transition |
|
|
||||||
| `src/hooks/useActivityCity.ts` | Hook pour détecter le changement d'activité de la ville |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration audio
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/data/audioConfig.ts
|
|
||||||
export const AUDIO_PATHS = {
|
|
||||||
intro: "/sounds/fa.mp3",
|
|
||||||
bienvenue: "/sounds/fa.mp3",
|
|
||||||
alertCentral: "/sounds/fa.mp3",
|
|
||||||
searchingProblem: "/sounds/fa.mp3",
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration des zones
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/data/zones.ts
|
|
||||||
export const ZONES: Zone[] = [
|
|
||||||
{
|
|
||||||
id: "fabrikExit",
|
|
||||||
position: [-5, 25, -15],
|
|
||||||
radius: 10,
|
|
||||||
height: 20,
|
|
||||||
targetStep: "mission2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "searchingProblemZone",
|
|
||||||
position: [-5, 25, -30],
|
|
||||||
radius: 10,
|
|
||||||
height: 20,
|
|
||||||
targetStep: "searching_problem",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Store Zustand
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/stores/gameStore.ts
|
|
||||||
interface GameState {
|
|
||||||
step: GameStep;
|
|
||||||
activityCity: boolean;
|
|
||||||
playerName: string;
|
|
||||||
canMove: boolean;
|
|
||||||
setStep: (step: GameStep) => void;
|
|
||||||
setActivityCity: (value: boolean) => void;
|
|
||||||
setPlayerName: (name: string) => void;
|
|
||||||
setCanMove: (canMove: boolean) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hooks personnalisés
|
|
||||||
|
|
||||||
### useActivityCity
|
|
||||||
|
|
||||||
Permet aux objets 3D de réagir au changement d'activité de la ville :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useActivityCity } from "@/hooks/useActivityCity";
|
|
||||||
|
|
||||||
function MyAnimatedObject() {
|
|
||||||
const activityCity = useActivityCity(); // true par défaut, false en mission2
|
|
||||||
|
|
||||||
// L'animation se déclenche quand activityCity change à false
|
|
||||||
// Utiliser useEffect pour réagir au changement
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Debug
|
|
||||||
|
|
||||||
En mode debug (`?debug` dans l'URL), on peut voir :
|
|
||||||
|
|
||||||
- **Game Step** : L'étape actuelle dans le panneau lil-gui
|
|
||||||
- **Player Position** : Position X, Y, Z du joueur en temps réel
|
|
||||||
- **Zone Visualization** : Anneaux visuels au sol pour les zones + cylindres transparents
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes techniques
|
|
||||||
|
|
||||||
- Le mouvement du joueur est bloqué tant que `canMove` est `false`
|
|
||||||
- Le store Zustand (`useGameStore`) est la source principale de vérité
|
|
||||||
- `GameStepManager` synchronise automatiquement avec le store Zustand lors des transitions
|
|
||||||
- Les transitions via les zones utilisent `GameStepManager.transitionTo()` qui met à jour le store
|
|
||||||
- L'audio utilise un callback `onEnded` pour déclencher les transitions automatiques
|
|
||||||
@@ -10,7 +10,7 @@ The current prototype puts the player in a repair-oriented world where they prog
|
|||||||
- Production map loaded from `public/map.json`
|
- Production map loaded from `public/map.json`
|
||||||
- Progressive map/model/collision/stage loading overlay
|
- Progressive map/model/collision/stage loading overlay
|
||||||
- Player controller with pointer lock, `ZQSD` movement, jump, octree collision, trigger input, and grab input
|
- Player controller with pointer lock, `ZQSD` movement, jump, octree collision, trigger input, and grab input
|
||||||
- Reusable repair-game flow for `bike`, `pylone`, and `ferme`
|
- Reusable repair-game flow for `ebike`, `pylon`, and `farm`
|
||||||
- Repair case animation, exploded model scan, broken-part markers, grabbable replacements, snap-to-placeholder placement, install validation, reassembly, and completion
|
- Repair case animation, exploded model scan, broken-part markers, grabbable replacements, snap-to-placeholder placement, install validation, reassembly, and completion
|
||||||
- Shared interaction system for trigger and grab objects
|
- Shared interaction system for trigger and grab objects
|
||||||
- Rapier physics for gameplay objects while the player keeps a Three.js octree collision controller
|
- Rapier physics for gameplay objects while the player keeps a Three.js octree collision controller
|
||||||
@@ -18,7 +18,7 @@ The current prototype puts the player in a repair-oriented world where they prog
|
|||||||
- Category-based audio manager for music, SFX, and dialogue
|
- Category-based audio manager for music, SFX, and dialogue
|
||||||
- Dialogue manifest, SRT subtitles, subtitle overlay, and dialogue queueing
|
- Dialogue manifest, SRT subtitles, subtitle overlay, and dialogue queueing
|
||||||
- Cinematic manifest with GSAP camera keyframes and optional dialogue cues
|
- Cinematic manifest with GSAP camera keyframes and optional dialogue cues
|
||||||
- In-game settings menu for volumes, subtitles, subtitle language, and the currently staged repair-runtime toggle
|
- In-game settings menu for volumes, subtitles, and subtitle language
|
||||||
- Debug mode with `?debug`, lil-gui controls, game-state panel, hand-tracking panel, debug camera, physics playground, and R3F perf
|
- Debug mode with `?debug`, lil-gui controls, game-state panel, hand-tracking panel, debug camera, physics playground, and R3F perf
|
||||||
- `/editor` route for map transforms, SRT editing, dialogue manifest editing, cinematic manifest editing, preview, validation, export, and dev-server saves
|
- `/editor` route for map transforms, SRT editing, dialogue manifest editing, cinematic manifest editing, preview, validation, export, and dev-server saves
|
||||||
- `/docs` route that renders the repository documentation inside the app
|
- `/docs` route that renders the repository documentation inside the app
|
||||||
@@ -110,9 +110,15 @@ npm run format:check
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Regenerate runtime map data after editing `public/map_raw.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run map:transform
|
||||||
|
```
|
||||||
|
|
||||||
## Optional Hand-Tracking Backend
|
## Optional Hand-Tracking Backend
|
||||||
|
|
||||||
The app can use browser-side MediaPipe, but the default debug source is the local backend.
|
The app can use the local Python backend, but the default debug source is browser-side MediaPipe.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3.11 -m venv backend/.venv
|
python3.11 -m venv backend/.venv
|
||||||
@@ -154,8 +160,7 @@ WS ws://localhost:8000/ws
|
|||||||
## Current Caveats
|
## Current Caveats
|
||||||
|
|
||||||
- This is still a prototype, not a complete game runtime.
|
- This is still a prototype, not a complete game runtime.
|
||||||
- The repair-runtime toggle is stored in settings and displayed in the UI, but the repair game currently still runs locally in React/Three.
|
- `useRepairMovementLocked()` locks player movement during focused repair steps and drives the repair movement indicator.
|
||||||
- `useRepairMovementLocked()` currently returns `false`, so the movement-lock rule and indicator are present but disabled on `develop`.
|
|
||||||
- Production editor persistence does not exist. Save endpoints in `vite.config.ts` are local Vite dev-server helpers.
|
- Production editor persistence does not exist. Save endpoints in `vite.config.ts` are local Vite dev-server helpers.
|
||||||
- The player uses octree collision while gameplay objects use Rapier. Keep that boundary deliberate unless the whole player controller is migrated.
|
- The player uses octree collision while gameplay objects use Rapier. Keep that boundary deliberate unless the whole player controller is migrated.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ class ClientConnection:
|
|||||||
websocket: WebSocket
|
websocket: WebSocket
|
||||||
is_processing: bool = False
|
is_processing: bool = False
|
||||||
last_frame_at: float = 0.0
|
last_frame_at: float = 0.0
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ La-Fabrik est une expérience web 3D en React, Vite, Three.js et React Three Fib
|
|||||||
Le joueur est dans un monde 3D et avance dans une progression de réparation :
|
Le joueur est dans un monde 3D et avance dans une progression de réparation :
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
intro -> bike -> pylone -> ferme -> outro
|
intro -> ebike -> pylon -> farm -> outro
|
||||||
```
|
```
|
||||||
|
|
||||||
Les trois piliers à connaître pour la review :
|
Les trois piliers à connaître pour la review :
|
||||||
@@ -62,7 +62,7 @@ HomePage
|
|||||||
-> World
|
-> World
|
||||||
-> GameMap
|
-> GameMap
|
||||||
-> GameStageContent
|
-> GameStageContent
|
||||||
-> RepairGame bike/pylone/ferme
|
-> RepairGame ebike/pylon/farm
|
||||||
-> GameMusic
|
-> GameMusic
|
||||||
-> GameDialogues
|
-> GameDialogues
|
||||||
-> Player
|
-> Player
|
||||||
@@ -121,7 +121,7 @@ Phrase à retenir :
|
|||||||
|
|
||||||
Piège à connaître :
|
Piège à connaître :
|
||||||
|
|
||||||
`useRepairMovementLocked()` retourne actuellement `false`. Le lock de mouvement est prévu dans le code et l'UI, mais il est désactivé sur `develop`.
|
`useRepairMovementLocked()` lit maintenant l'étape de mission active et verrouille le déplacement pendant les phases de réparation qui doivent immobiliser le joueur.
|
||||||
|
|
||||||
### Interaction
|
### Interaction
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ Ouvrir dans cet ordre :
|
|||||||
`RepairGame` reçoit une mission :
|
`RepairGame` reçoit une mission :
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
<RepairGame mission="ebike" position={[42.2399, 4.5484, 34.6468]} />
|
||||||
```
|
```
|
||||||
|
|
||||||
Puis il vérifie :
|
Puis il vérifie :
|
||||||
@@ -347,7 +347,7 @@ Les variations mission sont dans `repairMissions.ts` :
|
|||||||
### Pourquoi c'est bien
|
### Pourquoi c'est bien
|
||||||
|
|
||||||
- Un seul flow réutilisable.
|
- Un seul flow réutilisable.
|
||||||
- Moins de duplication entre `bike`, `pylone`, `ferme`.
|
- Moins de duplication entre `ebike`, `pylon`, `farm`.
|
||||||
- Les règles générales restent dans les composants.
|
- Les règles générales restent dans les composants.
|
||||||
- Les variations restent dans la data.
|
- Les variations restent dans la data.
|
||||||
- Le debug panel peut tester les mêmes steps que le vrai jeu.
|
- Le debug panel peut tester les mêmes steps que le vrai jeu.
|
||||||
@@ -471,9 +471,9 @@ Main states :
|
|||||||
|
|
||||||
```txt
|
```txt
|
||||||
intro
|
intro
|
||||||
bike
|
ebike
|
||||||
pylone
|
pylon
|
||||||
ferme
|
farm
|
||||||
outro
|
outro
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -509,12 +509,7 @@ Gère :
|
|||||||
- menu ouvert/fermé ;
|
- menu ouvert/fermé ;
|
||||||
- volumes ;
|
- volumes ;
|
||||||
- sous-titres ;
|
- sous-titres ;
|
||||||
- langue ;
|
- langue.
|
||||||
- `repairRuntime`.
|
|
||||||
|
|
||||||
Piège :
|
|
||||||
|
|
||||||
`repairRuntime` est stocké et affiché, mais pas encore utilisé par `RepairGame`.
|
|
||||||
|
|
||||||
### Subtitle store
|
### Subtitle store
|
||||||
|
|
||||||
@@ -532,9 +527,8 @@ Phrase simple :
|
|||||||
Si on te pose une question précise, réponds vrai.
|
Si on te pose une question précise, réponds vrai.
|
||||||
|
|
||||||
| Sujet | Réponse honnête |
|
| Sujet | Réponse honnête |
|
||||||
| ------------------------- | ------------------------------------------------------------------------ |
|
| ------------------------- | -------------------------------------------------------------------- |
|
||||||
| Lock mouvement réparation | Le hook existe mais retourne `false`, donc pas actif actuellement. |
|
| Lock mouvement réparation | Les étapes repair actives bloquent le déplacement via le hook dédié. |
|
||||||
| `repairRuntime` JS/Python | Le choix est stocké dans settings, mais pas consommé par le repair game. |
|
|
||||||
| Old debug flags | `noMusic`, `noMap`, `noDialogues`, etc. ne sont plus branchés. |
|
| Old debug flags | `noMusic`, `noMap`, `noDialogues`, etc. ne sont plus branchés. |
|
||||||
| Player physics | Le joueur n'est pas Rapier, il utilise capsule + octree. |
|
| Player physics | Le joueur n'est pas Rapier, il utilise capsule + octree. |
|
||||||
| Collision map | L'octree vient de nodes dédiés, actuellement `terrain`. |
|
| Collision map | L'octree vient de nodes dédiés, actuellement `terrain`. |
|
||||||
@@ -833,8 +827,6 @@ Pour réutiliser le même flow sur plusieurs missions et garder les variations d
|
|||||||
### Qu'est-ce qui est incomplet ?
|
### Qu'est-ce qui est incomplet ?
|
||||||
|
|
||||||
- pas de vrai `GameManager` global ;
|
- pas de vrai `GameManager` global ;
|
||||||
- lock mouvement réparation désactivé ;
|
|
||||||
- `repairRuntime` pas consommé ;
|
|
||||||
- editor save uniquement en dev ;
|
- editor save uniquement en dev ;
|
||||||
- hand tracking encore approximatif sur profondeur et smoothing.
|
- hand tracking encore approximatif sur profondeur et smoothing.
|
||||||
|
|
||||||
@@ -869,8 +861,7 @@ Fichiers à avoir en tête :
|
|||||||
|
|
||||||
Réponses pièges à réviser :
|
Réponses pièges à réviser :
|
||||||
|
|
||||||
- lock mouvement repair désactivé actuellement ;
|
- lock mouvement repair actif sur les étapes dédiées ;
|
||||||
- `repairRuntime` pas consommé ;
|
|
||||||
- player pas Rapier ;
|
- player pas Rapier ;
|
||||||
- save editor pas production ;
|
- save editor pas production ;
|
||||||
- old boot flags non branchés.
|
- old boot flags non branchés.
|
||||||
|
|||||||
@@ -46,13 +46,11 @@ It supports:
|
|||||||
The debug physics scene currently uses it to preview:
|
The debug physics scene currently uses it to preview:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
public/models/electricienne_animated/model.gltf
|
public/models/electricienne-animated/model.gltf
|
||||||
```
|
```
|
||||||
|
|
||||||
with the `Dance` animation.
|
with the `Dance` animation.
|
||||||
|
|
||||||
`src/hooks/animation/useCharacterAnimation.ts` is a hook-level alternative for components that need to own their group ref and animation controls directly.
|
|
||||||
|
|
||||||
## GLTF Reuse
|
## GLTF Reuse
|
||||||
|
|
||||||
Use `useClonedObject` when a GLTF scene is reused by a component instance. It memoizes `scene.clone(true)` and keeps clone creation out of render churn.
|
Use `useClonedObject` when a GLTF scene is reused by a component instance. It memoizes `scene.clone(true)` and keeps clone creation out of render churn.
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ It owns:
|
|||||||
|
|
||||||
- `mainState`
|
- `mainState`
|
||||||
- intro state
|
- intro state
|
||||||
- `bike`, `pylone`, and `ferme` mission state
|
- `ebike`, `pylon`, and `farm` mission state
|
||||||
- outro state
|
- outro state
|
||||||
- `isCinematicPlaying`
|
- `isCinematicPlaying`
|
||||||
- progression actions
|
- progression actions
|
||||||
@@ -297,8 +297,7 @@ public/models/{name}/model.gltf
|
|||||||
- The repository is still a prototype.
|
- The repository is still a prototype.
|
||||||
- There is no central production `GameManager`.
|
- There is no central production `GameManager`.
|
||||||
- The repair game is implemented, but broader mission orchestration is still light.
|
- The repair game is implemented, but broader mission orchestration is still light.
|
||||||
- `useRepairMovementLocked()` currently returns `false`, so repair movement lock is disabled even though the rule and UI component exist.
|
- `useRepairMovementLocked()` locks player movement during focused repair steps.
|
||||||
- The repair-runtime setting is stored in settings but not consumed by the repair-game implementation.
|
|
||||||
- Player collision and Rapier gameplay physics are separate systems.
|
- Player collision and Rapier gameplay physics are separate systems.
|
||||||
- Editor persistence is local development tooling only.
|
- Editor persistence is local development tooling only.
|
||||||
- Debug systems are still part of active scene composition and should remain easy to identify.
|
- Debug systems are still part of active scene composition and should remain easy to identify.
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
|||||||
2. `useEditorSceneData` calls `loadMapSceneData()`.
|
2. `useEditorSceneData` calls `loadMapSceneData()`.
|
||||||
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
|
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
|
||||||
4. If `/map.json` is missing, the page displays a folder-upload flow.
|
4. If `/map.json` is missing, the page displays a folder-upload flow.
|
||||||
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
|
5. The route-level loading overlay reports map JSON loading, then hands off to the editor scene once the map payload is ready.
|
||||||
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
|
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
|
||||||
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, multi-selection status, and the cinematic/dialogue/SRT editors.
|
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, multi-selection status, and the cinematic/dialogue/SRT editors.
|
||||||
|
|
||||||
@@ -122,8 +122,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
|||||||
- Click: select a node.
|
- Click: select a node.
|
||||||
- `Shift` + right click: add or remove a node from the multi-selection.
|
- `Shift` + right click: add or remove a node from the multi-selection.
|
||||||
- `Esc`: clear selection.
|
- `Esc`: clear selection.
|
||||||
- Click empty space: clear selection.
|
- Selection lock button: prevent object clicks and `Esc` from changing the current selection.
|
||||||
- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection.
|
|
||||||
- Selection clear button: intentionally clear the current selection even when the lock is active.
|
- Selection clear button: intentionally clear the current selection even when the lock is active.
|
||||||
- `T`: translate mode.
|
- `T`: translate mode.
|
||||||
- `R`: rotate mode.
|
- `R`: rotate mode.
|
||||||
@@ -151,14 +150,13 @@ The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite
|
|||||||
|
|
||||||
## Editor Loading Overlay
|
## Editor Loading Overlay
|
||||||
|
|
||||||
The editor uses `SceneLoadingOverlay` like the runtime scene. `EditorSceneLoadingTracker` lives in `src/pages/editor/page.tsx` and reads drei `useProgress()` inside the canvas.
|
The editor uses `SceneLoadingOverlay` like the runtime scene for the route-level map JSON loading phase.
|
||||||
|
|
||||||
The route tracks two loading phases:
|
The route tracks the map JSON loading phase:
|
||||||
|
|
||||||
- map JSON loading through `useEditorSceneData()`
|
- map JSON loading through `useEditorSceneData()`
|
||||||
- model loading through `useProgress()`
|
|
||||||
|
|
||||||
The overlay is rendered outside the canvas so it remains visible while the R3F scene mounts. The scene itself is wrapped in `Suspense` with a `null` fallback; the visual feedback is handled by the overlay instead of by the canvas fallback.
|
The overlay is rendered outside the canvas so it remains visible while the editor route mounts. Model loading is left to R3F `Suspense` boundaries to avoid progress updates during model render.
|
||||||
|
|
||||||
## Panel Groups
|
## Panel Groups
|
||||||
|
|
||||||
@@ -190,9 +188,9 @@ The state is passed to:
|
|||||||
|
|
||||||
- `EditorControls`, to render the lock/unlock button
|
- `EditorControls`, to render the lock/unlock button
|
||||||
- `EditorScene`, to block `Esc` deselection when locked
|
- `EditorScene`, to block `Esc` deselection when locked
|
||||||
- `EditorMap`, to block object selection and empty-space deselection when locked
|
- `EditorMap`, to block object selection when locked
|
||||||
|
|
||||||
The clear button calls `onClearSelection` directly from `EditorControls`. This is intentionally separate from scene click behavior so the user always has an explicit way to clear the selection.
|
The clear button calls `onClearSelection` directly from `EditorControls`. Clicking empty canvas space does not clear the current selection; use `Esc` or the explicit clear button instead.
|
||||||
|
|
||||||
## Dialogue SRT Editing
|
## Dialogue SRT Editing
|
||||||
|
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
# Game Flow - La Fabrik
|
|
||||||
|
|
||||||
## Étapes du jeu
|
|
||||||
|
|
||||||
```
|
|
||||||
intro → start-intro → naming → bienvenue → star-move → mission2 → searching_problem → preparation → outOfFabrik
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Détail des étapes
|
|
||||||
|
|
||||||
### 1. `intro` (initial)
|
|
||||||
|
|
||||||
- État initial au chargement du jeu
|
|
||||||
- Aucune action, juste une étape de départ
|
|
||||||
- Transition automatique vers `start-intro`
|
|
||||||
|
|
||||||
### 2. `start-intro`
|
|
||||||
|
|
||||||
- **Déclenchement** : Auto-transition depuis `intro` quand la scène est chargée
|
|
||||||
- **Action** : Joue l'audio d'intro (`intro`)
|
|
||||||
- **Attente** : Attend que l'audio se termine
|
|
||||||
- **Transition** : Vers `naming` quand l'audio se termine
|
|
||||||
|
|
||||||
### 3. `naming`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand l'audio d'intro se termine
|
|
||||||
- **Action** : Affiche un input pour demander le prénom du joueur
|
|
||||||
- **Attente** : L'utilisateur entre son prénom et valide
|
|
||||||
- **Transition** : Vers `bienvenue` quand l'utilisateur valide
|
|
||||||
|
|
||||||
### 4. `bienvenue`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand l'utilisateur valide son prénom
|
|
||||||
- **Actions** :
|
|
||||||
- Affiche "Bienvenue {prénom} !" à l'écran
|
|
||||||
- Joue l'audio de bienvenue
|
|
||||||
- **Attente** : Attend que l'audio se termine
|
|
||||||
- **Transition** : Vers `star-move` quand l'audio se termine
|
|
||||||
|
|
||||||
### 5. `star-move`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand l'audio de bienvenue se termine
|
|
||||||
- **Action** : Active le mouvement du joueur (`setCanMove(true)`)
|
|
||||||
- **État** : Le joueur peut maintenant se déplacer librement
|
|
||||||
- **Zone** : La détection de zone devient active (ZoneDetection)
|
|
||||||
|
|
||||||
### 6. `mission2`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand le joueur entre dans la zone `fabrikExit` (position: `[-5, 25, -15]`)
|
|
||||||
- **Actions** :
|
|
||||||
- Stocke `activityCity: false` dans le store Zustand
|
|
||||||
- Joue l'audio `alertCentral`
|
|
||||||
- **État** : Les objets avec hook `useActivityCity()` détectent le changement et jouent leurs animations
|
|
||||||
- **Attente** : Le joueur atteint la zone de trigger pour `searching_problem`
|
|
||||||
|
|
||||||
### 7. `searching_problem`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand le joueur entre dans la zone `searchingProblemZone` (position: `[-5, 25, -30]`)
|
|
||||||
- **Actions** :
|
|
||||||
- Joue l'audio `searchingProblem`
|
|
||||||
- Affiche l'objet "central" (position: `[1, 15, -45]`)
|
|
||||||
- **Attente** : Le joueur interagit avec l'objet "central"
|
|
||||||
|
|
||||||
### 8. `preparation`
|
|
||||||
|
|
||||||
- **Déclenchement** : Quand le joueur interagit avec l'objet "central"
|
|
||||||
- **Actions** :
|
|
||||||
- Bloque le mouvement (`setCanMove(false)`)
|
|
||||||
- Cache l'objet "central"
|
|
||||||
|
|
||||||
### 9. `outOfFabrik`
|
|
||||||
|
|
||||||
- **Déclenchement** : (non implémenté pour le moment)
|
|
||||||
- **Action** : Transition vers l'étape finale
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fichiers clés
|
|
||||||
|
|
||||||
| Fichier | Rôle |
|
|
||||||
| --------------------------------------- | --------------------------------------------------------- |
|
|
||||||
| `src/stores/gameStore.ts` | Store Zustand pour l'état global du jeu |
|
|
||||||
| `src/stateManager/GameStepManager.ts` | Synchronise avec le store Zustand |
|
|
||||||
| `src/components/game/GameFlow.tsx` | Gère les transitions automatiques et la lecture audio |
|
|
||||||
| `src/components/ui/IntroUI.tsx` | Affiche l'input pour le prénom et le message de bienvenue |
|
|
||||||
| `src/components/zone/ZoneDetection.tsx` | Détecte quand le joueur entre dans une zone |
|
|
||||||
| `src/components/3d/CentralObject.tsx` | Objet interactif "central" pour la mission 2 |
|
|
||||||
| `src/data/audioConfig.ts` | Chemins des fichiers audio |
|
|
||||||
| `src/data/zones.ts` | Configuration des zones de transition |
|
|
||||||
| `src/hooks/useActivityCity.ts` | Hook pour détecter le changement d'activité de la ville |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration audio
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/data/audioConfig.ts
|
|
||||||
export const AUDIO_PATHS = {
|
|
||||||
intro: "/sounds/fa.mp3",
|
|
||||||
bienvenue: "/sounds/fa.mp3",
|
|
||||||
alertCentral: "/sounds/fa.mp3",
|
|
||||||
searchingProblem: "/sounds/fa.mp3",
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration des zones
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/data/zones.ts
|
|
||||||
export const ZONES: Zone[] = [
|
|
||||||
{
|
|
||||||
id: "fabrikExit",
|
|
||||||
position: [-5, 25, -15],
|
|
||||||
radius: 10,
|
|
||||||
height: 20,
|
|
||||||
targetStep: "mission2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "searchingProblemZone",
|
|
||||||
position: [-5, 25, -30],
|
|
||||||
radius: 10,
|
|
||||||
height: 20,
|
|
||||||
targetStep: "searching_problem",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Store Zustand
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/stores/gameStore.ts
|
|
||||||
interface GameState {
|
|
||||||
step: GameStep;
|
|
||||||
activityCity: boolean;
|
|
||||||
playerName: string;
|
|
||||||
canMove: boolean;
|
|
||||||
setStep: (step: GameStep) => void;
|
|
||||||
setActivityCity: (value: boolean) => void;
|
|
||||||
setPlayerName: (name: string) => void;
|
|
||||||
setCanMove: (canMove: boolean) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hooks personnalisés
|
|
||||||
|
|
||||||
### useActivityCity
|
|
||||||
|
|
||||||
Permet aux objets 3D de réagir au changement d'activité de la ville :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useActivityCity } from "@/hooks/useActivityCity";
|
|
||||||
|
|
||||||
function MyAnimatedObject() {
|
|
||||||
const activityCity = useActivityCity(); // true par défaut, false en mission2
|
|
||||||
|
|
||||||
// L'animation se déclenche quand activityCity change à false
|
|
||||||
// Utiliser useEffect pour réagir au changement
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Debug
|
|
||||||
|
|
||||||
En mode debug (`?debug` dans l'URL), on peut voir :
|
|
||||||
|
|
||||||
- **Game Step** : L'étape actuelle dans le panneau lil-gui
|
|
||||||
- **Player Position** : Position X, Y, Z du joueur en temps réel
|
|
||||||
- **Zone Visualization** : Anneaux visuels au sol pour les zones + cylindres transparents
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes techniques
|
|
||||||
|
|
||||||
- Le mouvement du joueur est bloqué tant que `canMove` est `false`
|
|
||||||
- Le store Zustand (`useGameStore`) est la source principale de vérité
|
|
||||||
- `GameStepManager` synchronise automatiquement avec le store Zustand lors des transitions
|
|
||||||
- Les transitions via les zones utilisent `GameStepManager.transitionTo()` qui met à jour le store
|
|
||||||
- L'audio utilise un callback `onEnded` pour déclencher les transitions automatiques
|
|
||||||
@@ -32,7 +32,7 @@ This keeps hand tracking active while the player is inside an interaction zone,
|
|||||||
|
|
||||||
The production repair activation conditions are:
|
The production repair activation conditions are:
|
||||||
|
|
||||||
- active `mainState` is `bike`, `pylone`, or `ferme`
|
- active `mainState` is `ebike`, `pylon`, or `farm`
|
||||||
- the active mission step is `inspected`, `repairing`, `reassembling`, or `done`
|
- the active mission step is `inspected`, `repairing`, `reassembling`, or `done`
|
||||||
|
|
||||||
This keeps the webcam off during `waiting`, `fragmented`, and `scanning`, then enables hand input only when the repair flow is expected to use hands.
|
This keeps the webcam off during `waiting`, `fragmented`, and `scanning`, then enables hand input only when the repair flow is expected to use hands.
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ Input is ignored while:
|
|||||||
- the settings menu is open
|
- the settings menu is open
|
||||||
- a cinematic is playing
|
- a cinematic is playing
|
||||||
|
|
||||||
Movement lock is read separately from `useRepairMovementLocked`, but that hook currently returns `false` on this branch.
|
Movement lock is read separately from `useRepairMovementLocked`, which locks the player during focused repair steps.
|
||||||
|
|
||||||
## UI Prompt
|
## UI Prompt
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ These vegetation and crop assets account for almost all of the current `~69M` tr
|
|||||||
|
|
||||||
## Debug Performance Controls
|
## Debug Performance Controls
|
||||||
|
|
||||||
The next useful runtime tool is a debug-only performance folder that can isolate model families. This should be mounted only when `?debug` is enabled.
|
The debug-only performance folder can isolate model families when `?debug` is enabled.
|
||||||
|
|
||||||
Proposed controls:
|
Proposed controls:
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ The store owns the `missionFlow` slice:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
missionFlow: {
|
missionFlow: {
|
||||||
step: GameStep;
|
|
||||||
activityCity: boolean;
|
activityCity: boolean;
|
||||||
playerName: string;
|
playerName: string;
|
||||||
canMove: boolean;
|
canMove: boolean;
|
||||||
@@ -31,14 +30,14 @@ Managers stay responsible for local runtime services:
|
|||||||
- `AudioManager` owns audio elements, audio pools, music playback, category volume, and stereo pan.
|
- `AudioManager` owns audio elements, audio pools, music playback, category volume, and stereo pan.
|
||||||
- `InteractionManager` owns transient focused/nearby/held interaction handles.
|
- `InteractionManager` owns transient focused/nearby/held interaction handles.
|
||||||
|
|
||||||
Mission progression is not owned by a manager. Components update the store through explicit actions such as `setFlowStep`, `setCanMove`, `showDialog`, and `hideDialog`.
|
Mission progression is not owned by a manager. Components update the store through explicit actions such as `setIntroStep`, `setCanMove`, `showDialog`, and `hideDialog`.
|
||||||
|
|
||||||
## Runtime Components
|
## Runtime Components
|
||||||
|
|
||||||
- `src/components/game/GameFlow.tsx` reacts to `missionFlow.step` and triggers one-off side effects such as intro audio and movement unlocks.
|
- `src/components/game/GameFlow.tsx` reacts to intro state and triggers one-off side effects such as intro audio and movement unlocks.
|
||||||
- `src/components/zone/ZoneDetection.tsx` reads the camera position and moves the flow to a target step when the player enters a configured zone.
|
- `src/components/zone/ZoneDetection.tsx` reads the camera position and moves the flow to a target step when the player enters a configured zone.
|
||||||
- `src/components/three/interaction/CentralObject.tsx` and `VillageoisHelperObject.tsx` expose temporary interactive mission objects.
|
- `src/world/GameStageContent.tsx` mounts repair games and their mission-start triggers.
|
||||||
- `src/pages/page.tsx` mounts mission HTML overlays: `IntroUI`, `BienvenueDisplay`, and `DialogMessage`.
|
- `src/pages/page.tsx` mounts mission HTML overlays: `IntroUI`, `DialogMessage`, and subtitles.
|
||||||
- `src/world/player/PlayerController.tsx` reads `missionFlow.canMove` as an additional movement lock.
|
- `src/world/player/PlayerController.tsx` reads `missionFlow.canMove` as an additional movement lock.
|
||||||
|
|
||||||
## Step Sequence
|
## Step Sequence
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ The repair game is the current core gameplay loop. It gives three missions the s
|
|||||||
Implemented missions:
|
Implemented missions:
|
||||||
|
|
||||||
| Mission | Object | Role |
|
| Mission | Object | Role |
|
||||||
| -------- | ------------- | --------------------------------------------- |
|
| ------- | ------------- | --------------------------------------------- |
|
||||||
| `bike` | E-bike | Repair a damaged cooling core |
|
| `ebike` | E-bike | Repair a damaged cooling core |
|
||||||
| `pylone` | Power pylon | Restore relay/panel-like broken parts |
|
| `pylon` | Power pylon | Restore relay/panel-like broken parts |
|
||||||
| `ferme` | Vertical farm | Stabilize irrigation/sensor-like broken parts |
|
| `farm` | Vertical farm | Stabilize irrigation/sensor-like broken parts |
|
||||||
|
|
||||||
## Main Files
|
## Main Files
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ src/managers/stores/useGameStore.ts
|
|||||||
- `setMissionStep(mission, nextStep)`
|
- `setMissionStep(mission, nextStep)`
|
||||||
- `completeMission(mission)`
|
- `completeMission(mission)`
|
||||||
|
|
||||||
The important architectural choice is that reusable repair components do not call `setBikeState`, `setPyloneState`, or `setFermeState` directly. They use generic mission actions so the same component can run for all three missions.
|
The important architectural choice is that reusable repair components do not call `setEbikeState`, `setPylonState`, or `setFarmState` directly. They use generic mission actions so the same component can run for all three missions.
|
||||||
|
|
||||||
## Data-Driven Mission Config
|
## Data-Driven Mission Config
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ The repair case appears near the mission object. The player can:
|
|||||||
|
|
||||||
Both paths move to `fragmented`.
|
Both paths move to `fragmented`.
|
||||||
|
|
||||||
Important current detail: `useRepairMovementLocked()` currently returns `false`, so the movement-lock rule and indicator are present but disabled in the current branch.
|
`useRepairMovementLocked()` locks player movement during focused repair steps and drives the repair movement indicator.
|
||||||
|
|
||||||
### Fragmented
|
### Fragmented
|
||||||
|
|
||||||
@@ -324,9 +324,9 @@ src/world/GameStageContent.tsx
|
|||||||
Current positions:
|
Current positions:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
<RepairGame mission="ebike" position={[42.2399, 4.5484, 34.6468]} />
|
||||||
<RepairGame mission="pylone" position={[64, 0, -66]} />
|
<RepairGame mission="pylon" position={[64, 0, -66]} />
|
||||||
<RepairGame mission="ferme" position={[-24, 0, 42]} />
|
<RepairGame mission="farm" position={[-24, 0, 42]} />
|
||||||
```
|
```
|
||||||
|
|
||||||
Only the repair game whose `mission` matches `useGameStore().mainState` renders active content.
|
Only the repair game whose `mission` matches `useGameStore().mainState` renders active content.
|
||||||
|
|||||||
+20
-27
@@ -29,9 +29,9 @@ They are under `src/managers/stores/` because they are shared runtime state, not
|
|||||||
## Store Responsibilities
|
## Store Responsibilities
|
||||||
|
|
||||||
| Store | Responsibility |
|
| Store | Responsibility |
|
||||||
| ------------------ | ----------------------------------------------------------------- |
|
| ------------------ | ------------------------------------------------------------- |
|
||||||
| `useGameStore` | Durable game progression, mission steps, cinematic input lock |
|
| `useGameStore` | Durable game progression, mission steps, cinematic input lock |
|
||||||
| `useSettingsStore` | Menu visibility, volumes, subtitle options, repair-runtime toggle |
|
| `useSettingsStore` | Menu visibility, volumes, and subtitle options |
|
||||||
| `useSubtitleStore` | Currently displayed subtitle cue |
|
| `useSubtitleStore` | Currently displayed subtitle cue |
|
||||||
|
|
||||||
## Managers vs Stores
|
## Managers vs Stores
|
||||||
@@ -65,18 +65,18 @@ Main states:
|
|||||||
| Main state | Role |
|
| Main state | Role |
|
||||||
| ---------- | ------------------------------- |
|
| ---------- | ------------------------------- |
|
||||||
| `intro` | Onboarding and opening sequence |
|
| `intro` | Onboarding and opening sequence |
|
||||||
| `bike` | E-bike repair sequence |
|
| `ebike` | E-bike repair sequence |
|
||||||
| `pylone` | Power pylon repair sequence |
|
| `pylon` | Power pylon repair sequence |
|
||||||
| `ferme` | Vertical farm repair sequence |
|
| `farm` | Vertical farm repair sequence |
|
||||||
| `outro` | Ending sequence |
|
| `outro` | Ending sequence |
|
||||||
|
|
||||||
Other important state:
|
Other important state:
|
||||||
|
|
||||||
- `isCinematicPlaying`
|
- `isCinematicPlaying`
|
||||||
- `intro`
|
- `intro`
|
||||||
- `bike`
|
- `ebike`
|
||||||
- `pylone`
|
- `pylon`
|
||||||
- `ferme`
|
- `farm`
|
||||||
- `outro`
|
- `outro`
|
||||||
|
|
||||||
Mission steps:
|
Mission steps:
|
||||||
@@ -125,28 +125,28 @@ For development and debug tooling, direct setters also exist:
|
|||||||
```ts
|
```ts
|
||||||
const setMainState = useGameStore((state) => state.setMainState);
|
const setMainState = useGameStore((state) => state.setMainState);
|
||||||
|
|
||||||
setMainState("bike");
|
setMainState("ebike");
|
||||||
```
|
```
|
||||||
|
|
||||||
Direct setters are useful for debug panels, but production gameplay should prefer business actions such as:
|
Direct setters are useful for debug panels, but production gameplay should prefer business actions such as:
|
||||||
|
|
||||||
- `advanceGameState`
|
- `advanceGameState`
|
||||||
- `completeBike`
|
- `completeEbike`
|
||||||
- `completePylone`
|
- `completePylon`
|
||||||
- `completeFerme`
|
- `completeFarm`
|
||||||
- `completeMission`
|
- `completeMission`
|
||||||
|
|
||||||
Mission gameplay that can target `bike`, `pylone`, or `ferme` should prefer generic mission actions:
|
Mission gameplay that can target `ebike`, `pylon`, or `farm` should prefer generic mission actions:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||||
const completeMission = useGameStore((state) => state.completeMission);
|
const completeMission = useGameStore((state) => state.completeMission);
|
||||||
|
|
||||||
setMissionStep("bike", "inspected");
|
setMissionStep("ebike", "inspected");
|
||||||
completeMission("bike");
|
completeMission("ebike");
|
||||||
```
|
```
|
||||||
|
|
||||||
This keeps reusable gameplay components such as `RepairGame` from duplicating mission-specific branches like `setBikeState`, `setPyloneState`, and `setFermeState`.
|
This keeps reusable gameplay components such as `RepairGame` from duplicating mission-specific branches like `setEbikeState`, `setPylonState`, and `setFarmState`.
|
||||||
|
|
||||||
## Settings Store
|
## Settings Store
|
||||||
|
|
||||||
@@ -160,7 +160,6 @@ State:
|
|||||||
- `dialogueVolume`
|
- `dialogueVolume`
|
||||||
- `subtitlesEnabled`
|
- `subtitlesEnabled`
|
||||||
- `subtitleLanguage`
|
- `subtitleLanguage`
|
||||||
- `repairRuntime`
|
|
||||||
|
|
||||||
Audio setters clamp values between `0` and `1`, then call:
|
Audio setters clamp values between `0` and `1`, then call:
|
||||||
|
|
||||||
@@ -170,8 +169,6 @@ AudioManager.getInstance().setCategoryVolume(category, nextVolume);
|
|||||||
|
|
||||||
This keeps UI state and browser audio state synchronized.
|
This keeps UI state and browser audio state synchronized.
|
||||||
|
|
||||||
Current caveat: `repairRuntime` is stored and displayed in the settings menu, but the repair game does not consume it yet. Treat it as a staged architecture hook rather than an active runtime switch.
|
|
||||||
|
|
||||||
## Subtitle Store
|
## Subtitle Store
|
||||||
|
|
||||||
`useSubtitleStore` is intentionally tiny.
|
`useSubtitleStore` is intentionally tiny.
|
||||||
@@ -191,9 +188,9 @@ State/actions:
|
|||||||
Current production repair placement:
|
Current production repair placement:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
<RepairGame mission="ebike" position={[42.2399, 4.5484, 34.6468]} />
|
||||||
<RepairGame mission="pylone" position={[64, 0, -66]} />
|
<RepairGame mission="pylon" position={[64, 0, -66]} />
|
||||||
<RepairGame mission="ferme" position={[-24, 0, 42]} />
|
<RepairGame mission="farm" position={[-24, 0, 42]} />
|
||||||
```
|
```
|
||||||
|
|
||||||
`RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep` and `completeMission`.
|
`RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep` and `completeMission`.
|
||||||
@@ -222,13 +219,11 @@ Current overlays:
|
|||||||
- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states
|
- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states
|
||||||
- `Crosshair`: player aiming helper
|
- `Crosshair`: player aiming helper
|
||||||
- `InteractPrompt`: interaction prompt
|
- `InteractPrompt`: interaction prompt
|
||||||
- `RepairMovementLockIndicator`: indicator intended for repair movement lock
|
- `RepairMovementLockIndicator`: indicator shown while repair steps lock movement
|
||||||
- `HandTrackingVisualizer`: hand tracking SVG fallback/debug visualization
|
- `HandTrackingVisualizer`: hand tracking SVG fallback/debug visualization
|
||||||
- `Subtitles`: active dialogue subtitle overlay
|
- `Subtitles`: active dialogue subtitle overlay
|
||||||
- `GameSettingsMenu`: options menu and settings controls
|
- `GameSettingsMenu`: options menu and settings controls
|
||||||
|
|
||||||
Current caveat: `useRepairMovementLocked()` returns `false` immediately on the current branch, so the movement-lock rule and indicator exist but are disabled at runtime.
|
|
||||||
|
|
||||||
## Regression Rules
|
## Regression Rules
|
||||||
|
|
||||||
- Do not store per-frame values in Zustand.
|
- Do not store per-frame values in Zustand.
|
||||||
@@ -241,6 +236,4 @@ Current caveat: `useRepairMovementLocked()` returns `false` immediately on the c
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- Decide whether `repairRuntime` should be removed, implemented, or clearly labeled as experimental.
|
|
||||||
- Re-enable or remove the repair movement-lock rule depending on desired gameplay.
|
|
||||||
- Move broader mission orchestration into a clearer layer if intro, mission, dialogue, and cinematic branching grows.
|
- Move broader mission orchestration into a clearer layer if intro, mission, dialogue, and cinematic branching grows.
|
||||||
|
|||||||
+2
-3
@@ -72,7 +72,7 @@ Use the trash button in `Selection` to delete the selected node from the map tre
|
|||||||
| -------------------- | -------------------------- |
|
| -------------------- | -------------------------- |
|
||||||
| Select object | Click object |
|
| Select object | Click object |
|
||||||
| Toggle multi-select | `Shift` + right click |
|
| Toggle multi-select | `Shift` + right click |
|
||||||
| Deselect | `Esc` or click empty space |
|
| Deselect | `Esc` |
|
||||||
| Lock selection | `Lock` button in Selection |
|
| Lock selection | `Lock` button in Selection |
|
||||||
| Clear selection | `X` button in Selection |
|
| Clear selection | `X` button in Selection |
|
||||||
| Translate mode | `T` |
|
| Translate mode | `T` |
|
||||||
@@ -91,7 +91,7 @@ The `Selection` section shows the selected object name and its index in `public/
|
|||||||
- Click an object to select it.
|
- Click an object to select it.
|
||||||
- Use `Shift + right click` on objects to add or remove them from a multi-selection.
|
- Use `Shift + right click` on objects to add or remove them from a multi-selection.
|
||||||
- When several objects are selected, the gizmo appears on the selection group and applies translate, rotate, or scale to each selected node.
|
- When several objects are selected, the gizmo appears on the selection group and applies translate, rotate, or scale to each selected node.
|
||||||
- Click empty space or press `Esc` to clear the selection.
|
- Press `Esc` to clear the selection.
|
||||||
- Use the `X` button to clear the selection explicitly.
|
- Use the `X` button to clear the selection explicitly.
|
||||||
- Use the `Lock` button to protect the current selection while editing.
|
- Use the `Lock` button to protect the current selection while editing.
|
||||||
- Use the scale fields to edit X/Y/Z scale precisely.
|
- Use the scale fields to edit X/Y/Z scale precisely.
|
||||||
@@ -108,7 +108,6 @@ This is intended for map objects that should sit on the ground. Disable it when
|
|||||||
When selection is locked:
|
When selection is locked:
|
||||||
|
|
||||||
- clicking another object does not change the selection
|
- clicking another object does not change the selection
|
||||||
- clicking empty space does not clear the selection
|
|
||||||
- pressing `Esc` does not clear the selection
|
- pressing `Esc` does not clear the selection
|
||||||
- the `X` button still clears the selection intentionally
|
- the `X` button still clears the selection intentionally
|
||||||
|
|
||||||
|
|||||||
+11
-12
@@ -37,7 +37,7 @@ This document lists the user-visible and developer-facing features implemented i
|
|||||||
- Input lock while the settings menu is open
|
- Input lock while the settings menu is open
|
||||||
- Input lock while a cinematic is playing
|
- Input lock while a cinematic is playing
|
||||||
- Octree collision against dedicated map collision nodes, currently scoped to the `terrain` node
|
- Octree collision against dedicated map collision nodes, currently scoped to the `terrain` node
|
||||||
- Repair movement-lock hook and indicator exist, but the hook currently returns `false`, so movement is not locked during repair on the current branch
|
- Repair movement lock during focused repair steps, with a matching UI indicator
|
||||||
|
|
||||||
## Physics And Collision
|
## Physics And Collision
|
||||||
|
|
||||||
@@ -63,12 +63,12 @@ This document lists the user-visible and developer-facing features implemented i
|
|||||||
|
|
||||||
## Repair Gameplay
|
## Repair Gameplay
|
||||||
|
|
||||||
- Reusable `RepairGame` mounted for `bike`, `pylone`, and `ferme`
|
- Reusable `RepairGame` mounted for `ebike`, `pylon`, and `farm`
|
||||||
- Mission progression driven by Zustand and shared `MissionStep` types
|
- Mission progression driven by Zustand and shared `MissionStep` types
|
||||||
- Production repair positions:
|
- Production repair positions:
|
||||||
- `bike` at `[8, 0, -6]`
|
- `ebike` at `[42.2399, 4.5484, 34.6468]`
|
||||||
- `pylone` at `[64, 0, -66]`
|
- `pylon` at `[64, 0, -66]`
|
||||||
- `ferme` at `[-24, 0, 42]`
|
- `farm` at `[-24, 0, 42]`
|
||||||
- Debug physics repair playground zones for all three missions
|
- Debug physics repair playground zones for all three missions
|
||||||
- Data-driven mission config in `src/data/gameplay/repairMissions.ts`
|
- Data-driven mission config in `src/data/gameplay/repairMissions.ts`
|
||||||
- Mission flow: `locked -> waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done`
|
- Mission flow: `locked -> waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done`
|
||||||
@@ -80,9 +80,9 @@ This document lists the user-visible and developer-facing features implemented i
|
|||||||
- Fragmentation through repair-case trigger or two-fists hand gesture
|
- Fragmentation through repair-case trigger or two-fists hand gesture
|
||||||
- Exploded model visualization through `ExplodableModel`
|
- Exploded model visualization through `ExplodableModel`
|
||||||
- Scan visual that steps through exploded parts
|
- Scan visual that steps through exploded parts
|
||||||
- Broken-part detection by configured `nodeName`, with fallback to first scanned parts
|
- Broken-part detection by configured `nodeName`, with diagnostics when configured parts are missing
|
||||||
- Persistent broken-part highlight and broken-part prompt after discovery
|
- Persistent broken-part highlight and broken-part prompt after discovery
|
||||||
- Grabbable replacement part choices, including decoys
|
- Grabbable replacement part choices, including distractor parts
|
||||||
- Grabbable broken parts that must be deposited back into the case
|
- Grabbable broken parts that must be deposited back into the case
|
||||||
- Snap-to-placeholder placement
|
- Snap-to-placeholder placement
|
||||||
- Correct-part, wrong-part, and stored-part visual feedback
|
- Correct-part, wrong-part, and stored-part visual feedback
|
||||||
@@ -95,7 +95,7 @@ This document lists the user-visible and developer-facing features implemented i
|
|||||||
## Game Progression Store
|
## Game Progression Store
|
||||||
|
|
||||||
- Zustand `useGameStore` for durable gameplay progression
|
- Zustand `useGameStore` for durable gameplay progression
|
||||||
- Main states: `intro`, `bike`, `pylone`, `ferme`, `outro`
|
- Main states: `intro`, `ebike`, `pylon`, `farm`, `outro`
|
||||||
- Per-mission repair step state
|
- Per-mission repair step state
|
||||||
- Per-mission completion flags
|
- Per-mission completion flags
|
||||||
- Generic mission helpers: `setMissionStep`, `completeMission`, `advanceGameState`, `rewindGameState`, `resetGame`
|
- Generic mission helpers: `setMissionStep`, `completeMission`, `advanceGameState`, `rewindGameState`, `resetGame`
|
||||||
@@ -108,12 +108,11 @@ This document lists the user-visible and developer-facing features implemented i
|
|||||||
- Music, SFX, and dialogue volume sliders
|
- Music, SFX, and dialogue volume sliders
|
||||||
- Subtitle visibility toggle
|
- Subtitle visibility toggle
|
||||||
- Subtitle language choice between French and English
|
- Subtitle language choice between French and English
|
||||||
- Repair-runtime choice between JavaScript and Python modes stored in settings
|
|
||||||
- Quit action that clears browser-accessible cookies and returns to `/`
|
- Quit action that clears browser-accessible cookies and returns to `/`
|
||||||
- Crosshair overlay
|
- Crosshair overlay
|
||||||
- Interaction prompt
|
- Interaction prompt
|
||||||
- Subtitle overlay
|
- Subtitle overlay
|
||||||
- Repair movement-lock indicator component, currently inactive because the lock hook returns `false`
|
- Repair movement-lock indicator
|
||||||
- Debug overlay layout
|
- Debug overlay layout
|
||||||
- Scene loading overlay
|
- Scene loading overlay
|
||||||
|
|
||||||
@@ -192,7 +191,7 @@ This document lists the user-visible and developer-facing features implemented i
|
|||||||
- Debug game-state panel
|
- Debug game-state panel
|
||||||
- Debug hand-tracking panel
|
- Debug hand-tracking panel
|
||||||
- Physics test scene with floor, grabbable object, trigger object, repair zones, and animated model preview
|
- Physics test scene with floor, grabbable object, trigger object, repair zones, and animated model preview
|
||||||
- Animated `electricienne_animated` model preview restored in the debug physics scene
|
- Animated `electricienne-animated` model preview restored in the debug physics scene
|
||||||
|
|
||||||
## Map And Content Editor
|
## Map And Content Editor
|
||||||
|
|
||||||
@@ -230,7 +229,7 @@ This document lists the user-visible and developer-facing features implemented i
|
|||||||
- Technical docs for architecture, scene runtime, repair game, interaction, editor, audio, hand tracking, Zustand, Three debugging, animation, and target architecture
|
- Technical docs for architecture, scene runtime, repair game, interaction, editor, audio, hand tracking, Zustand, Three debugging, animation, and target architecture
|
||||||
- User docs for implemented features, main feature, editor usage, and code-review preparation
|
- User docs for implemented features, main feature, editor usage, and code-review preparation
|
||||||
|
|
||||||
## Not Implemented Or Incomplete
|
## Known Gaps
|
||||||
|
|
||||||
- Complete production mission manager/orchestrator
|
- Complete production mission manager/orchestrator
|
||||||
- Full mission HUD or minimap
|
- Full mission HUD or minimap
|
||||||
|
|||||||
+10
-10
@@ -8,7 +8,7 @@ The main feature is a reusable repair flow mounted in the production game scene.
|
|||||||
|
|
||||||
The current user flow is:
|
The current user flow is:
|
||||||
|
|
||||||
1. Enter a mission state such as `bike`, `pylone`, or `ferme`.
|
1. Enter a mission state such as `ebike`, `pylon`, or `farm`.
|
||||||
2. Move close to the active repair object in the game scene.
|
2. Move close to the active repair object in the game scene.
|
||||||
3. Aim at the object and press the interaction key when prompted.
|
3. Aim at the object and press the interaction key when prompted.
|
||||||
4. The mission step moves from `waiting` to `inspected`.
|
4. The mission step moves from `waiting` to `inspected`.
|
||||||
@@ -21,7 +21,7 @@ The current user flow is:
|
|||||||
11. Move each scanned broken part into a compatible placeholder so the damaged parts are stored in the case.
|
11. Move each scanned broken part into a compatible placeholder so the damaged parts are stored in the case.
|
||||||
12. Press `E` on the green install target to move to `reassembling`. Wrong parts turn the target red and cannot finish the repair.
|
12. Press `E` on the green install target to move to `reassembling`. Wrong parts turn the target red and cannot finish the repair.
|
||||||
13. The exploded object animates back into its assembled form with completion particles, then moves to `done`.
|
13. The exploded object animates back into its assembled form with completion particles, then moves to `done`.
|
||||||
14. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `ferme`.
|
14. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `farm`.
|
||||||
|
|
||||||
## Why It Matters
|
## Why It Matters
|
||||||
|
|
||||||
@@ -33,17 +33,17 @@ For implementation details, see `docs/technical/repair-game.md`.
|
|||||||
|
|
||||||
In `waiting`, the active mission renders its repair object and the `interagir.webm` prompt in the game scene. The interaction uses the shared focus/raycast interaction system, so the player still gets the normal `E` prompt.
|
In `waiting`, the active mission renders its repair object and the `interagir.webm` prompt in the game scene. The interaction uses the shared focus/raycast interaction system, so the player still gets the normal `E` prompt.
|
||||||
|
|
||||||
When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config with a small pop animation. When the player is close enough, the case model floats upward and rotates gently to signal interactivity. The codebase also contains a shared repair movement-lock hook and HTML indicator, but `useRepairMovementLocked()` currently returns `false`, so movement remains available during the repair flow on the current branch.
|
When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config with a small pop animation. When the player is close enough, the case model floats upward and rotates gently to signal interactivity. The shared repair movement-lock hook and HTML indicator keep movement disabled during active repair steps.
|
||||||
|
|
||||||
In `inspected`, `RepairGame` can also move to `fragmented`. Keyboard input goes through the shared focus/raycast interaction system on the repair case, so the player must be close enough and aim at the case before pressing `E`. The hand-tracking path still uses a two-fists hold gesture and is state-based, so it does not depend on being inside a local object interaction radius.
|
In `inspected`, `RepairGame` can also move to `fragmented`. Keyboard input goes through the shared focus/raycast interaction system on the repair case, so the player must be close enough and aim at the case before pressing `E`. The hand-tracking path still uses a two-fists hold gesture and is state-based, so it does not depend on being inside a local object interaction radius.
|
||||||
|
|
||||||
In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan can match a specific `nodeName` when mission data provides one, otherwise it falls back to the first scanned parts as placeholder broken parts. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those placeholder positions, and releasing a part near a placeholder snaps it into place with a short GSAP animation. Scanned broken parts are also rendered as grabbable objects and must be deposited into a compatible placeholder before the final install target validates. If `brokenParts[].placeholderName` is configured, that broken part snaps only to the matching placeholder; otherwise it can use any available placeholder. If the current case asset has no placeholder nodes, the flow keeps using fallback focus positions. Replacement parts show green or red placement feedback after snapping, broken parts show stored feedback after deposit, and the install target gives a short blocked feedback if the player tries to validate too early. The install target only validates when the configured correct replacement part is placed and all scanned broken parts have been deposited. In `reassembling`, the exploded model animates back into its assembled position with green completion particles before the flow moves to `done`. In `done`, the repaired object remains visible with a completion target; validating closes the repair case first, then plays the case exit animation before advancing the global mission progression.
|
In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan matches configured broken parts by `nodeName` and reports diagnostics when a configured node is missing. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those slot positions, and releasing a part near a slot snaps it into place with a short GSAP animation. Scanned broken parts are also rendered as grabbable objects and must be deposited into a compatible slot before the final install target validates. If `brokenParts[].caseSlotName` is configured, that broken part snaps only to the matching slot; otherwise it can use any available slot. If the current case asset has no slot nodes, the flow keeps using fallback focus positions and logs the fallback. Replacement parts show green or red placement feedback after snapping, broken parts show stored feedback after deposit, and the install target gives a short blocked feedback if the player tries to validate too early. The install target only validates when the configured correct replacement part is placed and all scanned broken parts have been deposited. In `reassembling`, the exploded model animates back into its assembled position with green completion particles before the flow moves to `done`. In `done`, the repaired object remains visible with a completion target; validating closes the repair case first, then plays the case exit animation before advancing the global mission progression.
|
||||||
|
|
||||||
The mission config now carries the mission-specific variations. `bike` repairs one cooling core, `pylone` scans and stores both the lamp relay and a damaged panel with slower scan/reassembly timing, and `ferme` scans and stores an irrigation pump plus humidity sensor with faster scan/reassembly timing.
|
The mission config now carries the mission-specific variations. `ebike` repairs one cooling core, `pylon` scans and stores both the lamp relay and a damaged panel with slower scan/reassembly timing, and `farm` scans and stores an irrigation pump plus humidity sensor with faster scan/reassembly timing.
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
- `src/world/GameStageContent.tsx` mounts production `RepairGame` instances for `bike`, `pylone`, and `ferme`.
|
- `src/world/GameStageContent.tsx` mounts production `RepairGame` instances for `ebike`, `pylon`, and `farm`.
|
||||||
- `src/components/three/gameplay/RepairCompletionStep.tsx` renders the final repaired object, completion target, case exit animation, and mission UI prompt.
|
- `src/components/three/gameplay/RepairCompletionStep.tsx` renders the final repaired object, completion target, case exit animation, and mission UI prompt.
|
||||||
- `src/components/three/gameplay/RepairGame.tsx` composes the reusable production repair flow.
|
- `src/components/three/gameplay/RepairGame.tsx` composes the reusable production repair flow.
|
||||||
- `src/components/three/gameplay/RepairBrokenPartHighlight.tsx` renders the red halo and wire marker around detected broken parts during scanning.
|
- `src/components/three/gameplay/RepairBrokenPartHighlight.tsx` renders the red halo and wire marker around detected broken parts during scanning.
|
||||||
@@ -56,16 +56,16 @@ The mission config now carries the mission-specific variations. `bike` repairs o
|
|||||||
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
|
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
|
||||||
- `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part.
|
- `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part.
|
||||||
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
|
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
|
||||||
- `src/components/ui/RepairMovementLockIndicator.tsx` renders the HTML indicator intended for repair movement lock.
|
- `src/components/ui/RepairMovementLockIndicator.tsx` renders the HTML repair movement-lock indicator.
|
||||||
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` two-fists input and can optionally bind keyboard input for non-trigger flows.
|
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` two-fists input and can optionally bind keyboard input for non-trigger flows.
|
||||||
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
|
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
|
||||||
- `src/hooks/gameplay/useRepairMovementLocked.ts` exposes the shared repair movement-lock rule used by the player controller and UI indicator, but currently returns `false`.
|
- `src/hooks/gameplay/useRepairMovementLocked.ts` exposes the shared repair movement-lock rule used by the player controller and UI indicator.
|
||||||
- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
|
- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
|
||||||
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them.
|
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them.
|
||||||
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
|
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
|
||||||
- `src/data/gameplay/repairCaseConfig.ts` stores repair case model, sound, and animation constants.
|
- `src/data/gameplay/repairCaseConfig.ts` stores repair case model, sound, and animation constants.
|
||||||
- `src/data/gameplay/repairGameConfig.ts` stores repair flow timing constants.
|
- `src/data/gameplay/repairGameConfig.ts` stores repair flow timing constants.
|
||||||
- `src/data/gameplay/repairMissions.ts` stores reusable repair mission config for `bike`, `pylone`, and `ferme`.
|
- `src/data/gameplay/repairMissions.ts` stores reusable repair mission config for `ebike`, `pylon`, and `farm`.
|
||||||
- `src/managers/stores/useGameStore.ts` stores mission progression state and generic mission step helpers.
|
- `src/managers/stores/useGameStore.ts` stores mission progression state and generic mission step helpers.
|
||||||
- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used by the store, data config, debug UI, and gameplay components.
|
- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used by the store, data config, debug UI, and gameplay components.
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ The mission config now carries the mission-specific variations. `bike` repairs o
|
|||||||
|
|
||||||
The production repair flow currently requires:
|
The production repair flow currently requires:
|
||||||
|
|
||||||
- the active `mainState` to be one of `bike`, `pylone`, or `ferme`
|
- the active `mainState` to be one of `ebike`, `pylon`, or `farm`
|
||||||
- `GameStageContent` mounted inside the game scene Rapier `Physics` boundary
|
- `GameStageContent` mounted inside the game scene Rapier `Physics` boundary
|
||||||
- model assets available under `public/models/`
|
- model assets available under `public/models/`
|
||||||
- sound assets available under `public/sounds/`
|
- sound assets available under `public/sounds/`
|
||||||
|
|||||||
Generated
+1
@@ -22,6 +22,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"three": "0.182.0",
|
"three": "0.182.0",
|
||||||
|
"three-stdlib": "^2.36.1",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
|
"map:transform": "node scripts/transformMap.cjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -b"
|
"typecheck": "tsc -b"
|
||||||
},
|
},
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"three": "0.182.0",
|
"three": "0.182.0",
|
||||||
|
"three-stdlib": "^2.36.1",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+2
-1
@@ -39565,7 +39565,8 @@
|
|||||||
"rotation": [0, 0.0027, 0.0819],
|
"rotation": [0, 0.0027, 0.0819],
|
||||||
"scale": [1, 1, 1]
|
"scale": [1, 1, 1]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"id": "repair:pylon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "pylone",
|
"name": "pylone",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+200
-26
@@ -25,10 +25,39 @@ const IDENTITY_NODE = {
|
|||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
scale: [1, 1, 1],
|
scale: [1, 1, 1],
|
||||||
};
|
};
|
||||||
|
const REPAIR_PYLON_ANCHOR_ID = "repair:pylon";
|
||||||
|
const REPAIR_PYLON_FALLBACK_POSITION = [64, 0, -66];
|
||||||
const MAX_MESH_Y_PLACEMENT_OFFSET = 2;
|
const MAX_MESH_Y_PLACEMENT_OFFSET = 2;
|
||||||
|
const RAW_INDEX = {
|
||||||
|
directionGroup: 5,
|
||||||
|
fermeGroup: 4798,
|
||||||
|
energieGroup: 4800,
|
||||||
|
lafabrikGroup: 4873,
|
||||||
|
ecoleGroup: 4895,
|
||||||
|
residenceZoneSources: [830, 874, 892],
|
||||||
|
};
|
||||||
|
const RAW_RANGES = {
|
||||||
|
directionPrimary: [6, 12],
|
||||||
|
residenceZone1: [831, 873],
|
||||||
|
residenceZone2: [875, 891],
|
||||||
|
residenceZone3: [893, 942],
|
||||||
|
residenceBikes: [14, 23],
|
||||||
|
residenceMailboxes: [25, 58],
|
||||||
|
energyPylones: [61, 96],
|
||||||
|
vegetationPrimary: [98, 829],
|
||||||
|
lakePipes: [944, 944],
|
||||||
|
fields: [946, 4594],
|
||||||
|
farm: [4595, 4799],
|
||||||
|
vegetationFarmArea: [4750, 4797],
|
||||||
|
energy: [4801, 4872],
|
||||||
|
lafabrik: [4874, 4894],
|
||||||
|
directionSecondary: [4896, 4897],
|
||||||
|
vegetationSecondary: [4898, 4997],
|
||||||
|
};
|
||||||
|
|
||||||
function cloneNode(node) {
|
function cloneNode(node) {
|
||||||
return {
|
return {
|
||||||
|
...(node.id ? { id: node.id } : {}),
|
||||||
name: node.name,
|
name: node.name,
|
||||||
type: node.type,
|
type: node.type,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
@@ -37,6 +66,60 @@ function cloneNode(node) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOriginPosition(position) {
|
||||||
|
return position.every((value) => Math.abs(value) < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDistinctPylonTransform(node) {
|
||||||
|
return (
|
||||||
|
node.rotation.some((value) => Math.abs(value) > 0.0001) ||
|
||||||
|
node.scale.some((value) => Math.abs(value - 1) > 0.0001)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToPosition(node, position) {
|
||||||
|
return Math.hypot(
|
||||||
|
node.position[0] - position[0],
|
||||||
|
node.position[2] - position[2],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMapNodes(root, predicate) {
|
||||||
|
const results = [];
|
||||||
|
const stack = [root];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const node = stack.pop();
|
||||||
|
if (predicate(node)) {
|
||||||
|
results.push(node);
|
||||||
|
}
|
||||||
|
stack.push(...(node.children ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignRepairPylonAnchorId(root) {
|
||||||
|
const pylones = collectMapNodes(
|
||||||
|
root,
|
||||||
|
(node) =>
|
||||||
|
node.name === "pylone" &&
|
||||||
|
node.type === "Object3D" &&
|
||||||
|
!isOriginPosition(node.position),
|
||||||
|
);
|
||||||
|
const distinctPylones = pylones.filter(hasDistinctPylonTransform);
|
||||||
|
const candidates = distinctPylones.length > 0 ? distinctPylones : pylones;
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
|
||||||
|
const anchor = [...candidates].sort(
|
||||||
|
(a, b) =>
|
||||||
|
distanceToPosition(a, REPAIR_PYLON_FALLBACK_POSITION) -
|
||||||
|
distanceToPosition(b, REPAIR_PYLON_FALLBACK_POSITION),
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
anchor.id = REPAIR_PYLON_ANCHOR_ID;
|
||||||
|
}
|
||||||
|
|
||||||
function createGroup(name, sourceNode) {
|
function createGroup(name, sourceNode) {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -69,6 +152,15 @@ function getOrCreateModelGroup(parent, modelName) {
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRequiredRawNode(rawData, index, label) {
|
||||||
|
const node = rawData[index];
|
||||||
|
if (!node) {
|
||||||
|
throw new Error(`Missing raw map node for ${label} at index ${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
function createRenderableObject(objectNode, meshNode) {
|
function createRenderableObject(objectNode, meshNode) {
|
||||||
const mappedMesh = mapMeshNode(meshNode);
|
const mappedMesh = mapMeshNode(meshNode);
|
||||||
const renderableNode = cloneNode(objectNode ?? meshNode);
|
const renderableNode = cloneNode(objectNode ?? meshNode);
|
||||||
@@ -177,28 +269,53 @@ function getNearestGroup(groups, node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createResidenceZones(rawData, residence) {
|
function createResidenceZones(rawData, residence) {
|
||||||
const zoneSources = [rawData[830], rawData[874], rawData[892]];
|
const zoneSources = RAW_INDEX.residenceZoneSources.map((index) =>
|
||||||
|
getRequiredRawNode(rawData, index, `residence zone ${index}`),
|
||||||
|
);
|
||||||
const zones = zoneSources.map((sourceNode, index) => {
|
const zones = zoneSources.map((sourceNode, index) => {
|
||||||
const zone = createGroup(`zone${index + 1}_residence`, sourceNode);
|
const zone = createGroup(`zone${index + 1}_residence`, sourceNode);
|
||||||
residence.children.push(zone);
|
residence.children.push(zone);
|
||||||
return zone;
|
return zone;
|
||||||
});
|
});
|
||||||
|
|
||||||
addBuildingsByRange(rawData, zones[0], 831, 873);
|
addBuildingsByRange(rawData, zones[0], ...RAW_RANGES.residenceZone1);
|
||||||
addBuildingsByRange(rawData, zones[1], 875, 891);
|
addBuildingsByRange(rawData, zones[1], ...RAW_RANGES.residenceZone2);
|
||||||
addBuildingsByRange(rawData, zones[2], 893, 942);
|
addBuildingsByRange(rawData, zones[2], ...RAW_RANGES.residenceZone3);
|
||||||
addObjectsByRange(rawData, zones[0], 831, 873, RESIDENCE_MESH_NAMES);
|
addObjectsByRange(
|
||||||
addObjectsByRange(rawData, zones[1], 875, 891, RESIDENCE_MESH_NAMES);
|
rawData,
|
||||||
addObjectsByRange(rawData, zones[2], 893, 942, RESIDENCE_MESH_NAMES);
|
zones[0],
|
||||||
|
...RAW_RANGES.residenceZone1,
|
||||||
|
RESIDENCE_MESH_NAMES,
|
||||||
|
);
|
||||||
|
addObjectsByRange(
|
||||||
|
rawData,
|
||||||
|
zones[1],
|
||||||
|
...RAW_RANGES.residenceZone2,
|
||||||
|
RESIDENCE_MESH_NAMES,
|
||||||
|
);
|
||||||
|
addObjectsByRange(
|
||||||
|
rawData,
|
||||||
|
zones[2],
|
||||||
|
...RAW_RANGES.residenceZone3,
|
||||||
|
RESIDENCE_MESH_NAMES,
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 14; i <= 23; i++) {
|
for (
|
||||||
|
let i = RAW_RANGES.residenceBikes[0];
|
||||||
|
i <= RAW_RANGES.residenceBikes[1];
|
||||||
|
i++
|
||||||
|
) {
|
||||||
const node = rawData[i];
|
const node = rawData[i];
|
||||||
if (node?.type === "Mesh" && node.name === "parcebike") {
|
if (node?.type === "Mesh" && node.name === "parcebike") {
|
||||||
addRenderable(getNearestGroup(zones, node), null, node);
|
addRenderable(getNearestGroup(zones, node), null, node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 25; i <= 58; i++) {
|
for (
|
||||||
|
let i = RAW_RANGES.residenceMailboxes[0];
|
||||||
|
i <= RAW_RANGES.residenceMailboxes[1];
|
||||||
|
i++
|
||||||
|
) {
|
||||||
const node = rawData[i];
|
const node = rawData[i];
|
||||||
if (node?.type === "Mesh" && node.name === "boitesauxlettres") {
|
if (node?.type === "Mesh" && node.name === "boitesauxlettres") {
|
||||||
addRenderable(getNearestGroup(zones, node), null, node);
|
addRenderable(getNearestGroup(zones, node), null, node);
|
||||||
@@ -266,12 +383,27 @@ function transformMap() {
|
|||||||
const vegetation = createGroup("vegetation");
|
const vegetation = createGroup("vegetation");
|
||||||
const agriculture = createGroup("agriculture");
|
const agriculture = createGroup("agriculture");
|
||||||
const champs = createGroup("champs");
|
const champs = createGroup("champs");
|
||||||
const ferme = createGroup("ferme", rawData[4798]);
|
const ferme = createGroup(
|
||||||
|
"ferme",
|
||||||
|
getRequiredRawNode(rawData, RAW_INDEX.fermeGroup, "ferme group"),
|
||||||
|
);
|
||||||
const residence = createGroup("residence");
|
const residence = createGroup("residence");
|
||||||
const energie = createGroup("energie", rawData[4800]);
|
const energie = createGroup(
|
||||||
const direction = createGroup("direction", rawData[5]);
|
"energie",
|
||||||
const lafabrik = createGroup("lafabrik", rawData[4873]);
|
getRequiredRawNode(rawData, RAW_INDEX.energieGroup, "energie group"),
|
||||||
const ecole = createGroup("ecole", rawData[4895]);
|
);
|
||||||
|
const direction = createGroup(
|
||||||
|
"direction",
|
||||||
|
getRequiredRawNode(rawData, RAW_INDEX.directionGroup, "direction group"),
|
||||||
|
);
|
||||||
|
const lafabrik = createGroup(
|
||||||
|
"lafabrik",
|
||||||
|
getRequiredRawNode(rawData, RAW_INDEX.lafabrikGroup, "lafabrik group"),
|
||||||
|
);
|
||||||
|
const ecole = createGroup(
|
||||||
|
"ecole",
|
||||||
|
getRequiredRawNode(rawData, RAW_INDEX.ecoleGroup, "ecole group"),
|
||||||
|
);
|
||||||
delete ecole.role;
|
delete ecole.role;
|
||||||
const unclassified = createGroup("unclassified");
|
const unclassified = createGroup("unclassified");
|
||||||
|
|
||||||
@@ -288,20 +420,60 @@ function transformMap() {
|
|||||||
);
|
);
|
||||||
agriculture.children.push(champs, ferme);
|
agriculture.children.push(champs, ferme);
|
||||||
|
|
||||||
addObjectsByRange(rawData, direction, 6, 12, DIRECTION_MESH_NAMES);
|
addObjectsByRange(
|
||||||
|
rawData,
|
||||||
|
direction,
|
||||||
|
...RAW_RANGES.directionPrimary,
|
||||||
|
DIRECTION_MESH_NAMES,
|
||||||
|
);
|
||||||
addStandaloneObject(rawData, residence, "ebike");
|
addStandaloneObject(rawData, residence, "ebike");
|
||||||
createResidenceZones(rawData, residence);
|
createResidenceZones(rawData, residence);
|
||||||
addObjectsByRange(rawData, energie, 61, 96, new Set(["pyloneelectrique"]));
|
addObjectsByRange(
|
||||||
addObjectsByRange(rawData, vegetation, 98, 829, VEGETATION_MESH_NAMES);
|
rawData,
|
||||||
addObjectsByRange(rawData, agriculture, 944, 944, new Set(["tuyauxlac"]));
|
energie,
|
||||||
addObjectsByRange(rawData, champs, 946, 4594, CHAMP_MESH_NAMES);
|
...RAW_RANGES.energyPylones,
|
||||||
addObjectsByRange(rawData, ferme, 4595, 4799, FERME_MESH_NAMES);
|
new Set(["pyloneelectrique"]),
|
||||||
addObjectsByRange(rawData, vegetation, 4750, 4797, VEGETATION_MESH_NAMES);
|
);
|
||||||
addObjectsByRange(rawData, energie, 4801, 4872, ENERGIE_MESH_NAMES);
|
addObjectsByRange(
|
||||||
addBuildingsByRange(rawData, lafabrik, 4874, 4894);
|
rawData,
|
||||||
addObjectsByRange(rawData, lafabrik, 4874, 4894, LAFABRIK_MESH_NAMES);
|
vegetation,
|
||||||
addObjectsByRange(rawData, direction, 4896, 4897, DIRECTION_MESH_NAMES);
|
...RAW_RANGES.vegetationPrimary,
|
||||||
addObjectsByRange(rawData, vegetation, 4898, 4997, VEGETATION_MESH_NAMES);
|
VEGETATION_MESH_NAMES,
|
||||||
|
);
|
||||||
|
addObjectsByRange(
|
||||||
|
rawData,
|
||||||
|
agriculture,
|
||||||
|
...RAW_RANGES.lakePipes,
|
||||||
|
new Set(["tuyauxlac"]),
|
||||||
|
);
|
||||||
|
addObjectsByRange(rawData, champs, ...RAW_RANGES.fields, CHAMP_MESH_NAMES);
|
||||||
|
addObjectsByRange(rawData, ferme, ...RAW_RANGES.farm, FERME_MESH_NAMES);
|
||||||
|
addObjectsByRange(
|
||||||
|
rawData,
|
||||||
|
vegetation,
|
||||||
|
...RAW_RANGES.vegetationFarmArea,
|
||||||
|
VEGETATION_MESH_NAMES,
|
||||||
|
);
|
||||||
|
addObjectsByRange(rawData, energie, ...RAW_RANGES.energy, ENERGIE_MESH_NAMES);
|
||||||
|
addBuildingsByRange(rawData, lafabrik, ...RAW_RANGES.lafabrik);
|
||||||
|
addObjectsByRange(
|
||||||
|
rawData,
|
||||||
|
lafabrik,
|
||||||
|
...RAW_RANGES.lafabrik,
|
||||||
|
LAFABRIK_MESH_NAMES,
|
||||||
|
);
|
||||||
|
addObjectsByRange(
|
||||||
|
rawData,
|
||||||
|
direction,
|
||||||
|
...RAW_RANGES.directionSecondary,
|
||||||
|
DIRECTION_MESH_NAMES,
|
||||||
|
);
|
||||||
|
addObjectsByRange(
|
||||||
|
rawData,
|
||||||
|
vegetation,
|
||||||
|
...RAW_RANGES.vegetationSecondary,
|
||||||
|
VEGETATION_MESH_NAMES,
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 0; i < rawData.length; i++) {
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
const node = rawData[i];
|
const node = rawData[i];
|
||||||
@@ -319,6 +491,8 @@ function transformMap() {
|
|||||||
blocking.children.push(unclassified);
|
blocking.children.push(unclassified);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assignRepairPylonAnchorId(scene);
|
||||||
|
|
||||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(scene, null, 2));
|
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(scene, null, 2));
|
||||||
console.log(`Written hierarchical map to ${OUTPUT_PATH}`);
|
console.log(`Written hierarchical map to ${OUTPUT_PATH}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ interface DocsDocumentProps {
|
|||||||
title: string;
|
title: string;
|
||||||
meta: string;
|
meta: string;
|
||||||
content: string;
|
content: string;
|
||||||
frContent: string;
|
frContent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocsDocument({
|
export function DocsDocument({
|
||||||
title,
|
title,
|
||||||
meta,
|
meta,
|
||||||
content,
|
content,
|
||||||
frContent,
|
frContent = content,
|
||||||
}: DocsDocumentProps): React.JSX.Element {
|
}: DocsDocumentProps): React.JSX.Element {
|
||||||
const { language, toggleLanguage } = useDocsLanguage();
|
const { language, toggleLanguage } = useDocsLanguage();
|
||||||
const hasAlternateContent = frContent !== content;
|
const hasAlternateContent = frContent !== content;
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useEffect, useRef, useState, useMemo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||||
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
|
||||||
|
|
||||||
|
export interface CameraTransform {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
rotation: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||||
|
position: [-3.5, 6, 0],
|
||||||
|
rotation: [-10, -90, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||||
|
position: [0, 1.5, -3],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EbikeProps {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
|
||||||
|
scope: "Ebike",
|
||||||
|
position: position,
|
||||||
|
});
|
||||||
|
const model = useClonedObject(scene);
|
||||||
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
|
// Map active mainState to target repair zone coordinate
|
||||||
|
const destPos = useMemo(() => {
|
||||||
|
switch (mainState) {
|
||||||
|
case "ebike":
|
||||||
|
return { x: 8, y: 0, z: -6 };
|
||||||
|
case "pylon":
|
||||||
|
return { x: 64, y: 0, z: -66 };
|
||||||
|
case "farm":
|
||||||
|
return { x: -24, y: 0, z: 42 };
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [mainState]);
|
||||||
|
|
||||||
|
// Throttled GPS start position to optimize pathfinding A* algorithm execution
|
||||||
|
const [gpsStartPos, setGpsStartPos] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}>({
|
||||||
|
x: position[0],
|
||||||
|
y: position[1],
|
||||||
|
z: position[2],
|
||||||
|
});
|
||||||
|
const lastGpsUpdatePos = useRef<THREE.Vector3>(
|
||||||
|
new THREE.Vector3(...position),
|
||||||
|
);
|
||||||
|
|
||||||
|
const restingPosition = useRef<Vector3Tuple>([
|
||||||
|
position[0],
|
||||||
|
position[1] - PLAYER_EYE_HEIGHT,
|
||||||
|
position[2],
|
||||||
|
]);
|
||||||
|
const restingRotation = useRef<number>(0);
|
||||||
|
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (model) {
|
||||||
|
const fork = model.getObjectByName("fourche");
|
||||||
|
if (fork) {
|
||||||
|
forkRef.current = fork;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).ebikeVisualGroup = groupRef;
|
||||||
|
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||||
|
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||||
|
return () => {
|
||||||
|
(window as any).ebikeVisualGroup = null;
|
||||||
|
(window as any).ebikeParkedPosition = null;
|
||||||
|
(window as any).ebikeParkedRotation = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
if (movementMode === "ebike") {
|
||||||
|
restingPosition.current = [
|
||||||
|
groupRef.current.position.x,
|
||||||
|
groupRef.current.position.y,
|
||||||
|
groupRef.current.position.z,
|
||||||
|
];
|
||||||
|
restingRotation.current = groupRef.current.rotation.y;
|
||||||
|
|
||||||
|
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
|
||||||
|
const steerFactor = (window as any).ebikeSteerFactor || 0;
|
||||||
|
if (forkRef.current) {
|
||||||
|
// 15 degrees is 0.26 radians
|
||||||
|
const targetForkRotation = steerFactor * 0.26;
|
||||||
|
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||||
|
forkRef.current.rotation.z,
|
||||||
|
targetForkRotation,
|
||||||
|
12 * delta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttled GPS start position update to prevent performance loss
|
||||||
|
const currentPos = groupRef.current.position;
|
||||||
|
if (currentPos.distanceTo(lastGpsUpdatePos.current) > 2.0) {
|
||||||
|
lastGpsUpdatePos.current.copy(currentPos);
|
||||||
|
setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
groupRef.current.position.set(...restingPosition.current);
|
||||||
|
groupRef.current.rotation.set(0, restingRotation.current, 0);
|
||||||
|
|
||||||
|
// Reset fork rotation when parked
|
||||||
|
if (forkRef.current) {
|
||||||
|
forkRef.current.rotation.z = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||||
|
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const camPointPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0],
|
||||||
|
restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1],
|
||||||
|
restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
const dropPointPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||||
|
restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
|
restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleInteract = (): void => {
|
||||||
|
if (movementMode === "walk") {
|
||||||
|
const cameraOffset = new THREE.Vector3(
|
||||||
|
...EBIKE_CAMERA_TRANSFORM.position,
|
||||||
|
);
|
||||||
|
cameraOffset.applyAxisAngle(
|
||||||
|
new THREE.Vector3(0, 1, 0),
|
||||||
|
restingRotation.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetCamPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + cameraOffset.x,
|
||||||
|
restingPosition.current[1] + cameraOffset.y,
|
||||||
|
restingPosition.current[2] + cameraOffset.z,
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetRotation: Vector3Tuple = [
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[1] +
|
||||||
|
THREE.MathUtils.radToDeg(restingRotation.current),
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||||
|
useGameStore.getState().setPlayerMovementMode("ebike");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const currentPos = new THREE.Vector3();
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.getWorldPosition(currentPos);
|
||||||
|
} else {
|
||||||
|
currentPos.set(...position);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCamPos: Vector3Tuple = [
|
||||||
|
currentPos.x + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||||
|
currentPos.y + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
|
currentPos.z + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get camera's current rotation in degrees so we keep the exact orientation during dismount
|
||||||
|
const currentEuler = new THREE.Euler().setFromQuaternion(
|
||||||
|
camera.quaternion,
|
||||||
|
"YXZ",
|
||||||
|
);
|
||||||
|
const targetRotation: Vector3Tuple = [
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.x),
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.y),
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.z),
|
||||||
|
];
|
||||||
|
|
||||||
|
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||||
|
useGameStore.getState().setPlayerMovementMode("walk");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInteractRef = useRef(handleInteract);
|
||||||
|
handleInteractRef.current = handleInteract;
|
||||||
|
|
||||||
|
const debugRef = useRef({ showCameraPoints: true });
|
||||||
|
const debugActions = useRef({
|
||||||
|
toggleRide: () => {
|
||||||
|
handleInteractRef.current();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useDebugFolder("Ebike", (folder) => {
|
||||||
|
folder
|
||||||
|
.add(debugRef.current, "showCameraPoints")
|
||||||
|
.name("Show Camera Points")
|
||||||
|
.onChange((value: boolean) => {
|
||||||
|
debugRef.current.showCameraPoints = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
folder.add(debugActions.current, "toggleRide").name("Monter / Descendre");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<group ref={groupRef} position={position}>
|
||||||
|
<primitive object={model} />
|
||||||
|
<InteractableObject
|
||||||
|
kind="trigger"
|
||||||
|
label={
|
||||||
|
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
|
||||||
|
}
|
||||||
|
position={position}
|
||||||
|
radius={15}
|
||||||
|
onPress={handleInteract}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[10, 13, 2]} />
|
||||||
|
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</InteractableObject>
|
||||||
|
|
||||||
|
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||||
|
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||||
|
<EbikeGPSMap
|
||||||
|
width={0.8}
|
||||||
|
height={0.8}
|
||||||
|
startPos={gpsStartPos}
|
||||||
|
destPos={destPos}
|
||||||
|
mapImageUrl="/assets/gps/map_background.png"
|
||||||
|
worldBounds={{
|
||||||
|
minX: -166,
|
||||||
|
maxX: 163,
|
||||||
|
minZ: -142,
|
||||||
|
maxZ: 138,
|
||||||
|
}}
|
||||||
|
zoom={4}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{debugRef.current.showCameraPoints && (
|
||||||
|
<>
|
||||||
|
<mesh position={camPointPos}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="yellow"
|
||||||
|
emissive="yellow"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh position={dropPointPos}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="cyan"
|
||||||
|
emissive="cyan"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useMemo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import {
|
||||||
|
findClosestWaypoint,
|
||||||
|
findWaypointPath,
|
||||||
|
} from "@/pathfinding/WaypointAStar";
|
||||||
|
import type { Waypoint } from "@/pathfinding/types";
|
||||||
|
function computeImageSource(
|
||||||
|
img: HTMLImageElement | HTMLCanvasElement,
|
||||||
|
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||||
|
bounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||||
|
) {
|
||||||
|
const imgW = img.width;
|
||||||
|
const imgH = img.height;
|
||||||
|
|
||||||
|
const baseW = baseBounds.maxX - baseBounds.minX;
|
||||||
|
const baseH = baseBounds.maxZ - baseBounds.minZ;
|
||||||
|
|
||||||
|
if (baseW === 0 || baseH === 0) {
|
||||||
|
return { sx: 0, sy: 0, sW: imgW, sH: imgH };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sx = ((bounds.minX - baseBounds.minX) / baseW) * imgW;
|
||||||
|
const sy = ((bounds.minZ - baseBounds.minZ) / baseH) * imgH;
|
||||||
|
const sW = ((bounds.maxX - bounds.minX) / baseW) * imgW;
|
||||||
|
const sH = ((bounds.maxZ - bounds.minZ) / baseH) * imgH;
|
||||||
|
|
||||||
|
return { sx, sy, sW, sH };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EbikeGPSMapProps {
|
||||||
|
/**
|
||||||
|
* 3D world position of the player/bike (GPS start point)
|
||||||
|
* If omitted, snaps to [0,0,0]
|
||||||
|
*/
|
||||||
|
startPos?: { x: number; y: number; z: number } | undefined;
|
||||||
|
destPos?: { x: number; y: number; z: number } | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional custom URL to the map background texture.
|
||||||
|
* If not provided, renders a high-tech minimalist neon blueprint map dynamically.
|
||||||
|
*/
|
||||||
|
mapImageUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional explicit bounds for mapping coordinates.
|
||||||
|
* If omitted, bounds are calculated automatically to perfectly fit the road network!
|
||||||
|
*/
|
||||||
|
worldBounds?: {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minZ: number;
|
||||||
|
maxZ: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the 3D plane mesh (default: 1)
|
||||||
|
*/
|
||||||
|
width?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of the 3D plane mesh (default: 1)
|
||||||
|
*/
|
||||||
|
height?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional world position for the GPS screen (defaults to origin)
|
||||||
|
*/
|
||||||
|
position?: [number, number, number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolution of the offscreen canvas used for the map texture.
|
||||||
|
* Higher values yield sharper rendering at the cost of GPU memory.
|
||||||
|
* Default: 1024 (1024×1024 px)
|
||||||
|
*/
|
||||||
|
canvasSize?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom level applied to the map view.
|
||||||
|
* 1 = full world bounds, 2 = 2× zoom-in centred on the player, etc.
|
||||||
|
* Values < 1 zoom out beyond the calculated world bounds.
|
||||||
|
* Default: 1
|
||||||
|
*/
|
||||||
|
zoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EbikeGPSMap
|
||||||
|
* A premium, state-of-the-art 3D GPS navigation screen for the Ebike.
|
||||||
|
* Loads the road network, runs A* pathfinding, and renders a glowing, animated
|
||||||
|
* orange path over a sleek high-tech map background.
|
||||||
|
*/
|
||||||
|
export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||||
|
startPos = { x: 0, y: 0, z: 0 },
|
||||||
|
destPos,
|
||||||
|
mapImageUrl,
|
||||||
|
worldBounds,
|
||||||
|
width = 1,
|
||||||
|
height = 1,
|
||||||
|
position = [0, 0, 0],
|
||||||
|
canvasSize = 1024,
|
||||||
|
zoom = 1,
|
||||||
|
}) => {
|
||||||
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
|
const [mapImage, setMapImage] = useState<
|
||||||
|
HTMLImageElement | HTMLCanvasElement | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Offscreen high-res canvas for crystal clear rendering
|
||||||
|
const [offscreenCanvas] = useState(() => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = canvasSize;
|
||||||
|
canvas.height = canvasSize;
|
||||||
|
return canvas;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize the canvas whenever canvasSize changes
|
||||||
|
useEffect(() => {
|
||||||
|
offscreenCanvas.width = canvasSize;
|
||||||
|
offscreenCanvas.height = canvasSize;
|
||||||
|
if (textureRef.current) {
|
||||||
|
textureRef.current.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}, [canvasSize, offscreenCanvas]);
|
||||||
|
|
||||||
|
const textureRef = useRef<THREE.CanvasTexture | null>(null);
|
||||||
|
const animTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Load waypoints (localStorage with /roadNetwork.json fallback)
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("la-fabrik-waypoints");
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
setWaypoints(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"[GPS Component] Error loading local storage waypoints",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to static roadNetwork.json
|
||||||
|
fetch("/roadNetwork.json")
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) return res.json();
|
||||||
|
throw new Error("Not found");
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setWaypoints(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log("[GPS Component] No default road network found.", err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Pre-load background map image (standard HTML5 Image loader)
|
||||||
|
// Since the user's PNG is already transparent, we don't need fetch or pixel manipulation!
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapImageUrl) {
|
||||||
|
setMapImage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
setMapImage(img);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.warn(
|
||||||
|
`[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`,
|
||||||
|
);
|
||||||
|
setMapImage(null);
|
||||||
|
};
|
||||||
|
img.src = mapImageUrl;
|
||||||
|
}, [mapImageUrl]);
|
||||||
|
|
||||||
|
// Determine grid boundaries (before zoom)
|
||||||
|
const baseBounds = useMemo(() => {
|
||||||
|
if (worldBounds) return worldBounds;
|
||||||
|
|
||||||
|
if (waypoints.length === 0) {
|
||||||
|
return { minX: -200, maxX: 200, minZ: -200, maxZ: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const xs = waypoints.map((w) => w.x);
|
||||||
|
const zs = waypoints.map((w) => w.z);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
const minZ = Math.min(...zs);
|
||||||
|
const maxZ = Math.max(...zs);
|
||||||
|
|
||||||
|
// Padding (15% to ensure full view breathing room)
|
||||||
|
const padX = (maxX - minX) * 0.15 || 40;
|
||||||
|
const padZ = (maxZ - minZ) * 0.15 || 40;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: minX - padX,
|
||||||
|
maxX: maxX + padX,
|
||||||
|
minZ: minZ - padZ,
|
||||||
|
maxZ: maxZ + padZ,
|
||||||
|
};
|
||||||
|
}, [waypoints, worldBounds]);
|
||||||
|
|
||||||
|
// Apply zoom: shrink the view window around the player position
|
||||||
|
const bounds = useMemo(() => {
|
||||||
|
const clampedZoom = Math.max(0.1, zoom);
|
||||||
|
if (clampedZoom === 1) return baseBounds;
|
||||||
|
|
||||||
|
const centerX = startPos.x;
|
||||||
|
const centerZ = startPos.z;
|
||||||
|
const halfW = (baseBounds.maxX - baseBounds.minX) / 2 / clampedZoom;
|
||||||
|
const halfH = (baseBounds.maxZ - baseBounds.minZ) / 2 / clampedZoom;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: centerX - halfW,
|
||||||
|
maxX: centerX + halfW,
|
||||||
|
minZ: centerZ - halfH,
|
||||||
|
maxZ: centerZ + halfH,
|
||||||
|
};
|
||||||
|
}, [baseBounds, zoom, startPos]);
|
||||||
|
|
||||||
|
// Snapped positions
|
||||||
|
const startPosSnapped = useMemo(() => {
|
||||||
|
if (waypoints.length === 0) return null;
|
||||||
|
return findClosestWaypoint(waypoints, startPos);
|
||||||
|
}, [waypoints, startPos]);
|
||||||
|
|
||||||
|
const destPosSnapped = useMemo(() => {
|
||||||
|
if (!destPos || waypoints.length === 0) return null;
|
||||||
|
return findClosestWaypoint(waypoints, destPos);
|
||||||
|
}, [waypoints, destPos]);
|
||||||
|
|
||||||
|
// Calculated active A* route
|
||||||
|
const activePath = useMemo(() => {
|
||||||
|
if (!startPosSnapped || !destPosSnapped || waypoints.length === 0)
|
||||||
|
return [];
|
||||||
|
return findWaypointPath(waypoints, startPosSnapped, destPosSnapped);
|
||||||
|
}, [waypoints, startPosSnapped, destPosSnapped]);
|
||||||
|
|
||||||
|
// Translation helper: 3D world to Canvas pixels
|
||||||
|
const worldToCanvas = (wx: number, wz: number, canvasSize: number) => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = bounds;
|
||||||
|
const px = ((wx - minX) / (maxX - minX)) * canvasSize;
|
||||||
|
const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize;
|
||||||
|
return { x: px, y: py };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw loop
|
||||||
|
const draw = () => {
|
||||||
|
const canvas = offscreenCanvas;
|
||||||
|
const ctx = canvas.getContext("2d", {
|
||||||
|
willReadFrequently: true,
|
||||||
|
alpha: true,
|
||||||
|
});
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const size = canvas.width;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// 1. Draw Map Background (Image or premium blueprint vectors)
|
||||||
|
if (mapImage) {
|
||||||
|
const src = computeImageSource(mapImage, baseBounds, bounds);
|
||||||
|
const sx = Math.max(0, Math.min(mapImage.width, src.sx));
|
||||||
|
const sy = Math.max(0, Math.min(mapImage.height, src.sy));
|
||||||
|
const sW = Math.max(1, Math.min(mapImage.width - sx, src.sW));
|
||||||
|
const sH = Math.max(1, Math.min(mapImage.height - sy, src.sH));
|
||||||
|
|
||||||
|
ctx.drawImage(mapImage, sx, sy, sW, sH, 0, 0, size, size);
|
||||||
|
ctx.globalAlpha = 1.0;
|
||||||
|
} else {
|
||||||
|
// Dynamic Sci-fi background grid (Background is transparent!)
|
||||||
|
|
||||||
|
// Sci-fi subgrid
|
||||||
|
ctx.strokeStyle = "rgba(30, 41, 59, 0.4)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const step = size / 32;
|
||||||
|
for (let x = 0; x < size; x += step) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, size);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 0; y < size; y += step) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(size, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aesthetic concentric radar topo-rings
|
||||||
|
ctx.strokeStyle = "rgba(71, 85, 105, 0.06)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let r = size / 6; r < size; r += size / 6) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(size / 2, size / 2, r, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faint diagonal technical accents
|
||||||
|
ctx.strokeStyle = "rgba(56, 189, 248, 0.03)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(size, size);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(size, 0);
|
||||||
|
ctx.lineTo(0, size);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Draw Active Orange Glowing Path (Neon Highway effect)
|
||||||
|
if (activePath.length > 1) {
|
||||||
|
// Pass 1: Wide transparent orange bloom
|
||||||
|
ctx.beginPath();
|
||||||
|
let pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||||
|
ctx.moveTo(pt.x, pt.y);
|
||||||
|
for (let i = 1; i < activePath.length; i++) {
|
||||||
|
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = "rgba(249, 115, 22, 0.2)"; // Faint bright orange
|
||||||
|
ctx.lineWidth = 20;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.shadowBlur = 30;
|
||||||
|
ctx.shadowColor = "#f97316"; // Neon Orange
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pass 2: Saturated glow core
|
||||||
|
ctx.beginPath();
|
||||||
|
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||||
|
ctx.moveTo(pt.x, pt.y);
|
||||||
|
for (let i = 1; i < activePath.length; i++) {
|
||||||
|
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = "#f97316"; // Vibrant orange
|
||||||
|
ctx.lineWidth = 8;
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.shadowColor = "#ea580c";
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pass 3: High-intensity white core
|
||||||
|
ctx.beginPath();
|
||||||
|
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||||
|
ctx.moveTo(pt.x, pt.y);
|
||||||
|
for (let i = 1; i < activePath.length; i++) {
|
||||||
|
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = "#fff7ed"; // Cream white
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.shadowBlur = 0; // Turn off shadows for the core
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 3. Energy Particle Pulse animation tracing the road
|
||||||
|
const segments: {
|
||||||
|
start: { x: number; y: number };
|
||||||
|
end: { x: number; y: number };
|
||||||
|
len: number;
|
||||||
|
}[] = [];
|
||||||
|
let totalLen = 0;
|
||||||
|
for (let i = 0; i < activePath.length - 1; i++) {
|
||||||
|
const p1 = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
const p2 = worldToCanvas(
|
||||||
|
activePath[i + 1]!.x,
|
||||||
|
activePath[i + 1]!.z,
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
const len = Math.sqrt(
|
||||||
|
Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2),
|
||||||
|
);
|
||||||
|
segments.push({ start: p1, end: p2, len });
|
||||||
|
totalLen += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalLen > 0) {
|
||||||
|
const targetLen = totalLen * animTimeRef.current;
|
||||||
|
let currentLen = 0;
|
||||||
|
let dotPt = segments[0]!.start;
|
||||||
|
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (currentLen + seg.len >= targetLen) {
|
||||||
|
const ratio = (targetLen - currentLen) / seg.len;
|
||||||
|
dotPt = {
|
||||||
|
x: seg.start.x + (seg.end.x - seg.start.x) * ratio,
|
||||||
|
y: seg.start.y + (seg.end.y - seg.start.y) * ratio,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentLen += seg.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw multiple glowing pulses along the path
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(dotPt.x, dotPt.y, 8, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.shadowBlur = 15;
|
||||||
|
ctx.shadowColor = "#f97316";
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Snap Markers (Start and End)
|
||||||
|
if (destPosSnapped) {
|
||||||
|
const pt = worldToCanvas(destPosSnapped.x, destPosSnapped.z, size);
|
||||||
|
const pulseSize = 12 + Math.sin(Date.now() * 0.007) * 4;
|
||||||
|
|
||||||
|
// Pulse ring
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, pulseSize, 0, 2 * Math.PI);
|
||||||
|
ctx.strokeStyle = "rgba(249, 115, 22, 0.4)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Solid target core
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, 6, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = "#ea580c"; // Deep target orange
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startPosSnapped) {
|
||||||
|
const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size);
|
||||||
|
|
||||||
|
// Start Marker (Player Arrow/Dot)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = "#0ea5e9"; // Cool cyberpunk sky blue
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Tech details
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, 3, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update WebGL Texture
|
||||||
|
if (textureRef.current) {
|
||||||
|
textureRef.current.needsUpdate = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 60 FPS animation ticker
|
||||||
|
useEffect(() => {
|
||||||
|
let animId: number;
|
||||||
|
const tick = () => {
|
||||||
|
animTimeRef.current += 0.004; // Slow, premium sweep speed
|
||||||
|
if (animTimeRef.current > 1) animTimeRef.current = 0;
|
||||||
|
|
||||||
|
draw();
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
animId = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(animId);
|
||||||
|
}, [waypoints, startPos, destPos, bounds, mapImage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh castShadow receiveShadow position={position as any}>
|
||||||
|
<planeGeometry args={[width, height]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
toneMapped={false}
|
||||||
|
transparent={true}
|
||||||
|
opacity={1}
|
||||||
|
depthWrite={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
>
|
||||||
|
<canvasTexture
|
||||||
|
ref={textureRef}
|
||||||
|
attach="map"
|
||||||
|
image={offscreenCanvas}
|
||||||
|
format={THREE.RGBAFormat}
|
||||||
|
minFilter={THREE.LinearFilter}
|
||||||
|
magFilter={THREE.LinearFilter}
|
||||||
|
/>
|
||||||
|
</meshBasicMaterial>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Unlock,
|
Unlock,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
||||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||||
@@ -41,6 +42,7 @@ interface EditorControlsProps {
|
|||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
snapToTerrain: boolean;
|
snapToTerrain: boolean;
|
||||||
onSnapToTerrainToggle: () => void;
|
onSnapToTerrainToggle: () => void;
|
||||||
|
onSnapAllToTerrain: () => void;
|
||||||
newNodeName: string;
|
newNodeName: string;
|
||||||
onNewNodeNameChange: (value: string) => void;
|
onNewNodeNameChange: (value: string) => void;
|
||||||
onAddNode: () => void;
|
onAddNode: () => void;
|
||||||
@@ -70,7 +72,7 @@ const EDITOR_SHORTCUTS = [
|
|||||||
["Shift + Right click", "Toggle multi-selection"],
|
["Shift + Right click", "Toggle multi-selection"],
|
||||||
["T / R / S", "Transform mode"],
|
["T / R / S", "Transform mode"],
|
||||||
["Ctrl Z / Y", "Undo / redo"],
|
["Ctrl Z / Y", "Undo / redo"],
|
||||||
["Esc", "Deselect"],
|
["Esc / X button", "Clear selection"],
|
||||||
["WASD", "Move when locked"],
|
["WASD", "Move when locked"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -101,6 +103,52 @@ function EditorPanelGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EditorScaleFieldProps {
|
||||||
|
axis: 0 | 1 | 2;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onCommit: (axis: 0 | 1 | 2, value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorScaleField({
|
||||||
|
axis,
|
||||||
|
label,
|
||||||
|
onCommit,
|
||||||
|
value,
|
||||||
|
}: EditorScaleFieldProps): React.JSX.Element {
|
||||||
|
const [draftValue, setDraftValue] = useState(() =>
|
||||||
|
String(Number(value.toFixed(4))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitDraftValue = (): void => {
|
||||||
|
const nextValue = Number(draftValue);
|
||||||
|
if (!draftValue.trim() || Number.isNaN(nextValue)) {
|
||||||
|
setDraftValue(String(Number(value.toFixed(4))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommit(axis, nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<span>{label}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={draftValue}
|
||||||
|
onBlur={commitDraftValue}
|
||||||
|
onChange={(event) => setDraftValue(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EditorControls({
|
export function EditorControls({
|
||||||
transformMode,
|
transformMode,
|
||||||
onTransformModeChange,
|
onTransformModeChange,
|
||||||
@@ -117,6 +165,7 @@ export function EditorControls({
|
|||||||
onClearSelection,
|
onClearSelection,
|
||||||
snapToTerrain,
|
snapToTerrain,
|
||||||
onSnapToTerrainToggle,
|
onSnapToTerrainToggle,
|
||||||
|
onSnapAllToTerrain,
|
||||||
newNodeName,
|
newNodeName,
|
||||||
onNewNodeNameChange,
|
onNewNodeNameChange,
|
||||||
onAddNode,
|
onAddNode,
|
||||||
@@ -228,6 +277,15 @@ export function EditorControls({
|
|||||||
/>
|
/>
|
||||||
<span>Snap terrain on move</span>
|
<span>Snap terrain on move</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="editor-history-button"
|
||||||
|
onClick={onSnapAllToTerrain}
|
||||||
|
>
|
||||||
|
<ScanSearch size={15} aria-hidden="true" />
|
||||||
|
Snap all to terrain
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -292,20 +350,13 @@ export function EditorControls({
|
|||||||
{selectedNodeScale ? (
|
{selectedNodeScale ? (
|
||||||
<div className="editor-scale-fields">
|
<div className="editor-scale-fields">
|
||||||
{selectedNodeScale.map((value, axis) => (
|
{selectedNodeScale.map((value, axis) => (
|
||||||
<label key={axis}>
|
<EditorScaleField
|
||||||
<span>{["X", "Y", "Z"][axis]}</span>
|
key={`${axis}:${value}`}
|
||||||
<input
|
axis={axis as 0 | 1 | 2}
|
||||||
type="number"
|
label={["X", "Y", "Z"][axis] ?? "?"}
|
||||||
step="0.01"
|
value={value}
|
||||||
value={Number(value.toFixed(4))}
|
onCommit={onSelectedScaleChange}
|
||||||
onChange={(event) =>
|
|
||||||
onSelectedScaleChange(
|
|
||||||
axis as 0 | 1 | 2,
|
|
||||||
Number(event.target.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -445,11 +445,14 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
Voix
|
Voix
|
||||||
<select
|
<select
|
||||||
value={selectedDialogue.voice}
|
value={selectedDialogue.voice}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
updateSelectedDialogue({
|
const selectedVoice = voices.find(
|
||||||
voice: event.target.value as DialogueVoiceId,
|
(voice) => voice.id === event.target.value,
|
||||||
})
|
);
|
||||||
}
|
if (!selectedVoice) return;
|
||||||
|
|
||||||
|
updateSelectedDialogue({ voice: selectedVoice.id });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{voices.map((voice) => (
|
{voices.map((voice) => (
|
||||||
<option key={voice.id} value={voice.id}>
|
<option key={voice.id} value={voice.id}>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Download, RefreshCw, Save } from "lucide-react";
|
import { Download, RefreshCw, Save } from "lucide-react";
|
||||||
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
|
import type { SubtitleLanguage } from "@/types/settings/settings";
|
||||||
import type {
|
import type {
|
||||||
DialogueDefinition,
|
DialogueDefinition,
|
||||||
DialogueManifest,
|
DialogueManifest,
|
||||||
DialogueSpeaker,
|
DialogueSpeaker,
|
||||||
DialogueVoiceId,
|
DialogueVoiceId,
|
||||||
} from "@/types/dialogues/dialogues";
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
import {
|
||||||
|
parseSrt,
|
||||||
|
parseSrtTime,
|
||||||
|
parseSrtWithDiagnostics,
|
||||||
|
} from "@/utils/subtitles/parseSrt";
|
||||||
|
|
||||||
interface SrtVoiceOption {
|
interface SrtVoiceOption {
|
||||||
id: DialogueVoiceId;
|
id: DialogueVoiceId;
|
||||||
@@ -88,21 +93,6 @@ function formatPreviewTime(totalSeconds: number): string {
|
|||||||
return `${Math.max(0, totalSeconds).toFixed(1)}s`;
|
return `${Math.max(0, totalSeconds).toFixed(1)}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSrtTime(value: string): number | null {
|
|
||||||
const match = value.match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const [, hours, minutes, seconds, milliseconds] = match;
|
|
||||||
if (!hours || !minutes || !seconds || !milliseconds) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
Number(hours) * 3600 +
|
|
||||||
Number(minutes) * 60 +
|
|
||||||
Number(seconds) +
|
|
||||||
Number(milliseconds) / 1000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function padTime(value: number): string {
|
function padTime(value: number): string {
|
||||||
return value.toString().padStart(2, "0");
|
return value.toString().padStart(2, "0");
|
||||||
}
|
}
|
||||||
@@ -120,7 +110,7 @@ function getSrtDiagnostic(
|
|||||||
.trim()
|
.trim()
|
||||||
.split(/\n{2,}/)
|
.split(/\n{2,}/)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const cues = parseSrt(content);
|
const { cues, diagnostics } = parseSrtWithDiagnostics(content);
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const indexes = new Set<number>();
|
const indexes = new Set<number>();
|
||||||
|
|
||||||
@@ -164,6 +154,10 @@ function getSrtDiagnostic(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const diagnostic of diagnostics) {
|
||||||
|
errors.push(`Bloc ${diagnostic.blockIndex + 1}: ${diagnostic.reason}.`);
|
||||||
|
}
|
||||||
|
|
||||||
const cueIndexes = new Set(cues.map((cue) => cue.index));
|
const cueIndexes = new Set(cues.map((cue) => cue.index));
|
||||||
const missingCueIndexes = expectedCueIndexes.filter(
|
const missingCueIndexes = expectedCueIndexes.filter(
|
||||||
(cueIndex) => !cueIndexes.has(cueIndex),
|
(cueIndex) => !cueIndexes.has(cueIndex),
|
||||||
@@ -470,8 +464,14 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
.then((loadedManifest) => {
|
.then((loadedManifest) => {
|
||||||
if (mounted) setManifest(loadedManifest);
|
if (mounted) setManifest(loadedManifest);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
if (mounted) setManifest(null);
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setManifest(null);
|
||||||
|
setStatus("Erreur de chargement du manifeste dialogues");
|
||||||
|
logger.error("EditorSrt", "Failed to load dialogue manifest", {
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -496,10 +496,16 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
setContent(await response.text());
|
setContent(await response.text());
|
||||||
setStatus(`Charge depuis ${srtPath}`);
|
setStatus(`Charge depuis ${srtPath}`);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error: unknown) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setContent(srtTemplate);
|
setContent(srtTemplate);
|
||||||
setStatus("Erreur de chargement, template local cree");
|
setStatus(
|
||||||
|
`Erreur de chargement, template local cree: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||||
|
);
|
||||||
|
logger.warn("EditorSrt", "Falling back to local SRT template", {
|
||||||
|
srtPath,
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -519,9 +525,14 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
Voix
|
Voix
|
||||||
<select
|
<select
|
||||||
value={voice}
|
value={voice}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setVoice(event.target.value as DialogueVoiceId)
|
const selectedVoice = SRT_VOICES.find(
|
||||||
|
(item) => item.id === event.target.value,
|
||||||
|
);
|
||||||
|
if (selectedVoice) {
|
||||||
|
setVoice(selectedVoice.id);
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{SRT_VOICES.map((item) => (
|
{SRT_VOICES.map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
@@ -535,9 +546,14 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
Langue
|
Langue
|
||||||
<select
|
<select
|
||||||
value={language}
|
value={language}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setLanguage(event.target.value as SubtitleLanguage)
|
const selectedLanguage = SRT_LANGUAGES.find(
|
||||||
|
(item) => item === event.target.value,
|
||||||
|
);
|
||||||
|
if (selectedLanguage) {
|
||||||
|
setLanguage(selectedLanguage);
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{SRT_LANGUAGES.map((item) => (
|
{SRT_LANGUAGES.map((item) => (
|
||||||
<option key={item} value={item}>
|
<option key={item} value={item}>
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
import {
|
||||||
import { Grid, TransformControls } from "@react-three/drei";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
Suspense,
|
||||||
|
} from "react";
|
||||||
|
import { TransformControls } from "@react-three/drei";
|
||||||
import type { ThreeEvent } from "@react-three/fiber";
|
import type { ThreeEvent } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
import {
|
||||||
|
getObjectBottomOffset,
|
||||||
|
useTerrainHeightSampler,
|
||||||
|
} from "@/hooks/three/useTerrainHeight";
|
||||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
||||||
import {
|
import {
|
||||||
isEditorVisibleMapNode,
|
isEditorVisibleMapNode,
|
||||||
getTerrainMapNode,
|
getTerrainMapNode,
|
||||||
} from "@/utils/map/mapRuntimeClassification";
|
} from "@/utils/map/mapRuntimeClassification";
|
||||||
|
import { getMapModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
|
||||||
|
import { getVegetationModelScaleMultiplier } from "@/data/world/vegetationConfig";
|
||||||
|
|
||||||
interface EditorMapProps {
|
interface EditorMapProps {
|
||||||
sceneData: SceneData;
|
sceneData: SceneData;
|
||||||
@@ -28,6 +40,8 @@ interface EditorMapProps {
|
|||||||
onTransformStart: () => void;
|
onTransformStart: () => void;
|
||||||
onTransformEnd: () => void;
|
onTransformEnd: () => void;
|
||||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||||
|
snapAllToTerrainRequest: number;
|
||||||
|
onSnapAllToTerrain: (mapNodes: MapNode[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditorNodeObjectRef = React.RefObject<Map<number, THREE.Object3D>>;
|
type EditorNodeObjectRef = React.RefObject<Map<number, THREE.Object3D>>;
|
||||||
@@ -64,6 +78,56 @@ const TEMP_POSITION = new THREE.Vector3();
|
|||||||
const TEMP_QUATERNION = new THREE.Quaternion();
|
const TEMP_QUATERNION = new THREE.Quaternion();
|
||||||
const TEMP_SCALE = new THREE.Vector3();
|
const TEMP_SCALE = new THREE.Vector3();
|
||||||
|
|
||||||
|
function isOriginPosition(position: MapNode["position"]): boolean {
|
||||||
|
return position.every((value) => Math.abs(value) < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSnapAllCandidate(node: MapNode): boolean {
|
||||||
|
return (
|
||||||
|
isEditorVisibleMapNode(node) &&
|
||||||
|
node.name !== "terrain" &&
|
||||||
|
!isOriginPosition(node.position)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRenderEditorNode(
|
||||||
|
node: MapNode,
|
||||||
|
selectedNodeName: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (!isEditorVisibleMapNode(node)) return false;
|
||||||
|
return selectedNodeName === null || node.name === selectedNodeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditorModelVisualScaleMultiplier(name: string): number {
|
||||||
|
return (
|
||||||
|
getMapModelScaleMultiplier(name) * getVegetationModelScaleMultiplier(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditorModelVisualYOffset(
|
||||||
|
object: THREE.Object3D,
|
||||||
|
node: MapNode,
|
||||||
|
terrainHeight: ReturnType<typeof useTerrainHeightSampler>,
|
||||||
|
visualScaleMultiplier: number,
|
||||||
|
): number {
|
||||||
|
const [x, y, z] = node.position;
|
||||||
|
const height = terrainHeight.getHeight(x, z);
|
||||||
|
if (height === null) return 0;
|
||||||
|
|
||||||
|
const finalScale: [number, number, number] = [
|
||||||
|
node.scale[0] * visualScaleMultiplier,
|
||||||
|
node.scale[1] * visualScaleMultiplier,
|
||||||
|
node.scale[2] * visualScaleMultiplier,
|
||||||
|
];
|
||||||
|
const originalPosition = object.position.clone();
|
||||||
|
object.position.set(0, 0, 0);
|
||||||
|
const bottomOffset = getObjectBottomOffset(object, finalScale);
|
||||||
|
object.position.copy(originalPosition);
|
||||||
|
const parentScaleY = Math.abs(node.scale[1]) > 0.0001 ? node.scale[1] : 1;
|
||||||
|
|
||||||
|
return (height + bottomOffset - y) / parentScaleY;
|
||||||
|
}
|
||||||
|
|
||||||
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
|
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
|
||||||
object.position.set(...node.position);
|
object.position.set(...node.position);
|
||||||
object.rotation.set(...node.rotation);
|
object.rotation.set(...node.rotation);
|
||||||
@@ -177,15 +241,21 @@ export function EditorMap({
|
|||||||
onTransformStart,
|
onTransformStart,
|
||||||
onTransformEnd,
|
onTransformEnd,
|
||||||
onNodeTransform,
|
onNodeTransform,
|
||||||
|
snapAllToTerrainRequest,
|
||||||
|
onSnapAllToTerrain,
|
||||||
}: EditorMapProps): React.JSX.Element {
|
}: EditorMapProps): React.JSX.Element {
|
||||||
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
||||||
const transformGroupRef = useRef<THREE.Group>(null);
|
const transformGroupRef = useRef<THREE.Group>(null);
|
||||||
const transformSnapshotRef = useRef<TransformSnapshot | null>(null);
|
const transformSnapshotRef = useRef<TransformSnapshot | null>(null);
|
||||||
const terrainHeight = useTerrainHeightSampler();
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
|
const lastSnapAllToTerrainRequestRef = useRef(0);
|
||||||
|
|
||||||
const selectedIndexSet = new Set(selectedNodeIndexes);
|
const selectedIndexSet = new Set(selectedNodeIndexes);
|
||||||
const isMultiSelection = selectedNodeIndexes.length > 1;
|
const isMultiSelection = selectedNodeIndexes.length > 1;
|
||||||
|
const selectedNodeName =
|
||||||
|
selectedNodeIndex !== null
|
||||||
|
? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null)
|
||||||
|
: null;
|
||||||
const getTransformObject = useCallback(() => {
|
const getTransformObject = useCallback(() => {
|
||||||
if (isMultiSelection) {
|
if (isMultiSelection) {
|
||||||
return transformGroupRef.current;
|
return transformGroupRef.current;
|
||||||
@@ -333,29 +403,46 @@ export function EditorMap({
|
|||||||
prepareTransformGroup();
|
prepareTransformGroup();
|
||||||
}, [prepareTransformGroup]);
|
}, [prepareTransformGroup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
snapAllToTerrainRequest === 0 ||
|
||||||
|
snapAllToTerrainRequest === lastSnapAllToTerrainRequestRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSnapAllToTerrainRequestRef.current = snapAllToTerrainRequest;
|
||||||
|
|
||||||
|
const snappedNodes = sceneData.mapNodes.map((node) => {
|
||||||
|
if (!isSnapAllCandidate(node)) return node;
|
||||||
|
|
||||||
|
const [x, y, z] = node.position;
|
||||||
|
const terrainY = terrainHeight.getHeight(x, z);
|
||||||
|
if (terrainY === null || Math.abs(terrainY - y) < 0.0001) return node;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: [x, terrainY, z] satisfies [number, number, number],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onSnapAllToTerrain(snappedNodes);
|
||||||
|
}, [
|
||||||
|
onSnapAllToTerrain,
|
||||||
|
sceneData.mapNodes,
|
||||||
|
snapAllToTerrainRequest,
|
||||||
|
terrainHeight,
|
||||||
|
]);
|
||||||
|
|
||||||
// TransformControls needs the current Three object; editor refs are managed outside React rendering.
|
// TransformControls needs the current Three object; editor refs are managed outside React rendering.
|
||||||
// eslint-disable-next-line react-hooks/refs
|
// eslint-disable-next-line react-hooks/refs
|
||||||
const selectedObject = getTransformObject();
|
const selectedObject = getTransformObject();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Grid
|
|
||||||
args={[100, 100]}
|
|
||||||
cellSize={1}
|
|
||||||
cellThickness={0.5}
|
|
||||||
cellColor="#242424"
|
|
||||||
sectionSize={5}
|
|
||||||
sectionThickness={1}
|
|
||||||
sectionColor="#3a3a3a"
|
|
||||||
fadeDistance={50}
|
|
||||||
fadeStrength={1}
|
|
||||||
followCamera={false}
|
|
||||||
infiniteGrid={false}
|
|
||||||
/>
|
|
||||||
<axesHelper args={[10]} />
|
|
||||||
|
|
||||||
<group>
|
<group>
|
||||||
{terrainNode ? (
|
{terrainNode ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<EditorTerrainNode
|
<EditorTerrainNode
|
||||||
index={terrainNodeIndex}
|
index={terrainNodeIndex}
|
||||||
node={terrainNode}
|
node={terrainNode}
|
||||||
@@ -368,9 +455,10 @@ export function EditorMap({
|
|||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
{sceneData.mapNodes.map((node, index) => {
|
{sceneData.mapNodes.map((node, index) => {
|
||||||
if (!isEditorVisibleMapNode(node)) {
|
if (!shouldRenderEditorNode(node, selectedNodeName)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,8 +466,23 @@ export function EditorMap({
|
|||||||
|
|
||||||
if (modelUrl) {
|
if (modelUrl) {
|
||||||
return (
|
return (
|
||||||
<EditorModelNode
|
<Suspense
|
||||||
key={index}
|
key={index}
|
||||||
|
fallback={
|
||||||
|
<EditorFallbackNode
|
||||||
|
index={index}
|
||||||
|
node={node}
|
||||||
|
isSelected={selectedIndexSet.has(index)}
|
||||||
|
isHovered={hoveredNodeIndex === index}
|
||||||
|
objectsMapRef={objectsMapRef}
|
||||||
|
onSelectNode={onSelectNode}
|
||||||
|
onToggleNodeSelection={onToggleNodeSelection}
|
||||||
|
isSelectionLocked={isSelectionLocked}
|
||||||
|
onHoverNode={onHoverNode}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EditorModelNode
|
||||||
index={index}
|
index={index}
|
||||||
node={node}
|
node={node}
|
||||||
modelUrl={modelUrl}
|
modelUrl={modelUrl}
|
||||||
@@ -391,6 +494,7 @@ export function EditorMap({
|
|||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
@@ -451,6 +555,18 @@ function EditorModelNode({
|
|||||||
scale: node.scale,
|
scale: node.scale,
|
||||||
});
|
});
|
||||||
const sceneInstance = useClonedObject(scene);
|
const sceneInstance = useClonedObject(scene);
|
||||||
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
|
const visualScaleMultiplier = getEditorModelVisualScaleMultiplier(node.name);
|
||||||
|
const visualYOffset = useMemo(
|
||||||
|
() =>
|
||||||
|
getEditorModelVisualYOffset(
|
||||||
|
sceneInstance,
|
||||||
|
node,
|
||||||
|
terrainHeight,
|
||||||
|
visualScaleMultiplier,
|
||||||
|
),
|
||||||
|
[node, sceneInstance, terrainHeight, visualScaleMultiplier],
|
||||||
|
);
|
||||||
const pointerHandlers = createEditorNodePointerHandlers(
|
const pointerHandlers = createEditorNodePointerHandlers(
|
||||||
index,
|
index,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
@@ -512,14 +628,19 @@ function EditorModelNode({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<primitive
|
<group
|
||||||
ref={groupRef}
|
ref={groupRef}
|
||||||
object={sceneInstance}
|
|
||||||
position={node.position}
|
position={node.position}
|
||||||
rotation={node.rotation}
|
rotation={node.rotation}
|
||||||
scale={node.scale}
|
scale={node.scale}
|
||||||
{...pointerHandlers}
|
{...pointerHandlers}
|
||||||
|
>
|
||||||
|
<primitive
|
||||||
|
object={sceneInstance}
|
||||||
|
position={[0, visualYOffset, 0]}
|
||||||
|
scale={visualScaleMultiplier}
|
||||||
/>
|
/>
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { Suspense, useCallback, useEffect, useRef } from "react";
|
||||||
import { OrbitControls } from "@react-three/drei";
|
import { Grid, OrbitControls } from "@react-three/drei";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||||
import { FlyController } from "@/controls/editor/FlyController";
|
import { FlyController } from "@/controls/editor/FlyController";
|
||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
import type {
|
||||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
EditorCinematicPreviewRequest,
|
||||||
|
MapNode,
|
||||||
|
TransformMode,
|
||||||
|
SceneData,
|
||||||
|
} from "@/types/editor/editor";
|
||||||
|
|
||||||
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
|
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
|
||||||
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
|
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
|
||||||
|
|
||||||
export interface EditorCinematicPreviewRequest {
|
function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
||||||
id: string;
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
cinematic: CinematicDefinition;
|
|
||||||
|
return (
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement ||
|
||||||
|
target.isContentEditable
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorSceneProps {
|
interface EditorSceneProps {
|
||||||
@@ -33,6 +43,8 @@ interface EditorSceneProps {
|
|||||||
onTransformStart: () => void;
|
onTransformStart: () => void;
|
||||||
onTransformEnd: () => void;
|
onTransformEnd: () => void;
|
||||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||||
|
snapAllToTerrainRequest: number;
|
||||||
|
onSnapAllToTerrain: (mapNodes: MapNode[]) => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
resetCameraRequest: number;
|
resetCameraRequest: number;
|
||||||
@@ -58,6 +70,8 @@ export function EditorScene({
|
|||||||
onTransformStart,
|
onTransformStart,
|
||||||
onTransformEnd,
|
onTransformEnd,
|
||||||
onNodeTransform,
|
onNodeTransform,
|
||||||
|
snapAllToTerrainRequest,
|
||||||
|
onSnapAllToTerrain,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
resetCameraRequest,
|
resetCameraRequest,
|
||||||
@@ -144,6 +158,8 @@ export function EditorScene({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (isEditableShortcutTarget(e.target)) return;
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
if (e.key === "z" || e.key === "Z") {
|
if (e.key === "z" || e.key === "Z") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -209,6 +225,22 @@ export function EditorScene({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
args={[100, 100]}
|
||||||
|
cellSize={1}
|
||||||
|
cellThickness={0.5}
|
||||||
|
cellColor="#242424"
|
||||||
|
sectionSize={5}
|
||||||
|
sectionThickness={1}
|
||||||
|
sectionColor="#3a3a3a"
|
||||||
|
fadeDistance={50}
|
||||||
|
fadeStrength={1}
|
||||||
|
followCamera={false}
|
||||||
|
infiniteGrid={false}
|
||||||
|
/>
|
||||||
|
<axesHelper args={[10]} />
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
<EditorMap
|
<EditorMap
|
||||||
sceneData={sceneData}
|
sceneData={sceneData}
|
||||||
selectedNodeIndex={selectedNodeIndex}
|
selectedNodeIndex={selectedNodeIndex}
|
||||||
@@ -224,7 +256,10 @@ export function EditorScene({
|
|||||||
onTransformStart={onTransformStart}
|
onTransformStart={onTransformStart}
|
||||||
onTransformEnd={onTransformEnd}
|
onTransformEnd={onTransformEnd}
|
||||||
onNodeTransform={onNodeTransform}
|
onNodeTransform={onNodeTransform}
|
||||||
|
snapAllToTerrainRequest={snapAllToTerrainRequest}
|
||||||
|
onSnapAllToTerrain={onSnapAllToTerrain}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function GameFlow(): null {
|
|||||||
const setStep = useGameStore((state) => state.setIntroStep);
|
const setStep = useGameStore((state) => state.setIntroStep);
|
||||||
const setActivityCity = useGameStore((state) => state.setActivityCity);
|
const setActivityCity = useGameStore((state) => state.setActivityCity);
|
||||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||||
|
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||||
const hasInitialized = useRef(false);
|
const hasInitialized = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,10 +56,17 @@ export function GameFlow(): null {
|
|||||||
|
|
||||||
if (step === "manipulation") {
|
if (step === "manipulation") {
|
||||||
setCanMove(false);
|
setCanMove(false);
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
completeIntro();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [step, setStep, setActivityCity, setCanMove]);
|
}, [completeIntro, step, setStep, setActivityCity, setCanMove]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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) => {
|
||||||
|
const timeUniform = materialRef.current?.uniforms.uTime;
|
||||||
|
if (timeUniform) timeUniform.value += delta;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh position={[0, 2, -3]} rotation={[0, 0, 0]}>
|
||||||
|
<planeGeometry args={[2, 2, 1, 1]} />
|
||||||
|
<primitive
|
||||||
|
object={createNetShader()}
|
||||||
|
ref={materialRef}
|
||||||
|
attach="material"
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase
|
|||||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||||
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
|
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
|
||||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
interface RepairCompletionStepProps {
|
interface RepairCompletionStepProps {
|
||||||
config: RepairMissionConfig;
|
config: RepairMissionConfig;
|
||||||
|
|||||||
@@ -7,21 +7,18 @@ import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspec
|
|||||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
||||||
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
|
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
|
||||||
import {
|
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
|
||||||
RepairScanSequence,
|
|
||||||
type RepairScannedBrokenPart,
|
|
||||||
} from "@/components/three/gameplay/RepairScanSequence";
|
|
||||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||||
import {
|
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||||
REPAIR_MISSIONS,
|
|
||||||
type RepairMissionConfig,
|
|
||||||
} from "@/data/gameplay/repairMissions";
|
|
||||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||||
|
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||||
import type {
|
import type {
|
||||||
MissionStep,
|
MissionStep,
|
||||||
|
RepairMissionConfig,
|
||||||
RepairMissionId,
|
RepairMissionId,
|
||||||
|
RepairScannedBrokenPart,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||||
@@ -70,6 +67,7 @@ export function RepairGame({
|
|||||||
readonly RepairScannedBrokenPart[]
|
readonly RepairScannedBrokenPart[]
|
||||||
>([]);
|
>([]);
|
||||||
const parsedScale = toVector3Scale(scale);
|
const parsedScale = toVector3Scale(scale);
|
||||||
|
const snappedPosition = useTerrainSnappedPosition(position);
|
||||||
const readyForFragmentation = step === "inspected";
|
const readyForFragmentation = step === "inspected";
|
||||||
|
|
||||||
useRepairFragmentationInput({
|
useRepairFragmentationInput({
|
||||||
@@ -109,7 +107,7 @@ export function RepairGame({
|
|||||||
if (step === "locked") return null;
|
if (step === "locked") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
<group position={snappedPosition} rotation={rotation} scale={parsedScale}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<RepairMissionAssetPreloader config={config} />
|
<RepairMissionAssetPreloader config={config} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -117,7 +115,7 @@ export function RepairGame({
|
|||||||
{step === "waiting" ? (
|
{step === "waiting" ? (
|
||||||
<RepairInspectionObject
|
<RepairInspectionObject
|
||||||
config={config}
|
config={config}
|
||||||
worldPosition={position}
|
worldPosition={snappedPosition}
|
||||||
onInspect={() => setMissionStep(mission, "inspected")}
|
onInspect={() => setMissionStep(mission, "inspected")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
|
|||||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
interface RepairInspectionObjectProps {
|
interface RepairInspectionObjectProps {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
REPAIR_CASE_MODEL_PATH,
|
REPAIR_CASE_MODEL_PATH,
|
||||||
} from "@/data/gameplay/repairCaseConfig";
|
} from "@/data/gameplay/repairCaseConfig";
|
||||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
interface RepairMissionCaseProps {
|
interface RepairMissionCaseProps {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
|
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
|
||||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||||
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
|
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
interface RepairReassemblyStepProps {
|
interface RepairReassemblyStepProps {
|
||||||
config: RepairMissionConfig;
|
config: RepairMissionConfig;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import * as THREE from "three";
|
|||||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||||
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
|
|
||||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +14,9 @@ import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
|||||||
import type {
|
import type {
|
||||||
RepairMissionConfig,
|
RepairMissionConfig,
|
||||||
RepairMissionPartConfig,
|
RepairMissionPartConfig,
|
||||||
} from "@/data/gameplay/repairMissions";
|
RepairScannedBrokenPart,
|
||||||
|
} from "@/types/gameplay/repairMission";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
|
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
|
||||||
@@ -34,6 +35,7 @@ const REPAIR_INSTALL_RADIUS = 1.1;
|
|||||||
const VALID_PART_COLOR = "#22c55e";
|
const VALID_PART_COLOR = "#22c55e";
|
||||||
const INVALID_PART_COLOR = "#ef4444";
|
const INVALID_PART_COLOR = "#ef4444";
|
||||||
const STORED_BROKEN_PART_COLOR = "#38bdf8";
|
const STORED_BROKEN_PART_COLOR = "#38bdf8";
|
||||||
|
let hasWarnedMissingPlaceholders = false;
|
||||||
|
|
||||||
interface RepairRepairingStepProps {
|
interface RepairRepairingStepProps {
|
||||||
brokenParts: readonly RepairScannedBrokenPart[];
|
brokenParts: readonly RepairScannedBrokenPart[];
|
||||||
@@ -400,6 +402,14 @@ function getPlaceholderTargets(
|
|||||||
return placeholders;
|
return placeholders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasWarnedMissingPlaceholders) {
|
||||||
|
hasWarnedMissingPlaceholders = true;
|
||||||
|
logger.warn(
|
||||||
|
"RepairGame",
|
||||||
|
"Repair case placeholders missing, using fallback slots",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return FALLBACK_PLACEHOLDER_OFFSETS.map(
|
return FALLBACK_PLACEHOLDER_OFFSETS.map(
|
||||||
(offset, index): RepairCasePlaceholder => ({
|
(offset, index): RepairCasePlaceholder => ({
|
||||||
name: `placeholder_${index + 1}`,
|
name: `placeholder_${index + 1}`,
|
||||||
@@ -416,12 +426,12 @@ function getBrokenPartTargetPositions(
|
|||||||
part: RepairScannedBrokenPart,
|
part: RepairScannedBrokenPart,
|
||||||
placeholderTargets: readonly RepairCasePlaceholder[],
|
placeholderTargets: readonly RepairCasePlaceholder[],
|
||||||
): readonly Vector3Tuple[] {
|
): readonly Vector3Tuple[] {
|
||||||
if (!part.placeholderName) {
|
if (!part.caseSlotName) {
|
||||||
return placeholderTargets.map((placeholder) => placeholder.position);
|
return placeholderTargets.map((placeholder) => placeholder.position);
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingPlaceholder = placeholderTargets.find(
|
const matchingPlaceholder = placeholderTargets.find(
|
||||||
(placeholder) => placeholder.name === part.placeholderName,
|
(placeholder) => placeholder.name === part.caseSlotName,
|
||||||
);
|
);
|
||||||
|
|
||||||
return matchingPlaceholder
|
return matchingPlaceholder
|
||||||
@@ -475,6 +485,6 @@ function getBrokenPartsToDeposit(
|
|||||||
id: part.id,
|
id: part.id,
|
||||||
label: part.label,
|
label: part.label,
|
||||||
modelPath: part.modelPath ?? config.modelPath,
|
modelPath: part.modelPath ?? config.modelPath,
|
||||||
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
|
...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
|
|||||||
import type {
|
import type {
|
||||||
RepairMissionConfig,
|
RepairMissionConfig,
|
||||||
RepairMissionPartConfig,
|
RepairMissionPartConfig,
|
||||||
} from "@/data/gameplay/repairMissions";
|
RepairScannedBrokenPart,
|
||||||
|
} from "@/types/gameplay/repairMission";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||||
|
|
||||||
interface RepairScanSequenceProps {
|
interface RepairScanSequenceProps {
|
||||||
@@ -16,13 +18,13 @@ interface RepairScanSequenceProps {
|
|||||||
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
|
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepairScannedBrokenPart {
|
interface RepairBrokenPartMatch {
|
||||||
id: string;
|
config: RepairMissionPartConfig;
|
||||||
label: string;
|
partIndex: number;
|
||||||
modelPath: string;
|
|
||||||
placeholderName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warnedMissingScanParts = new Set<string>();
|
||||||
|
|
||||||
export function RepairScanSequence({
|
export function RepairScanSequence({
|
||||||
config,
|
config,
|
||||||
onComplete,
|
onComplete,
|
||||||
@@ -31,9 +33,9 @@ export function RepairScanSequence({
|
|||||||
const [activePartIndex, setActivePartIndex] = useState(0);
|
const [activePartIndex, setActivePartIndex] = useState(0);
|
||||||
const activePart = parts[activePartIndex];
|
const activePart = parts[activePartIndex];
|
||||||
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
|
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
|
||||||
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
const brokenPartMatches = getBrokenPartMatches(parts, config);
|
||||||
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
|
const visibleBrokenPartMatches = brokenPartMatches.filter(
|
||||||
(partIndex) => partIndex <= activePartIndex,
|
(match) => match.partIndex <= activePartIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,8 +67,8 @@ export function RepairScanSequence({
|
|||||||
onPartsReady={setParts}
|
onPartsReady={setParts}
|
||||||
/>
|
/>
|
||||||
<RepairScanVisual target={activePart?.object} />
|
<RepairScanVisual target={activePart?.object} />
|
||||||
{visibleBrokenPartIndexes.map((partIndex) => {
|
{visibleBrokenPartMatches.map((match) => {
|
||||||
const part = parts[partIndex];
|
const part = parts[match.partIndex];
|
||||||
if (!part) return null;
|
if (!part) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,29 +89,25 @@ function getScannedBrokenParts(
|
|||||||
parts: readonly ExplodedPart[],
|
parts: readonly ExplodedPart[],
|
||||||
config: RepairMissionConfig,
|
config: RepairMissionConfig,
|
||||||
): readonly RepairScannedBrokenPart[] {
|
): readonly RepairScannedBrokenPart[] {
|
||||||
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
return getBrokenPartMatches(parts, config).map((match) => {
|
||||||
|
|
||||||
return brokenPartIndexes.map((_, index) => {
|
|
||||||
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
|
id: match.config.id,
|
||||||
label: configuredPart?.label ?? `${config.label} broken part`,
|
label: match.config.label,
|
||||||
modelPath: configuredPart?.modelPath ?? config.modelPath,
|
modelPath: match.config.modelPath ?? config.modelPath,
|
||||||
...(configuredPart?.placeholderName
|
...(match.config.caseSlotName
|
||||||
? { placeholderName: configuredPart.placeholderName }
|
? { caseSlotName: match.config.caseSlotName }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBrokenPartIndexes(
|
function getBrokenPartMatches(
|
||||||
parts: readonly ExplodedPart[],
|
parts: readonly ExplodedPart[],
|
||||||
brokenParts: readonly RepairMissionPartConfig[],
|
config: RepairMissionConfig,
|
||||||
): number[] {
|
): RepairBrokenPartMatch[] {
|
||||||
if (parts.length === 0 || brokenParts.length === 0) return [];
|
if (parts.length === 0 || config.brokenParts.length === 0) return [];
|
||||||
|
|
||||||
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
|
const matches = config.brokenParts.flatMap((brokenPart) => {
|
||||||
const { nodeName } = brokenPart;
|
const { nodeName } = brokenPart;
|
||||||
if (!nodeName) return [];
|
if (!nodeName) return [];
|
||||||
|
|
||||||
@@ -117,12 +115,30 @@ function getBrokenPartIndexes(
|
|||||||
objectContainsNodeName(part.object, nodeName),
|
objectContainsNodeName(part.object, nodeName),
|
||||||
);
|
);
|
||||||
|
|
||||||
return index >= 0 ? [index] : [];
|
return index >= 0 ? [{ config: brokenPart, partIndex: index }] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
|
if (matches.length !== config.brokenParts.length) {
|
||||||
|
const matchedIds = new Set(matches.map((match) => match.config.id));
|
||||||
|
const missingIds = config.brokenParts
|
||||||
|
.filter((brokenPart) => !matchedIds.has(brokenPart.id))
|
||||||
|
.map((brokenPart) => brokenPart.id);
|
||||||
|
|
||||||
return parts.slice(0, brokenParts.length).map((_, index) => index);
|
const warningKey = `${config.id}:${missingIds.join(",")}`;
|
||||||
|
if (!warnedMissingScanParts.has(warningKey)) {
|
||||||
|
warnedMissingScanParts.add(warningKey);
|
||||||
|
logger.warn("RepairScan", "Broken parts missing from exploded model", {
|
||||||
|
missionId: config.id,
|
||||||
|
missingIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.filter(
|
||||||
|
(match, index, allMatches) =>
|
||||||
|
allMatches.findIndex((item) => item.partIndex === match.partIndex) ===
|
||||||
|
index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function objectContainsNodeName(
|
function objectContainsNodeName(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Component, useEffect, useMemo, useRef } from "react";
|
|||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { clone } from "three/addons/utils/SkeletonUtils.js";
|
import { SkeletonUtils } from "three-stdlib";
|
||||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||||
import {
|
import {
|
||||||
useHandTrackingGloveStatus,
|
useHandTrackingGloveStatus,
|
||||||
@@ -255,7 +255,7 @@ function HandTrackingGloveModel({
|
|||||||
throw new Error(`Missing glove root node ${config.rootNodeName}`);
|
throw new Error(`Missing glove root node ${config.rootNodeName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedRootNode = clone(rootNode);
|
const clonedRootNode = SkeletonUtils.clone(rootNode);
|
||||||
clonedRootNode.visible = false;
|
clonedRootNode.visible = false;
|
||||||
|
|
||||||
return clonedRootNode;
|
return clonedRootNode;
|
||||||
|
|||||||
@@ -41,8 +41,27 @@ type InteractableObjectProps =
|
|||||||
const _cameraPos = new THREE.Vector3();
|
const _cameraPos = new THREE.Vector3();
|
||||||
const _cameraDir = new THREE.Vector3();
|
const _cameraDir = new THREE.Vector3();
|
||||||
const _objectPos = new THREE.Vector3();
|
const _objectPos = new THREE.Vector3();
|
||||||
|
const _objectBounds = new THREE.Box3();
|
||||||
const _raycaster = new THREE.Raycaster();
|
const _raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
|
function getInteractableWorldPosition(
|
||||||
|
group: THREE.Group,
|
||||||
|
debugSphere: THREE.Mesh | null,
|
||||||
|
): THREE.Vector3 {
|
||||||
|
_objectBounds.makeEmpty();
|
||||||
|
|
||||||
|
for (const child of group.children) {
|
||||||
|
if (child === debugSphere) continue;
|
||||||
|
_objectBounds.expandByObject(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_objectBounds.isEmpty()) {
|
||||||
|
return _objectBounds.getCenter(_objectPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group.getWorldPosition(_objectPos);
|
||||||
|
}
|
||||||
|
|
||||||
function createInteractableHandle(
|
function createInteractableHandle(
|
||||||
props: InteractableObjectProps,
|
props: InteractableObjectProps,
|
||||||
): InteractableHandle {
|
): InteractableHandle {
|
||||||
@@ -158,7 +177,7 @@ export function InteractableObject(
|
|||||||
const t = bodyRef.current.translation();
|
const t = bodyRef.current.translation();
|
||||||
_objectPos.set(t.x, t.y, t.z);
|
_objectPos.set(t.x, t.y, t.z);
|
||||||
} else if (group) {
|
} else if (group) {
|
||||||
group.getWorldPosition(_objectPos);
|
getInteractableWorldPosition(group, debugSphereRef.current);
|
||||||
} else {
|
} else {
|
||||||
_objectPos.set(...position);
|
_objectPos.set(...position);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
} from "@/hooks/animation/useAnimatedModel";
|
} from "@/hooks/animation/useAnimatedModel";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import type { ModelTransformProps } from "@/types/three/three";
|
import type { ModelTransformProps } from "@/types/three/three";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
export interface AnimatedModelConfig extends ModelTransformProps {
|
interface AnimatedModelConfig extends ModelTransformProps {
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
animations?: string[];
|
animations?: string[];
|
||||||
defaultAnimation?: string;
|
defaultAnimation?: string;
|
||||||
@@ -67,32 +68,6 @@ export function AnimatedModel({
|
|||||||
}
|
}
|
||||||
}, [mixer, onAnimationEnd]);
|
}, [mixer, onAnimationEnd]);
|
||||||
|
|
||||||
const play = useCallback(
|
|
||||||
(name: string, fade = fadeDuration) => {
|
|
||||||
const action = actions[name];
|
|
||||||
if (action) {
|
|
||||||
Object.values(actions).forEach((a) => {
|
|
||||||
if (a && a !== action) a.fadeOut(fade);
|
|
||||||
});
|
|
||||||
action.reset().fadeIn(fade).play();
|
|
||||||
setCurrentAnim(name);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[actions, fadeDuration],
|
|
||||||
);
|
|
||||||
|
|
||||||
const stop = useCallback(
|
|
||||||
(fade = fadeDuration) => {
|
|
||||||
Object.values(actions).forEach((a) => a?.fadeOut(fade));
|
|
||||||
const defaultAction = actions[defaultAnimation];
|
|
||||||
if (defaultAction) {
|
|
||||||
defaultAction.reset().fadeIn(fade).play();
|
|
||||||
setCurrentAnim(defaultAnimation);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[actions, defaultAnimation, fadeDuration],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fadeTo = useCallback(
|
const fadeTo = useCallback(
|
||||||
(name: string, fade = fadeDuration) => {
|
(name: string, fade = fadeDuration) => {
|
||||||
const action = actions[name];
|
const action = actions[name];
|
||||||
@@ -106,6 +81,19 @@ export function AnimatedModel({
|
|||||||
},
|
},
|
||||||
[actions, fadeDuration],
|
[actions, fadeDuration],
|
||||||
);
|
);
|
||||||
|
const play = fadeTo;
|
||||||
|
|
||||||
|
const stop = useCallback(
|
||||||
|
(fade = fadeDuration) => {
|
||||||
|
Object.values(actions).forEach((a) => a?.fadeOut(fade));
|
||||||
|
const defaultAction = actions[defaultAnimation];
|
||||||
|
if (defaultAction) {
|
||||||
|
defaultAction.reset().fadeIn(fade).play();
|
||||||
|
setCurrentAnim(defaultAnimation);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[actions, defaultAnimation, fadeDuration],
|
||||||
|
);
|
||||||
|
|
||||||
const setSpeed = useCallback(
|
const setSpeed = useCallback(
|
||||||
(newSpeed: number) => {
|
(newSpeed: number) => {
|
||||||
@@ -121,17 +109,39 @@ export function AnimatedModel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaultAction = actions[defaultAnimation as string];
|
let defaultAction = actions[defaultAnimation];
|
||||||
|
|
||||||
if (!defaultAction && names.length > 0) {
|
const fallbackAnimation = names[0];
|
||||||
defaultAction = actions[names[0] as string];
|
if (!defaultAction && fallbackAnimation) {
|
||||||
|
logger.warn(
|
||||||
|
"AnimatedModel",
|
||||||
|
"Default animation missing, using fallback",
|
||||||
|
{
|
||||||
|
modelPath,
|
||||||
|
defaultAnimation,
|
||||||
|
fallbackAnimation,
|
||||||
|
availableAnimations: names,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
defaultAction = actions[fallbackAnimation];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultAction) {
|
if (defaultAction) {
|
||||||
defaultAction.play();
|
Object.values(actions).forEach((action) => {
|
||||||
|
if (action && action !== defaultAction) action.fadeOut(fadeDuration);
|
||||||
|
});
|
||||||
|
defaultAction.reset().fadeIn(fadeDuration).play();
|
||||||
onLoaded?.();
|
onLoaded?.();
|
||||||
}
|
}
|
||||||
}, [actions, defaultAnimation, names, autoPlay, onLoaded]);
|
}, [
|
||||||
|
actions,
|
||||||
|
defaultAnimation,
|
||||||
|
fadeDuration,
|
||||||
|
modelPath,
|
||||||
|
names,
|
||||||
|
autoPlay,
|
||||||
|
onLoaded,
|
||||||
|
]);
|
||||||
|
|
||||||
const contextValue: AnimatedModelContextValue = {
|
const contextValue: AnimatedModelContextValue = {
|
||||||
play,
|
play,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||||
import { disposeObject3D } from "@/utils/three/dispose";
|
|
||||||
|
|
||||||
function applyShadowSettings(
|
function applyShadowSettings(
|
||||||
object: THREE.Object3D,
|
object: THREE.Object3D,
|
||||||
@@ -17,7 +17,7 @@ function applyShadowSettings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimpleModelConfig extends ModelTransformProps {
|
interface SimpleModelConfig extends ModelTransformProps {
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
castShadow?: boolean;
|
castShadow?: boolean;
|
||||||
receiveShadow?: boolean;
|
receiveShadow?: boolean;
|
||||||
@@ -42,18 +42,12 @@ export function SimpleModel({
|
|||||||
rotation,
|
rotation,
|
||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
const model = useMemo(() => scene.clone(true), [scene]);
|
const model = useClonedObject(scene, { cloneResources: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyShadowSettings(model, castShadow, receiveShadow);
|
applyShadowSettings(model, castShadow, receiveShadow);
|
||||||
}, [castShadow, model, receiveShadow]);
|
}, [castShadow, model, receiveShadow]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
disposeObject3D(model);
|
|
||||||
};
|
|
||||||
}, [model]);
|
|
||||||
|
|
||||||
const parsedScale =
|
const parsedScale =
|
||||||
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
import {
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
MergedStaticMapModel,
|
||||||
|
type MergedStaticMapModelProps,
|
||||||
|
} from "@/components/three/world/MergedStaticMapModel";
|
||||||
|
|
||||||
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
|
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
|
||||||
|
|
||||||
interface EcoleModelProps {
|
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||||
position: Vector3Tuple;
|
|
||||||
rotation: Vector3Tuple;
|
|
||||||
scale: Vector3Tuple;
|
|
||||||
castShadow?: boolean;
|
|
||||||
receiveShadow?: boolean;
|
|
||||||
onLoaded?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
|
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
|
||||||
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
|
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
import {
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
MergedStaticMapModel,
|
||||||
|
type MergedStaticMapModelProps,
|
||||||
|
} from "@/components/three/world/MergedStaticMapModel";
|
||||||
|
|
||||||
const FERME_VERTICALE_MODEL_PATH = "/models/fermeverticale/model.gltf";
|
const FERME_VERTICALE_MODEL_PATH = "/models/fermeverticale/model.gltf";
|
||||||
|
|
||||||
interface FermeVerticaleModelProps {
|
type FermeVerticaleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||||
position: Vector3Tuple;
|
|
||||||
rotation: Vector3Tuple;
|
|
||||||
scale: Vector3Tuple;
|
|
||||||
castShadow?: boolean;
|
|
||||||
receiveShadow?: boolean;
|
|
||||||
onLoaded?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FermeVerticaleModel(
|
export function FermeVerticaleModel(
|
||||||
props: FermeVerticaleModelProps,
|
props: FermeVerticaleModelProps,
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
import {
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
MergedStaticMapModel,
|
||||||
|
type MergedStaticMapModelProps,
|
||||||
|
} from "@/components/three/world/MergedStaticMapModel";
|
||||||
|
|
||||||
const GENERATEUR_MODEL_PATH = "/models/generateur/model.gltf";
|
const GENERATEUR_MODEL_PATH = "/models/generateur/model.gltf";
|
||||||
|
|
||||||
interface GenerateurModelProps {
|
type GenerateurModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||||
position: Vector3Tuple;
|
|
||||||
rotation: Vector3Tuple;
|
|
||||||
scale: Vector3Tuple;
|
|
||||||
castShadow?: boolean;
|
|
||||||
receiveShadow?: boolean;
|
|
||||||
onLoaded?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GenerateurModel(
|
export function GenerateurModel(
|
||||||
props: GenerateurModelProps,
|
props: GenerateurModelProps,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
import {
|
||||||
|
MergedStaticMapModel,
|
||||||
|
type MergedStaticMapModelProps,
|
||||||
|
} from "@/components/three/world/MergedStaticMapModel";
|
||||||
|
|
||||||
|
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
|
||||||
|
|
||||||
|
type LaFabrikMapModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||||
|
|
||||||
|
export function LaFabrikMapModel(
|
||||||
|
props: LaFabrikMapModelProps,
|
||||||
|
): React.JSX.Element {
|
||||||
|
return <MergedStaticMapModel modelPath={LA_FABRIK_MODEL_PATH} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
useGLTF.preload(LA_FABRIK_MODEL_PATH);
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useGLTF } from "@react-three/drei";
|
|
||||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
|
||||||
|
|
||||||
const LAFABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
|
|
||||||
|
|
||||||
interface LafabrikModelProps {
|
|
||||||
position: Vector3Tuple;
|
|
||||||
rotation: Vector3Tuple;
|
|
||||||
scale: Vector3Tuple;
|
|
||||||
castShadow?: boolean;
|
|
||||||
receiveShadow?: boolean;
|
|
||||||
onLoaded?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LafabrikModel(props: LafabrikModelProps): React.JSX.Element {
|
|
||||||
return <MergedStaticMapModel modelPath={LAFABRIK_MODEL_PATH} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
useGLTF.preload(LAFABRIK_MODEL_PATH);
|
|
||||||
@@ -6,7 +6,7 @@ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||||
|
|
||||||
interface MergedStaticMapModelProps {
|
export interface MergedStaticMapModelProps {
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
rotation: Vector3Tuple;
|
rotation: Vector3Tuple;
|
||||||
@@ -93,8 +93,11 @@ function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
|
|||||||
return [...groups.values()]
|
return [...groups.values()]
|
||||||
.map((group) => {
|
.map((group) => {
|
||||||
if (group.geometries.length === 1) {
|
if (group.geometries.length === 1) {
|
||||||
|
const [geometry] = group.geometries;
|
||||||
|
if (!geometry) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
geometry: group.geometries[0] as THREE.BufferGeometry,
|
geometry,
|
||||||
material: group.material,
|
material: group.material,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { useGLTF } from "@react-three/drei";
|
|||||||
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
interface SkyModelProps {
|
interface SkyModelProps {
|
||||||
|
fallbackModelScale?: number | undefined;
|
||||||
|
fallbackModelPath?: string | undefined;
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
fallbackColor?: string | undefined;
|
fallbackColor?: string | undefined;
|
||||||
fallbackModelPath?: string | undefined;
|
|
||||||
fallbackScale?: number | undefined;
|
|
||||||
scale?: number | undefined;
|
scale?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ interface SkyModelContentProps {
|
|||||||
interface SkyModelErrorBoundaryProps {
|
interface SkyModelErrorBoundaryProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
fallback: ReactNode;
|
fallback: ReactNode;
|
||||||
|
label: string;
|
||||||
|
modelPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkyModelErrorBoundaryState {
|
interface SkyModelErrorBoundaryState {
|
||||||
@@ -29,7 +32,6 @@ interface SkyModelErrorBoundaryState {
|
|||||||
const SKY_MODEL_SCALE = 1;
|
const SKY_MODEL_SCALE = 1;
|
||||||
const SKY_MODEL_RENDER_ORDER = -1000;
|
const SKY_MODEL_RENDER_ORDER = -1000;
|
||||||
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
|
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
|
||||||
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
|
|
||||||
|
|
||||||
class SkyModelErrorBoundary extends Component<
|
class SkyModelErrorBoundary extends Component<
|
||||||
SkyModelErrorBoundaryProps,
|
SkyModelErrorBoundaryProps,
|
||||||
@@ -44,6 +46,17 @@ class SkyModelErrorBoundary extends Component<
|
|||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error): void {
|
||||||
|
logger.warn(
|
||||||
|
"SkyModel",
|
||||||
|
`${this.props.label} model failed; using fallback`,
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
modelPath: this.props.modelPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
render(): ReactNode {
|
render(): ReactNode {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return this.props.fallback;
|
return this.props.fallback;
|
||||||
@@ -55,8 +68,8 @@ class SkyModelErrorBoundary extends Component<
|
|||||||
|
|
||||||
export function SkyModel({
|
export function SkyModel({
|
||||||
fallbackColor,
|
fallbackColor,
|
||||||
|
fallbackModelScale = SKY_MODEL_SCALE,
|
||||||
fallbackModelPath,
|
fallbackModelPath,
|
||||||
fallbackScale = SKY_MODEL_SCALE,
|
|
||||||
modelPath,
|
modelPath,
|
||||||
scale = SKY_MODEL_SCALE,
|
scale = SKY_MODEL_SCALE,
|
||||||
}: SkyModelProps): React.JSX.Element {
|
}: SkyModelProps): React.JSX.Element {
|
||||||
@@ -64,15 +77,28 @@ export function SkyModel({
|
|||||||
<color attach="background" args={[fallbackColor]} />
|
<color attach="background" args={[fallbackColor]} />
|
||||||
) : null;
|
) : null;
|
||||||
const fallback = fallbackModelPath ? (
|
const fallback = fallbackModelPath ? (
|
||||||
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
|
<SkyModelErrorBoundary
|
||||||
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
|
key={fallbackModelPath}
|
||||||
|
fallback={colorFallback}
|
||||||
|
label="Fallback sky"
|
||||||
|
modelPath={fallbackModelPath}
|
||||||
|
>
|
||||||
|
<SkyModelContent
|
||||||
|
modelPath={fallbackModelPath}
|
||||||
|
scale={fallbackModelScale}
|
||||||
|
/>
|
||||||
</SkyModelErrorBoundary>
|
</SkyModelErrorBoundary>
|
||||||
) : (
|
) : (
|
||||||
colorFallback
|
colorFallback
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
|
<SkyModelErrorBoundary
|
||||||
|
key={modelPath}
|
||||||
|
fallback={fallback}
|
||||||
|
label="Primary sky"
|
||||||
|
modelPath={modelPath}
|
||||||
|
>
|
||||||
<SkyModelContent modelPath={modelPath} scale={scale} />
|
<SkyModelContent modelPath={modelPath} scale={scale} />
|
||||||
</SkyModelErrorBoundary>
|
</SkyModelErrorBoundary>
|
||||||
);
|
);
|
||||||
@@ -154,4 +180,3 @@ function disposeSkyModelMaterials(model: THREE.Object3D): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useGLTF.preload(SKYBOX_MODEL_PATH);
|
useGLTF.preload(SKYBOX_MODEL_PATH);
|
||||||
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
|
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { useEffect } from "react";
|
|||||||
import { RotateCcw, X } from "lucide-react";
|
import { RotateCcw, X } from "lucide-react";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
import type {
|
import type { SubtitleLanguage } from "@/types/settings/settings";
|
||||||
RepairRuntime,
|
|
||||||
SubtitleLanguage,
|
|
||||||
} from "@/managers/stores/useSettingsStore";
|
|
||||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||||
|
|
||||||
function formatPercent(value: number): string {
|
function formatPercent(value: number): string {
|
||||||
@@ -62,14 +59,12 @@ export function GameSettingsMenu(): React.JSX.Element | null {
|
|||||||
dialogueVolume,
|
dialogueVolume,
|
||||||
subtitlesEnabled,
|
subtitlesEnabled,
|
||||||
subtitleLanguage,
|
subtitleLanguage,
|
||||||
repairRuntime,
|
|
||||||
setMusicVolume,
|
setMusicVolume,
|
||||||
setSfxVolume,
|
setSfxVolume,
|
||||||
setDialogueVolume,
|
setDialogueVolume,
|
||||||
setSettingsMenuOpen,
|
setSettingsMenuOpen,
|
||||||
setSubtitlesEnabled,
|
setSubtitlesEnabled,
|
||||||
setSubtitleLanguage,
|
setSubtitleLanguage,
|
||||||
setRepairRuntime,
|
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -178,28 +173,6 @@ export function GameSettingsMenu(): React.JSX.Element | null {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
|
||||||
className="game-settings-menu__section"
|
|
||||||
aria-labelledby="repair-settings-heading"
|
|
||||||
>
|
|
||||||
<h3 id="repair-settings-heading">Repair game</h3>
|
|
||||||
<div className="game-settings-menu__choice-group game-settings-menu__choice-group--stacked">
|
|
||||||
{(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
|
|
||||||
<button
|
|
||||||
key={runtime}
|
|
||||||
type="button"
|
|
||||||
className={repairRuntime === runtime ? "active" : undefined}
|
|
||||||
onClick={() => setRepairRuntime(runtime)}
|
|
||||||
aria-pressed={repairRuntime === runtime}
|
|
||||||
>
|
|
||||||
{runtime === "js"
|
|
||||||
? "Repair game en JS (local)"
|
|
||||||
: "Repair game en Python (server)"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{showDebugRestart ? (
|
{showDebugRestart ? (
|
||||||
<button
|
<button
|
||||||
className="game-settings-menu__restart"
|
className="game-settings-menu__restart"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
|||||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||||
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
|
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
|
||||||
|
|
||||||
export type SubtitleSpeaker = DialogueSpeaker;
|
type SubtitleSpeaker = DialogueSpeaker;
|
||||||
|
|
||||||
interface SubtitlesProps {
|
interface SubtitlesProps {
|
||||||
speaker?: SubtitleSpeaker | null;
|
speaker?: SubtitleSpeaker | null;
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { RotateCcw, StepBack, StepForward } from "lucide-react";
|
import { RotateCcw, StepBack, StepForward } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type MainGameState,
|
GAME_STEPS,
|
||||||
useGameStore,
|
isGameStep,
|
||||||
} from "@/managers/stores/useGameStore";
|
MAIN_GAME_STATES,
|
||||||
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
|
} from "@/data/game/gameStateConfig";
|
||||||
import { GAME_STEPS, type GameStep } from "@/types/game";
|
import {
|
||||||
|
isMissionStep,
|
||||||
const MAIN_STATES: MainGameState[] = [
|
MISSION_STEPS,
|
||||||
"intro",
|
} from "@/data/gameplay/repairMissionState";
|
||||||
"bike",
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
"pylone",
|
import type { MainGameState } from "@/types/game";
|
||||||
"ferme",
|
|
||||||
"outro",
|
|
||||||
];
|
|
||||||
|
|
||||||
function toPascalCase(value: string): string {
|
function toPascalCase(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -24,28 +21,28 @@ function toPascalCase(value: string): string {
|
|||||||
|
|
||||||
export function GameStateDebugPanel(): React.JSX.Element {
|
export function GameStateDebugPanel(): React.JSX.Element {
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||||
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
|
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||||
const fermeStep = useGameStore((state) => state.ferme.currentStep);
|
const farmStep = useGameStore((state) => state.farm.currentStep);
|
||||||
const detail = useGameStore((state) => {
|
const detail = useGameStore((state) => {
|
||||||
switch (state.mainState) {
|
switch (state.mainState) {
|
||||||
case "intro":
|
case "intro":
|
||||||
return state.intro.currentStep;
|
return state.intro.currentStep;
|
||||||
case "bike":
|
case "ebike":
|
||||||
return state.bike.currentStep;
|
return state.ebike.currentStep;
|
||||||
case "pylone":
|
case "pylon":
|
||||||
return state.pylone.currentStep;
|
return state.pylon.currentStep;
|
||||||
case "ferme":
|
case "farm":
|
||||||
return state.ferme.currentStep;
|
return state.farm.currentStep;
|
||||||
case "outro":
|
case "outro":
|
||||||
return state.outro.hasStarted ? "started" : "waiting";
|
return state.outro.hasStarted ? "started" : "waiting";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const setMainState = useGameStore((state) => state.setMainState);
|
const setMainState = useGameStore((state) => state.setMainState);
|
||||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
const setBikeState = useGameStore((state) => state.setBikeState);
|
const setEbikeState = useGameStore((state) => state.setEbikeState);
|
||||||
const setPyloneState = useGameStore((state) => state.setPyloneState);
|
const setPylonState = useGameStore((state) => state.setPylonState);
|
||||||
const setFermeState = useGameStore((state) => state.setFermeState);
|
const setFarmState = useGameStore((state) => state.setFarmState);
|
||||||
const setOutroState = useGameStore((state) => state.setOutroState);
|
const setOutroState = useGameStore((state) => state.setOutroState);
|
||||||
const advanceGameState = useGameStore((state) => state.advanceGameState);
|
const advanceGameState = useGameStore((state) => state.advanceGameState);
|
||||||
const rewindGameState = useGameStore((state) => state.rewindGameState);
|
const rewindGameState = useGameStore((state) => state.rewindGameState);
|
||||||
@@ -60,7 +57,9 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
|
|
||||||
function setSubState(nextSubState: string): void {
|
function setSubState(nextSubState: string): void {
|
||||||
if (mainState === "intro") {
|
if (mainState === "intro") {
|
||||||
setIntroStep(nextSubState as GameStep);
|
if (isGameStep(nextSubState)) {
|
||||||
|
setIntroStep(nextSubState);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,18 +70,18 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
|
|
||||||
if (!isMissionStep(nextSubState)) return;
|
if (!isMissionStep(nextSubState)) return;
|
||||||
|
|
||||||
if (mainState === "bike") {
|
if (mainState === "ebike") {
|
||||||
setBikeState({ currentStep: nextSubState });
|
setEbikeState({ currentStep: nextSubState });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainState === "pylone") {
|
if (mainState === "pylon") {
|
||||||
setPyloneState({ currentStep: nextSubState });
|
setPylonState({ currentStep: nextSubState });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainState === "ferme") {
|
if (mainState === "farm") {
|
||||||
setFermeState({ currentStep: nextSubState });
|
setFarmState({ currentStep: nextSubState });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,18 +89,34 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
function setDebugMainState(nextMainState: MainGameState): void {
|
function setDebugMainState(nextMainState: MainGameState): void {
|
||||||
setMainState(nextMainState);
|
setMainState(nextMainState);
|
||||||
|
|
||||||
if (nextMainState === "bike" && bikeStep === "locked") {
|
if (
|
||||||
setBikeState({ currentStep: "waiting" });
|
nextMainState === "pylon" ||
|
||||||
|
nextMainState === "farm" ||
|
||||||
|
nextMainState === "outro"
|
||||||
|
) {
|
||||||
|
setEbikeState({ currentStep: "done", isRepaired: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextMainState === "farm" || nextMainState === "outro") {
|
||||||
|
setPylonState({ currentStep: "done", isPowered: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextMainState === "outro") {
|
||||||
|
setFarmState({ currentStep: "done", irrigationFixed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextMainState === "ebike" && ebikeStep === "locked") {
|
||||||
|
setEbikeState({ currentStep: "waiting" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextMainState === "pylone" && pyloneStep === "locked") {
|
if (nextMainState === "pylon" && pylonStep === "locked") {
|
||||||
setPyloneState({ currentStep: "waiting" });
|
setPylonState({ currentStep: "waiting" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextMainState === "ferme" && fermeStep === "locked") {
|
if (nextMainState === "farm" && farmStep === "locked") {
|
||||||
setFermeState({ currentStep: "waiting" });
|
setFarmState({ currentStep: "waiting" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +139,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
aria-label="Main states"
|
aria-label="Main states"
|
||||||
role="group"
|
role="group"
|
||||||
>
|
>
|
||||||
{MAIN_STATES.map((state) => (
|
{MAIN_GAME_STATES.map((state) => (
|
||||||
<button
|
<button
|
||||||
key={state}
|
key={state}
|
||||||
aria-pressed={state === mainState}
|
aria-pressed={state === mainState}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as THREE from "three";
|
|||||||
import { ZONES } from "@/data/zones";
|
import { ZONES } from "@/data/zones";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
import { Debug } from "@/utils/debug/Debug";
|
||||||
import { GAME_STEPS } from "@/types/game";
|
import { GAME_STEPS } from "@/data/game/gameStateConfig";
|
||||||
|
|
||||||
const _playerPos = new THREE.Vector3();
|
const _playerPos = new THREE.Vector3();
|
||||||
const _zonePos = new THREE.Vector3();
|
const _zonePos = new THREE.Vector3();
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
|
|||||||
) => {
|
) => {
|
||||||
const { camera: rawCamera } = useThree();
|
const { camera: rawCamera } = useThree();
|
||||||
const cameraRef = useRef(rawCamera);
|
const cameraRef = useRef(rawCamera);
|
||||||
const keys = useRef<{ [key: string]: boolean }>({});
|
const keys = useRef<Partial<Record<string, boolean>>>({});
|
||||||
const controlsRef = useRef<OrbitControlsRef | null>(null);
|
const controlsRef = useRef<OrbitControlsRef | null>(null);
|
||||||
const lastPosition = useRef(new THREE.Vector3());
|
const lastPosition = useRef(new THREE.Vector3());
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,11 @@ export const AUDIO_PATHS = {
|
|||||||
searching: "/sounds/effect/fa.mp3",
|
searching: "/sounds/effect/fa.mp3",
|
||||||
helped: "/sounds/effect/fa.mp3",
|
helped: "/sounds/effect/fa.mp3",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type AudioCategory = "music" | "sfx" | "dialogue";
|
||||||
|
|
||||||
|
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||||
|
music: 1,
|
||||||
|
sfx: 1,
|
||||||
|
dialogue: 1,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
|
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
|
||||||
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
|
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
|
||||||
@@ -23,28 +24,30 @@ export const TEST_SCENE_TRIGGER_METALNESS = 0.5;
|
|||||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65;
|
export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65;
|
||||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
|
export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
|
||||||
|
|
||||||
export const TEST_SCENE_REPAIR_ZONES = [
|
export const GAME_REPAIR_ZONES = [
|
||||||
{
|
{
|
||||||
mission: "bike",
|
mission: "ebike",
|
||||||
label: "Bike",
|
label: "E-bike",
|
||||||
color: "#38bdf8",
|
color: "#38bdf8",
|
||||||
position: [-12, 0, -12],
|
position: [-12, 0, -12],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
mission: "pylone",
|
mission: "pylon",
|
||||||
label: "Pylone",
|
label: "Pylon",
|
||||||
color: "#facc15",
|
color: "#facc15",
|
||||||
position: [0, 0, -12],
|
position: [0, 0, -12],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
mission: "ferme",
|
mission: "farm",
|
||||||
label: "Farm",
|
label: "Farm",
|
||||||
color: "#86efac",
|
color: "#86efac",
|
||||||
position: [12, 0, -12],
|
position: [12, 0, -12],
|
||||||
},
|
},
|
||||||
] as const satisfies readonly {
|
] as const satisfies readonly {
|
||||||
mission: "bike" | "pylone" | "ferme";
|
mission: RepairMissionId;
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
export const TEST_SCENE_REPAIR_ZONES = GAME_REPAIR_ZONES;
|
||||||
|
|||||||
@@ -38,53 +38,59 @@ export const docGroups: DocGroup[] = [
|
|||||||
subtitle: "Gameplay implementation",
|
subtitle: "Gameplay implementation",
|
||||||
meta: "04",
|
meta: "04",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/docs/mission-flow",
|
||||||
|
title: "Mission Flow",
|
||||||
|
subtitle: "Intro and mission progression",
|
||||||
|
meta: "05",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/interaction",
|
path: "/docs/interaction",
|
||||||
title: "Interaction System",
|
title: "Interaction System",
|
||||||
subtitle: "Trigger, grab, hand input",
|
subtitle: "Trigger, grab, hand input",
|
||||||
meta: "05",
|
meta: "06",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/target-architecture",
|
path: "/docs/target-architecture",
|
||||||
title: "Target Architecture",
|
title: "Target Architecture",
|
||||||
subtitle: "Next direction",
|
subtitle: "Next direction",
|
||||||
meta: "06",
|
meta: "07",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/technical-editor",
|
path: "/docs/technical-editor",
|
||||||
title: "Editor Technical Notes",
|
title: "Editor Technical Notes",
|
||||||
subtitle: "Implementation details",
|
subtitle: "Implementation details",
|
||||||
meta: "07",
|
meta: "08",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/audio",
|
path: "/docs/audio",
|
||||||
title: "Audio Technical Notes",
|
title: "Audio Technical Notes",
|
||||||
subtitle: "Music, dialogue, SRT, and SFX",
|
subtitle: "Music, dialogue, SRT, and SFX",
|
||||||
meta: "08",
|
meta: "09",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/hand-tracking",
|
path: "/docs/hand-tracking",
|
||||||
title: "Hand Tracking Technical Notes",
|
title: "Hand Tracking Technical Notes",
|
||||||
subtitle: "Webcam interaction pipeline",
|
subtitle: "Webcam interaction pipeline",
|
||||||
meta: "09",
|
meta: "10",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/zustand",
|
path: "/docs/zustand",
|
||||||
title: "Zustand Stores",
|
title: "Zustand Stores",
|
||||||
subtitle: "Game, settings, subtitles",
|
subtitle: "Game, settings, subtitles",
|
||||||
meta: "10",
|
meta: "11",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/three-debugging",
|
path: "/docs/three-debugging",
|
||||||
title: "Three Debugging",
|
title: "Three Debugging",
|
||||||
subtitle: "Step into Three.js internals",
|
subtitle: "Step into Three.js internals",
|
||||||
meta: "11",
|
meta: "12",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/map-performance",
|
path: "/docs/map-performance",
|
||||||
title: "Map Performance",
|
title: "Map Performance",
|
||||||
subtitle: "Draw calls, triangles, and streaming",
|
subtitle: "Draw calls, triangles, and streaming",
|
||||||
meta: "12",
|
meta: "13",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -95,25 +101,25 @@ export const docGroups: DocGroup[] = [
|
|||||||
path: "/docs/features",
|
path: "/docs/features",
|
||||||
title: "Features",
|
title: "Features",
|
||||||
subtitle: "Implemented scope",
|
subtitle: "Implemented scope",
|
||||||
meta: "13",
|
meta: "14",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/main-feature",
|
path: "/docs/main-feature",
|
||||||
title: "Main Feature",
|
title: "Main Feature",
|
||||||
subtitle: "Repair-game prototype",
|
subtitle: "Repair-game prototype",
|
||||||
meta: "14",
|
meta: "15",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/editor",
|
path: "/docs/editor",
|
||||||
title: "Editor User Guide",
|
title: "Editor User Guide",
|
||||||
subtitle: "Editing workflow",
|
subtitle: "Editing workflow",
|
||||||
meta: "15",
|
meta: "16",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/animation",
|
path: "/docs/animation",
|
||||||
title: "Animation & 3D Model System",
|
title: "Animation & 3D Model System",
|
||||||
subtitle: "Components and usage",
|
subtitle: "Components and usage",
|
||||||
meta: "16",
|
meta: "17",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -124,7 +130,7 @@ export const docGroups: DocGroup[] = [
|
|||||||
path: "/docs/code-review",
|
path: "/docs/code-review",
|
||||||
title: "Code Review Prep",
|
title: "Code Review Prep",
|
||||||
subtitle: "Presentation support",
|
subtitle: "Presentation support",
|
||||||
meta: "17",
|
meta: "18",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,459 +1,3 @@
|
|||||||
export const readmeFr = `# La-Fabrik
|
|
||||||
|
|
||||||
Une expérience web 3D interactive pour La Fabrik Durable, un service low-tech de réparation et de transformation situé à Altera, une ville post-capitaliste reconstruite en 2039. Les joueurs incarnent un technicien fraîchement intégré et vivent une journée de service : réparer un vélo électrique, remettre en état un réseau d'énergie et améliorer le système d'irrigation d'une ferme verticale.
|
|
||||||
|
|
||||||
Construit avec React, Three.js et Vite. Fonctionne dans le navigateur, sans installation côté utilisateur.
|
|
||||||
|
|
||||||
## Stack technique
|
|
||||||
|
|
||||||
### Build et langage
|
|
||||||
|
|
||||||
| Package |
|
|
||||||
| -------------------------------------------------- |
|
|
||||||
| [TypeScript](https://www.typescriptlang.org/docs/) |
|
|
||||||
| [React](https://react.dev/learn) |
|
|
||||||
| [Vite](https://vite.dev/guide/) |
|
|
||||||
| [ESLint](https://eslint.org/docs/latest/) |
|
|
||||||
| [Prettier](https://prettier.io/docs/) |
|
|
||||||
|
|
||||||
### Moteur 3D
|
|
||||||
|
|
||||||
| Package |
|
|
||||||
| ----------------------------------------------------------------------------------------- |
|
|
||||||
| [Three.js](https://threejs.org/docs/) |
|
|
||||||
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
|
|
||||||
| [@react-three/drei](https://pmndrs.github.io/drei) |
|
|
||||||
| [@react-three/rapier](https://rapier.rs/docs/) |
|
|
||||||
| [GSAP](https://gsap.com/docs/v3/Installation/) |
|
|
||||||
|
|
||||||
### Performance et effets
|
|
||||||
|
|
||||||
| Package |
|
|
||||||
| --------------------------------------------------------------------------- |
|
|
||||||
| [r3f-perf](https://github.com/utsuboco/r3f-perf) |
|
|
||||||
| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) |
|
|
||||||
|
|
||||||
## Structure du projet
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
la-fabrik/
|
|
||||||
├── public/
|
|
||||||
│ ├── models/
|
|
||||||
│ │ ├── map/ # Carte de base, chargée au démarrage
|
|
||||||
│ │ ├── workshop/
|
|
||||||
│ │ ├── powerGrid/
|
|
||||||
│ │ └── farm/
|
|
||||||
│ ├── textures/
|
|
||||||
│ └── sounds/
|
|
||||||
│
|
|
||||||
└── src/
|
|
||||||
├── world/ # Composition du monde 3D persistant
|
|
||||||
│ ├── World.tsx # Composition de la scène active
|
|
||||||
│ ├── GameMap.tsx # Chargement de carte et collision octree
|
|
||||||
│ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles
|
|
||||||
│ ├── Environment.tsx # Arrière-plan et modèle de ciel
|
|
||||||
│ ├── GameMusic.tsx # Cycle de vie de la musique de jeu
|
|
||||||
│ ├── debug/ # Scène de test debug
|
|
||||||
│ └── player/ # Contrôleur joueur et caméra
|
|
||||||
│
|
|
||||||
├── components/
|
|
||||||
│ ├── three/ # Composants R3F par domaine
|
|
||||||
│ └── ui/ # Overlays HTML hors Canvas
|
|
||||||
│
|
|
||||||
├── managers/ # Logique, état et orchestration
|
|
||||||
├── hooks/ # Hooks React autour des managers
|
|
||||||
├── data/ # Configuration statique
|
|
||||||
├── shaders/ # Shaders GLSL
|
|
||||||
└── utils/ # Utilitaires partagés et debug
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Démarrage
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git
|
|
||||||
cd La-Fabrik
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- application : \`http://localhost:5173\`
|
|
||||||
- mode debug : \`http://localhost:5173?debug\`
|
|
||||||
|
|
||||||
## Licence
|
|
||||||
|
|
||||||
Voir le fichier [LICENSE](./LICENSE).
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const architectureFr = `# Architecture actuelle
|
|
||||||
|
|
||||||
Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
|
|
||||||
|
|
||||||
## Structure runtime
|
|
||||||
|
|
||||||
- \`src/App.tsx\` monte le \`RouterProvider\`, qui pilote l'affichage des vues de l'application.
|
|
||||||
- \`src/pages/page.tsx\` monte le \`Canvas\`, le \`World\` 3D, l'overlay de performance debug et les overlays HTML.
|
|
||||||
- \`src/world/World.tsx\` compose la scène active avec :
|
|
||||||
- l'environnement et l'éclairage
|
|
||||||
- les helpers debug et le mode caméra debug
|
|
||||||
- soit la carte principale, soit la scène de test physique debug
|
|
||||||
- le rig joueur quand le mode caméra actif est \`player\`
|
|
||||||
- \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision.
|
|
||||||
- \`src/world/GameStageContent.tsx\` est enveloppé dans le contexte Rapier \`Physics\` dans la scène de jeu de production afin que les objets gameplay de stage puissent utiliser la physique sans migrer la carte ou le joueur vers Rapier. Il monte maintenant des instances réutilisables de \`RepairGame\` pour les états de mission \`bike\`, \`pylone\` et \`ferme\`.
|
|
||||||
- \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique, avec les objets existants de grab, trigger et preview de modèle, plus des zones playground de réparation séparées \`Bike\`, \`Pylone\` et \`Farm\`.
|
|
||||||
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
|
|
||||||
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut, le verrouillage de déplacement pendant les étapes repair et les inputs d'interaction.
|
|
||||||
|
|
||||||
## Frontières physiques
|
|
||||||
|
|
||||||
Le projet utilise actuellement deux couches de collision avec des responsabilités séparées :
|
|
||||||
|
|
||||||
- \`GameMap\` construit une octree utilisée par le contrôleur joueur pour les collisions avec la carte.
|
|
||||||
- \`GameStageContent\` est enveloppé dans Rapier \`Physics\` pour les objets gameplay comme les triggers de réparation, les mallettes, les objets saisissables et les futurs objets spécifiques aux missions.
|
|
||||||
- \`TestMap\` possède son propre playground Rapier \`Physics\` afin de peaufiner le gameplay de réparation par state de mission sans dépendre du placement de la carte de production.
|
|
||||||
|
|
||||||
Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il n'existe pas de plan de migration volontaire. Cela évite de mélanger les règles de déplacement joueur avec la physique d'objets avant que les systèmes gameplay en aient besoin.
|
|
||||||
|
|
||||||
## Modèle d'interaction
|
|
||||||
|
|
||||||
- \`src/managers/InteractionManager.ts\` est la source d'état actuelle des interactions.
|
|
||||||
- \`src/components/three/interaction/InteractableObject.tsx\` gère la détection de focus par distance et raycasting.
|
|
||||||
- \`src/components/three/interaction/TriggerObject.tsx\` implémente les interactions de type trigger.
|
|
||||||
- \`src/components/three/interaction/GrabbableObject.tsx\` implémente les interactions saisir / relâcher.
|
|
||||||
- \`src/hooks/interaction/useInteraction.ts\` expose un snapshot d'interaction à l'UI React.
|
|
||||||
- \`src/components/ui/InteractPrompt.tsx\` affiche le prompt \`E\` pour les interactions trigger.
|
|
||||||
|
|
||||||
## Audio
|
|
||||||
|
|
||||||
- \`src/managers/AudioManager.ts\` fournit la lecture de sons one-shot avec pool, la musique en boucle, les volumes par catégorie et un pan stéréo optionnel pour les sons one-shot.
|
|
||||||
- Les catégories audio supportées sont \`music\`, \`sfx\` et \`dialogue\`.
|
|
||||||
- Les interactions trigger peuvent lancer directement des SFX via \`AudioManager\`.
|
|
||||||
|
|
||||||
## Menu options
|
|
||||||
|
|
||||||
- \`src/managers/stores/useSettingsStore.ts\` stocke les réglages de volume musique, volume SFX, volume dialogue, sous-titres, langue des sous-titres, runtime de réparation et visibilité du menu.
|
|
||||||
- \`src/components/ui/GameSettingsMenu.tsx\` rend le menu options en jeu.
|
|
||||||
- \`src/components/ui/GameUI.tsx\` monte le menu comme overlay HTML hors canvas.
|
|
||||||
- \`Esc\` ouvre et ferme le menu, et \`src/world/player/PlayerController.tsx\` ignore les inputs joueur pendant son ouverture.
|
|
||||||
- Les changements de volume sont transmis à \`AudioManager\` par catégorie.
|
|
||||||
|
|
||||||
## Dialogues et sous-titres
|
|
||||||
|
|
||||||
- \`public/sounds/dialogue/dialogues.json\` est le manifeste runtime des dialogues.
|
|
||||||
- Les fichiers audio de dialogue vivent dans \`public/sounds/dialogue/\`.
|
|
||||||
- Les fichiers de sous-titres vivent dans \`public/sounds/dialogue/subtitles/{fr|en}/\`.
|
|
||||||
- Le modèle actuel utilise un fichier SRT par voix et par langue.
|
|
||||||
- \`src/types/dialogues/dialogues.ts\` contient les types du manifeste.
|
|
||||||
- \`src/utils/dialogues/dialogueManifestValidation.ts\` valide la forme du manifeste au runtime.
|
|
||||||
- \`src/utils/dialogues/loadDialogueManifest.ts\` charge le manifeste et les cues SRT, avec fallback français si la langue sélectionnée manque.
|
|
||||||
- \`src/utils/subtitles/parseSrt.ts\` parse les blocs et timecodes SRT.
|
|
||||||
- \`src/utils/dialogues/playDialogue.ts\` joue l'audio de dialogue et synchronise le sous-titre actif avec le temps de l'élément audio.
|
|
||||||
- \`src/managers/stores/useSubtitleStore.ts\` stocke la cue de sous-titre affichée.
|
|
||||||
- \`src/components/ui/Subtitles.tsx\` rend l'overlay de sous-titres.
|
|
||||||
- \`src/world/GameDialogues.tsx\` déclenche actuellement les dialogues qui définissent un \`timecode\`.
|
|
||||||
- La lecture de dialogue est mise en file pour éviter les chevauchements.
|
|
||||||
|
|
||||||
## Cinématiques
|
|
||||||
|
|
||||||
- \`public/cinematics.json\` est le manifeste runtime des cinématiques.
|
|
||||||
- \`src/types/cinematics/cinematics.ts\` contient les types du manifeste.
|
|
||||||
- \`src/utils/cinematics/cinematicManifestValidation.ts\` valide la forme du manifeste.
|
|
||||||
- \`src/utils/cinematics/loadCinematicManifest.ts\` charge \`/cinematics.json\`.
|
|
||||||
- \`src/world/GameCinematics.tsx\` déclenche les cinématiques qui définissent un \`timecode\` global.
|
|
||||||
- Les cinématiques utilisent GSAP pour animer la position caméra et sa cible de regard.
|
|
||||||
- Les \`dialogueCues\` d'une cinématique déclenchent des dialogues à des temps relatifs au début de la cinématique.
|
|
||||||
- \`useGameStore.isCinematicPlaying\` sert à bloquer les inputs joueur pendant une cinématique.
|
|
||||||
|
|
||||||
## Système debug
|
|
||||||
|
|
||||||
- Le mode debug est activé avec \`?debug\`.
|
|
||||||
- \`src/utils/debug/Debug.ts\` possède l'instance \`lil-gui\` et les contrôles debug.
|
|
||||||
- \`src/hooks/debug/useCameraMode.ts\` et \`src/hooks/debug/useSceneMode.ts\` s'abonnent à l'état debug.
|
|
||||||
- \`src/components/debug/DebugPerf.tsx\` monte \`r3f-perf\` en lazy uniquement en mode debug.
|
|
||||||
- \`src/components/ui/debug/DebugOverlayLayout.tsx\` monte l'overlay HTML debug compact quand il est activé depuis \`lil-gui\`.
|
|
||||||
- \`src/components/ui/debug/GameStateDebugPanel.tsx\` expose l'état de jeu courant, le changement de main/sub-state, les contrôles previous/next step et le reset.
|
|
||||||
- \`src/components/ui/debug/HandTrackingDebugPanel.tsx\` affiche le statut hand tracking, l'usage, le modèle de gant chargé, le nombre de mains et l'état fist pendant l'activation du hand tracking.
|
|
||||||
- \`src/components/three/handTracking/HandTrackingGlove.tsx\` place les modèles riggés \`gant_l\` et \`gant_r\` sur les mains détectées dans la scène physics debug.
|
|
||||||
- \`src/components/debug/scene/DebugHelpers.tsx\` monte les helpers debug.
|
|
||||||
- \`src/components/debug/scene/DebugCameraControls.tsx\` monte la caméra libre debug.
|
|
||||||
- Les contrôles globaux \`lil-gui\` incluent camera mode, scene mode, \`R3F Perf\` et \`Debug Overlay\`; les contrôles d'interaction vivent dans le dossier \`Interaction\`.
|
|
||||||
|
|
||||||
## Domaines de composants 3D
|
|
||||||
|
|
||||||
- \`src/components/three/models/\` contient les helpers de modèles réutilisables comme \`ExplodableModel\`.
|
|
||||||
- \`src/components/three/interaction/\` contient les wrappers d'interaction réutilisables comme \`InteractableObject\`, \`TriggerObject\` et \`GrabbableObject\`.
|
|
||||||
- \`src/components/three/handTracking/\` contient les modèles debug R3F liés au hand tracking, comme les gants.
|
|
||||||
- \`src/components/three/gameplay/\` contient les composants de gameplay de réparation : le flow de production réutilisable \`RepairGame\`, la mallette, les étapes de réparation et les prompts.
|
|
||||||
- \`src/components/three/world/\` contient les objets world/environnement réutilisables comme \`SkyModel\`.
|
|
||||||
|
|
||||||
## Limites actuelles
|
|
||||||
|
|
||||||
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
|
|
||||||
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
|
|
||||||
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
|
|
||||||
- L'état de mission existe dans Zustand et le flow de réparation est implémenté comme prototype pour les missions de réparation actuelles.
|
|
||||||
- Les cinématiques et dialogues existent comme systèmes prototype pilotés par timecode; les branches de dialogue et l'orchestration gameplay globale restent limitées.
|
|
||||||
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const targetArchitectureFr = `# Architecture cible
|
|
||||||
|
|
||||||
Ce document décrit l'architecture visée à moyen terme pour le projet.
|
|
||||||
|
|
||||||
## Relation avec le code actuel
|
|
||||||
|
|
||||||
- \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant.
|
|
||||||
- Ce document décrit une direction d'architecture, pas un comportement implémenté.
|
|
||||||
- Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne.
|
|
||||||
|
|
||||||
## Objectifs
|
|
||||||
|
|
||||||
- Garder \`App.tsx\` petit et centré sur l'orchestration.
|
|
||||||
- Séparer le code de production du monde des chemins runtime uniquement debug.
|
|
||||||
- Garder une source de vérité claire par responsabilité.
|
|
||||||
- Faire grandir les systèmes gameplay progressivement, sans préconstruire une architecture vide.
|
|
||||||
|
|
||||||
## Couches prévues
|
|
||||||
|
|
||||||
### Couche App
|
|
||||||
|
|
||||||
- \`App.tsx\` monte la scène canvas et les overlays HTML de premier niveau.
|
|
||||||
- Il doit rester fin et éviter la logique gameplay.
|
|
||||||
|
|
||||||
### Couche World
|
|
||||||
|
|
||||||
- \`src/world/\` doit contenir la composition de scène de production et les objets de scène de production.
|
|
||||||
- Responsabilités attendues :
|
|
||||||
- composition du monde
|
|
||||||
- carte, environnement, éclairage
|
|
||||||
- contrôleur joueur
|
|
||||||
- ancres d'interaction de production
|
|
||||||
- post-processing de production si nécessaire
|
|
||||||
|
|
||||||
### Couche Debug
|
|
||||||
|
|
||||||
- Les scènes et outils uniquement debug doivent être isolés du chemin de production.
|
|
||||||
- Responsabilités attendues :
|
|
||||||
- \`lil-gui\`
|
|
||||||
- overlay de performance
|
|
||||||
- helpers de scène
|
|
||||||
- caméra libre et contrôles de calibration
|
|
||||||
- scènes temporaires de test utilisées pendant le développement
|
|
||||||
|
|
||||||
### Couche UI
|
|
||||||
|
|
||||||
- \`src/components/ui/\` doit contenir les overlays HTML visibles par le joueur.
|
|
||||||
- Exemples futurs :
|
|
||||||
- crosshair
|
|
||||||
- flow de chargement
|
|
||||||
- HUD de mission
|
|
||||||
- overlays narratifs
|
|
||||||
|
|
||||||
### Couche Gameplay
|
|
||||||
|
|
||||||
- À mesure que le projet grandit, l'état gameplay peut évoluer vers une couche d'orchestration plus claire.
|
|
||||||
- Sujets probables :
|
|
||||||
- missions
|
|
||||||
- zones
|
|
||||||
- cinématiques
|
|
||||||
- dialogues
|
|
||||||
- audio
|
|
||||||
- interactions
|
|
||||||
|
|
||||||
## Règles
|
|
||||||
|
|
||||||
- Préférer du code direct et fonctionnel plutôt qu'un échafaudage spéculatif.
|
|
||||||
- Les types partagés doivent rester proches de leur domaine jusqu'à avoir plusieurs vrais consommateurs.
|
|
||||||
- Éviter de créer de nouveaux managers ou services sans besoin runtime actif.
|
|
||||||
- Les chemins runtime uniquement debug doivent être clairement marqués et faciles à retirer plus tard.
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const zustandFr = `# État de jeu Zustand
|
|
||||||
|
|
||||||
Ce document explique comment Zustand est utilisé dans le projet actuel.
|
|
||||||
|
|
||||||
## Pourquoi Zustand existe ici
|
|
||||||
|
|
||||||
Le projet a besoin d'une source de vérité partagée pour suivre la progression du joueur dans l'expérience.
|
|
||||||
|
|
||||||
La progression actuelle est découpée en main states :
|
|
||||||
|
|
||||||
| Main state | Rôle |
|
|
||||||
| --- | --- |
|
|
||||||
| \`intro\` | Onboarding et séquence d'ouverture |
|
|
||||||
| \`bike\` | Séquence de réparation du vélo électrique |
|
|
||||||
| \`pylone\` | Séquence du réseau électrique |
|
|
||||||
| \`ferme\` | Séquence de la ferme verticale |
|
|
||||||
| \`outro\` | Séquence de fin |
|
|
||||||
|
|
||||||
Chaque main state peut aussi posséder un sous-état plus fin, comme l'étape de mission courante, l'audio de dialogue ou des flags de complétion.
|
|
||||||
|
|
||||||
Zustand est utile parce que les composants React et React Three Fiber peuvent s'abonner uniquement à la partie de state dont ils ont besoin. Quand cette partie change, seuls les composants abonnés se mettent à jour.
|
|
||||||
|
|
||||||
## Emplacement du store
|
|
||||||
|
|
||||||
Le store de progression du jeu vit ici :
|
|
||||||
|
|
||||||
\`\`\`txt
|
|
||||||
src/managers/stores/useGameStore.ts
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Le store est placé dans \`src/managers/stores/\` parce qu'il appartient à la couche d'orchestration gameplay, pas à un composant visuel précis.
|
|
||||||
|
|
||||||
## Managers vs Store
|
|
||||||
|
|
||||||
Les managers sont responsables des objets runtime locaux et des comportements impératifs.
|
|
||||||
|
|
||||||
Exemples :
|
|
||||||
|
|
||||||
- \`AudioManager\` possède les éléments audio et les pools de sons.
|
|
||||||
- \`InteractionManager\` possède les handles d'interaction transitoires et la logique orientée input.
|
|
||||||
|
|
||||||
Un manager peut lire ou mettre à jour le store Zustand quand son comportement local doit impacter la progression globale du jeu.
|
|
||||||
|
|
||||||
Le store Zustand est responsable de l'état global durable :
|
|
||||||
|
|
||||||
- main state courant
|
|
||||||
- sous-état de mission
|
|
||||||
- flags de progression
|
|
||||||
- références de dialogue/audio
|
|
||||||
- transitions de state
|
|
||||||
|
|
||||||
Règle simple :
|
|
||||||
|
|
||||||
- manager = objets runtime, effets de bord et logique impérative locale
|
|
||||||
- store = état gameplay global auquel l'UI ou le world peuvent s'abonner
|
|
||||||
|
|
||||||
## Forme actuelle
|
|
||||||
|
|
||||||
Le store expose :
|
|
||||||
|
|
||||||
- \`mainState\` : phase active du jeu
|
|
||||||
- \`missionFlow\` : état prototype de l'intro et de la mission 2
|
|
||||||
- \`intro\` : état spécifique à l'intro
|
|
||||||
- \`bike\` : état de la mission vélo
|
|
||||||
- \`pylone\` : état de la mission réseau électrique
|
|
||||||
- \`ferme\` : état de la mission ferme
|
|
||||||
- \`outro\` : état de fin
|
|
||||||
- des actions de mise à jour directe et des actions de progression
|
|
||||||
|
|
||||||
Le slice \`missionFlow\` contient l'étape prototype, le prénom joueur, le lock de déplacement, le flag d'activité de la ville et le message de dialogue temporaire. Il vit dans le store principal parce qu'il s'agit d'un état gameplay global utilisé par l'UI, le world et le controller joueur.
|
|
||||||
|
|
||||||
Les étapes de mission utilisent actuellement cette séquence :
|
|
||||||
|
|
||||||
\`\`\`ts
|
|
||||||
"locked" | "waiting" | "inspected" | "fragmented" | "scanning" | "repairing" | "reassembling" | "done"
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Lire le state dans un composant
|
|
||||||
|
|
||||||
Utilise des selectors pour lire uniquement ce dont le composant a besoin.
|
|
||||||
|
|
||||||
\`\`\`tsx
|
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
|
||||||
|
|
||||||
export function Example(): React.JSX.Element {
|
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
|
||||||
|
|
||||||
return <p>State courant : {mainState}</p>;
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
C'est mieux que de lire tout le store, car le composant se re-render uniquement quand \`mainState\` change.
|
|
||||||
|
|
||||||
## Mettre à jour le state
|
|
||||||
|
|
||||||
Préfère les actions explicites du store.
|
|
||||||
|
|
||||||
\`\`\`ts
|
|
||||||
const advanceGameState = useGameStore((state) => state.advanceGameState);
|
|
||||||
|
|
||||||
advanceGameState();
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Pour le développement et le debug, des setters directs existent aussi :
|
|
||||||
|
|
||||||
\`\`\`ts
|
|
||||||
const setMainState = useGameStore((state) => state.setMainState);
|
|
||||||
|
|
||||||
setMainState("bike");
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Les setters directs sont pratiques pour les panneaux debug, mais le gameplay de production devrait préférer les actions métier comme \`advanceGameState\`, \`completeBike\` ou \`completePylone\`.
|
|
||||||
|
|
||||||
Le gameplay de mission qui peut cibler \`bike\`, \`pylone\` ou \`ferme\` doit préférer les actions génériques de mission :
|
|
||||||
|
|
||||||
\`\`\`ts
|
|
||||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
|
||||||
const completeMission = useGameStore((state) => state.completeMission);
|
|
||||||
|
|
||||||
setMissionStep("bike", "inspected");
|
|
||||||
completeMission("bike");
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Cela évite aux composants gameplay réutilisables, comme les flows de réparation, de dupliquer des branches spécifiques à chaque mission avec \`setBikeState\`, \`setPyloneState\` et \`setFermeState\`.
|
|
||||||
|
|
||||||
## Intégration avec le World
|
|
||||||
|
|
||||||
\`src/world/GameStageContent.tsx\` s'abonne à \`mainState\` et monte le contenu spécifique au state courant.
|
|
||||||
|
|
||||||
Pour les missions de réparation, il monte le composant réutilisable \`RepairGame\` avec un id de mission :
|
|
||||||
|
|
||||||
\`\`\`tsx
|
|
||||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
\`RepairGame\` lit l'étape de mission active depuis le store et écrit les transitions via des actions génériques comme \`setMissionStep\` et \`completeMission\`. Les ids de mission, étapes de mission et guards partagés vivent dans \`src/types/gameplay/repairMission.ts\`, ce qui évite à la configuration statique des missions de dépendre du store Zustand. Le flow de réparation de production supporte actuellement les transitions \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`.
|
|
||||||
|
|
||||||
Le flow prototype intro et mission 2 est documenté séparément dans \`docs/technical/mission-flow.md\`. Il utilise volontairement la même source de vérité \`useGameStore\`, sans \`GameStepManager\` dédié ni second store Zustand.
|
|
||||||
|
|
||||||
La scène peut donc évoluer progressivement vers ce pattern :
|
|
||||||
|
|
||||||
\`\`\`tsx
|
|
||||||
switch (mainState) {
|
|
||||||
case "intro":
|
|
||||||
return <IntroContent />;
|
|
||||||
case "bike":
|
|
||||||
return <BikeContent />;
|
|
||||||
case "pylone":
|
|
||||||
return <PyloneContent />;
|
|
||||||
case "ferme":
|
|
||||||
return <FarmContent />;
|
|
||||||
case "outro":
|
|
||||||
return <OutroContent />;
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Dans React Three Fiber, monter ou démonter du JSX contrôle ce qui apparaît dans la scène Three.js. Quand un composant lié à un state disparaît du JSX, React le retire de la scène.
|
|
||||||
|
|
||||||
## Intégration UI
|
|
||||||
|
|
||||||
\`src/components/ui/GameUI.tsx\` regroupe les overlays HTML utilisés par la route jouable.
|
|
||||||
|
|
||||||
Overlays actuels :
|
|
||||||
|
|
||||||
- \`DebugOverlayLayout\` : layout compact des panels debug HTML visible avec \`?debug\`
|
|
||||||
- \`GameStateDebugPanel\` : panneau de progression debug pour consulter/changer le main state, le sub state, avancer/reculer et reset le store
|
|
||||||
- \`Crosshair\` : aide de visée joueur
|
|
||||||
- \`InteractPrompt\` : prompt d'interaction
|
|
||||||
- \`RepairMovementLockIndicator\` : indicateur joueur affiché quand les étapes repair désactivent temporairement le déplacement
|
|
||||||
- Les overlays du flow mission comme \`IntroUI\`, \`BienvenueDisplay\` et \`DialogMessage\` sont montés par \`src/pages/page.tsx\`, car ce sont des overlays HTML de route plutôt qu'un HUD de jeu persistant.
|
|
||||||
|
|
||||||
\`src/pages/page.tsx\` doit rester fin et monter le canvas, le \`GameUI\` persistant et les overlays de route.
|
|
||||||
|
|
||||||
## Règles anti-régression
|
|
||||||
|
|
||||||
- Ne pas stocker les valeurs mises à jour à chaque frame dans Zustand.
|
|
||||||
- Utiliser \`useRef\` pour les valeurs mutables fréquentes comme la vélocité joueur, les vecteurs temporaires ou les données de boucle d'animation.
|
|
||||||
- Utiliser des selectors au lieu de lire tout le store dans les composants.
|
|
||||||
- Garder les transitions gameplay dans les actions du store quand possible.
|
|
||||||
- Garder les contrôles debug derrière \`?debug\`.
|
|
||||||
- Ajouter du state uniquement quand une vraie fonctionnalité runtime en a besoin.
|
|
||||||
|
|
||||||
## Prochaines étapes
|
|
||||||
|
|
||||||
Déplacer la validation de réparation dans les données de mission lorsque chaque mission aura ses propres nodes de modules cassés, assets de remplacement et événements de complétion.
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const missionFlowFr = `# Flow de mission
|
export const missionFlowFr = `# Flow de mission
|
||||||
|
|
||||||
Ce document décrit le flow prototype d'intro et de mission 2 après son intégration dans l'architecture actuelle.
|
Ce document décrit le flow prototype d'intro et de mission 2 après son intégration dans l'architecture actuelle.
|
||||||
@@ -470,7 +14,6 @@ Le store possède le slice \`missionFlow\` :
|
|||||||
|
|
||||||
\`\`\`ts
|
\`\`\`ts
|
||||||
missionFlow: {
|
missionFlow: {
|
||||||
step: GameStep;
|
|
||||||
activityCity: boolean;
|
activityCity: boolean;
|
||||||
playerName: string;
|
playerName: string;
|
||||||
canMove: boolean;
|
canMove: boolean;
|
||||||
@@ -487,14 +30,13 @@ Les managers restent responsables de services runtime locaux :
|
|||||||
- \`AudioManager\` possède les éléments audio, les pools audio, la musique, le volume par catégorie et le pan stéréo.
|
- \`AudioManager\` possède les éléments audio, les pools audio, la musique, le volume par catégorie et le pan stéréo.
|
||||||
- \`InteractionManager\` possède les handles d'interaction transitoires, focus, nearby et held.
|
- \`InteractionManager\` possède les handles d'interaction transitoires, focus, nearby et held.
|
||||||
|
|
||||||
La progression de mission n'est pas possédée par un manager. Les composants mettent à jour le store via des actions explicites comme \`setFlowStep\`, \`setCanMove\`, \`showDialog\` et \`hideDialog\`.
|
La progression de mission n'est pas possédée par un manager. Les composants mettent à jour le store via des actions explicites comme \`setCanMove\`, \`showDialog\` et \`hideDialog\`.
|
||||||
|
|
||||||
## Composants runtime
|
## Composants runtime
|
||||||
|
|
||||||
- \`src/components/game/GameFlow.tsx\` réagit à \`missionFlow.step\` et déclenche les effets ponctuels comme l'audio d'intro et le déblocage du mouvement.
|
- \`src/components/game/GameFlow.tsx\` réagit au store et déclenche les effets ponctuels comme l'audio d'intro et le déblocage du mouvement.
|
||||||
- \`src/components/zone/ZoneDetection.tsx\` lit la position caméra et fait passer le flow à une étape cible quand le joueur entre dans une zone configurée.
|
- \`src/components/zone/ZoneDetection.tsx\` lit la position caméra et fait passer le flow à une étape cible quand le joueur entre dans une zone configurée.
|
||||||
- \`src/components/three/interaction/CentralObject.tsx\` et \`VillageoisHelperObject.tsx\` exposent les objets interactifs temporaires de mission.
|
- \`src/pages/page.tsx\` monte les overlays HTML de mission : \`IntroUI\`, \`DialogMessage\` et \`Subtitles\`.
|
||||||
- \`src/pages/page.tsx\` monte les overlays HTML de mission : \`IntroUI\`, \`BienvenueDisplay\` et \`DialogMessage\`.
|
|
||||||
- \`src/world/player/PlayerController.tsx\` lit \`missionFlow.canMove\` comme lock de déplacement supplémentaire.
|
- \`src/world/player/PlayerController.tsx\` lit \`missionFlow.canMove\` comme lock de déplacement supplémentaire.
|
||||||
|
|
||||||
## Séquence d'étapes
|
## Séquence d'étapes
|
||||||
@@ -525,251 +67,3 @@ Chaque zone possède un id, une position, un rayon, une hauteur et un \`targetSt
|
|||||||
- Garder les effets de bord comme l'audio dans les composants ou les managers de service, mais garder la transition d'état dans le store.
|
- Garder les effets de bord comme l'audio dans les composants ou les managers de service, mais garder la transition d'état dans le store.
|
||||||
- Ne pas mettre les valeurs par-frame comme la position caméra ou les distances de zones dans Zustand.
|
- Ne pas mettre les valeurs par-frame comme la position caméra ou les distances de zones dans Zustand.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const featuresFr = `# Fonctionnalités implémentées
|
|
||||||
|
|
||||||
Ce document liste les fonctionnalités présentes dans le code actuel.
|
|
||||||
|
|
||||||
## Scène
|
|
||||||
|
|
||||||
- Scène React Three Fiber plein écran
|
|
||||||
- Carte principale chargée depuis \`public/models/{name}/model.glb\`, avec fallback vers \`model.gltf\`
|
|
||||||
- Scène de test physique debug sélectionnable depuis le panneau debug, avec tests grab/trigger, preview de modèle animé et zones playground de réparation séparées pour \`bike\`, \`pylone\` et \`ferme\`
|
|
||||||
- Contexte physique Rapier disponible pour les objets gameplay de stage en production
|
|
||||||
- Éclairage ambiant et directionnel
|
|
||||||
- Configuration de l'environnement de fond
|
|
||||||
|
|
||||||
## Joueur
|
|
||||||
|
|
||||||
- Mode caméra joueur
|
|
||||||
- Orientation souris avec pointer lock
|
|
||||||
- Déplacement avec \`ZQSD\`
|
|
||||||
- Saut
|
|
||||||
- Verrouillage du déplacement pendant les étapes repair actives, avec indicateur à l'écran tout en gardant les interactions trigger disponibles
|
|
||||||
- Collision basée sur une octree contre la carte chargée
|
|
||||||
|
|
||||||
## Interactions
|
|
||||||
|
|
||||||
- Détection de focus par distance et raycast
|
|
||||||
- Interactions trigger activées avec \`E\`
|
|
||||||
- Interactions grab activées avec le bouton principal de la souris
|
|
||||||
- Les objets gameplay avec physique peuvent être montés dans le contenu de stage sans remplacer la collision octree du joueur
|
|
||||||
- Prompt d'interaction affiché pour les interactions trigger
|
|
||||||
|
|
||||||
## Gameplay de réparation
|
|
||||||
|
|
||||||
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
|
|
||||||
- Le playground physics debug monte le même \`RepairGame\` réutilisable dans des zones \`Bike\`, \`Pylone\` et \`Farm\`, afin de peaufiner chaque state avec un placement isolé avant déplacement vers la carte de production
|
|
||||||
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`, avec nodes cassés, placeholders cibles, timing de scan et timing de réassemblage propres à chaque mission
|
|
||||||
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, indicateur de verrouillage de déplacement pendant la réparation active, interaction trigger sur la mallette, traverse des placeholders de mallette, placement avec snap vers placeholder, feedback de dépôt des pièces cassées, touche \`E\`, hold deux poings, transition de modèle explosé, réassemblage inverse avec particules, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, feedback de validation de la bonne pièce et complétion de mission
|
|
||||||
|
|
||||||
## Audio
|
|
||||||
|
|
||||||
- Volumes par catégorie pour la musique, les SFX et les dialogues
|
|
||||||
- Lecture de musique en boucle via \`AudioManager\`
|
|
||||||
- Lecture de sons one-shot pour les SFX et les dialogues, avec pool simple par son
|
|
||||||
- Pan stéréo optionnel pour les sons one-shot
|
|
||||||
|
|
||||||
## Dialogues et sous-titres
|
|
||||||
|
|
||||||
- Manifeste de dialogues dans \`public/sounds/dialogue/dialogues.json\`
|
|
||||||
- Audios de dialogue chargés depuis \`public/sounds/dialogue/\`
|
|
||||||
- Un fichier SRT par voix et par langue
|
|
||||||
- Fallback vers les sous-titres français quand le fichier de langue sélectionné manque
|
|
||||||
- Overlay de sous-titres runtime avec couleurs par speaker
|
|
||||||
- Déclenchement timecodé pour les dialogues qui définissent \`timecode\`
|
|
||||||
- File d'attente pour éviter les dialogues superposés
|
|
||||||
|
|
||||||
## Cinématiques
|
|
||||||
|
|
||||||
- Manifeste de cinématiques dans \`public/cinematics.json\`
|
|
||||||
- Déclenchement timecodé des cinématiques
|
|
||||||
- Lecture de keyframes caméra via GSAP
|
|
||||||
- Dialogue cues optionnelles synchronisées avec les timelines de cinématique
|
|
||||||
- Blocage des inputs joueur pendant une cinématique
|
|
||||||
|
|
||||||
## Menu options
|
|
||||||
|
|
||||||
- \`Esc\` ouvre et ferme le menu options en jeu
|
|
||||||
- Sliders de volume musique, SFX et dialogue
|
|
||||||
- Toggle d'affichage des sous-titres
|
|
||||||
- Choix de langue des sous-titres entre français et anglais
|
|
||||||
- Choix du runtime de réparation entre JavaScript local et serveur Python
|
|
||||||
- Action quitter qui nettoie les cookies accessibles au navigateur et retourne vers \`/\`
|
|
||||||
|
|
||||||
## Outils debug
|
|
||||||
|
|
||||||
- Le paramètre \`?debug\` active le panneau debug
|
|
||||||
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène, \`R3F Perf\`, \`Debug Overlay\` et le tuning d'interaction
|
|
||||||
- Overlay debug compact pour les contrôles de game state et le statut hand tracking
|
|
||||||
- Le changement de mission dans le panneau game-state debug déverrouille les missions repair encore \`locked\` à \`waiting\` pour accélérer les tests
|
|
||||||
- Helpers de scène debug
|
|
||||||
- Caméra libre debug
|
|
||||||
- Overlay \`r3f-perf\`
|
|
||||||
|
|
||||||
## Éditeur de carte
|
|
||||||
|
|
||||||
- Route \`/editor\` pour inspecter et éditer \`public/map.json\`
|
|
||||||
- Chargement automatique de \`public/map.json\` quand il existe
|
|
||||||
- Rendu des modèles disponibles depuis \`public/models/{name}/model.glb\` ou \`model.gltf\`
|
|
||||||
- Cubes de fallback pour les nodes dont le modèle manque
|
|
||||||
- Sélection d'objet au clic
|
|
||||||
- Modes de transformation translation, rotation et scale
|
|
||||||
- Export JSON pour télécharger la carte modifiée
|
|
||||||
- Endpoint de sauvegarde dev-server pour écrire \`public/map.json\`
|
|
||||||
- Éditeur SRT pour les sous-titres de dialogue
|
|
||||||
- Preview audio et outils de timing pour les cues SRT
|
|
||||||
- Endpoint de sauvegarde dev-server pour les fichiers SRT
|
|
||||||
- Validation du manifeste de dialogues depuis l'UI de l'éditeur
|
|
||||||
- Éditeur de manifeste dialogues avec preview et création assistée de cue SRT FR
|
|
||||||
- Éditeur de manifeste cinématiques avec keyframes caméra, dialogue cues et preview canvas
|
|
||||||
|
|
||||||
## Pas encore implémenté
|
|
||||||
|
|
||||||
- système de missions complet
|
|
||||||
- système de zones
|
|
||||||
- branches de dialogues gameplay au-delà des déclencheurs prototype actuels
|
|
||||||
- flow de chargement
|
|
||||||
- minimap et HUD de mission
|
|
||||||
- séparation complète production / debug pour les scènes gameplay
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const editorFr = `# Éditeur de carte
|
|
||||||
|
|
||||||
L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur.
|
|
||||||
|
|
||||||
## Ce qui est édité
|
|
||||||
|
|
||||||
L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json".
|
|
||||||
|
|
||||||
Chaque node décrit un objet de la scène :
|
|
||||||
|
|
||||||
- "name" : nom du dossier modèle dans "/public/models/{name}/model.glb", avec fallback vers "model.gltf"
|
|
||||||
- "type" : catégorie de l'objet
|
|
||||||
- "position" : "[x, y, z]"
|
|
||||||
- "rotation" : "[x, y, z]"
|
|
||||||
- "scale" : "[x, y, z]"
|
|
||||||
|
|
||||||
Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable.
|
|
||||||
|
|
||||||
## Workflow de base
|
|
||||||
|
|
||||||
1. Ouvrir "/editor".
|
|
||||||
2. Sélectionner un objet dans la vue 3D.
|
|
||||||
3. Choisir un mode de transformation : translation, rotation ou scale.
|
|
||||||
4. Déplacer la gizmo de transformation.
|
|
||||||
5. Utiliser undo ou redo si nécessaire.
|
|
||||||
6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev.
|
|
||||||
|
|
||||||
## Contrôles
|
|
||||||
|
|
||||||
| Action | Input |
|
|
||||||
| --- | --- |
|
|
||||||
| Sélectionner un objet | Clic sur l'objet |
|
|
||||||
| Désélectionner | "Esc" ou clic dans le vide |
|
|
||||||
| Mode translation | "T" |
|
|
||||||
| Mode rotation | "R" |
|
|
||||||
| Mode scale | "S" |
|
|
||||||
| Undo | "Ctrl+Z" |
|
|
||||||
| Redo | "Ctrl+Y" |
|
|
||||||
| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches |
|
|
||||||
| Monter / descendre | "Space", "Shift" |
|
|
||||||
|
|
||||||
## Actions fichier
|
|
||||||
|
|
||||||
### Export JSON
|
|
||||||
|
|
||||||
"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json".
|
|
||||||
|
|
||||||
### Save to server
|
|
||||||
|
|
||||||
"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite.
|
|
||||||
|
|
||||||
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
|
|
||||||
|
|
||||||
## Éditer les dialogues et sous-titres
|
|
||||||
|
|
||||||
Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres.
|
|
||||||
|
|
||||||
### Manifeste dialogues
|
|
||||||
|
|
||||||
Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main.
|
|
||||||
|
|
||||||
- \`Reload\` recharge le manifeste depuis le disque.
|
|
||||||
- \`Add\` crée un dialogue local pour la voix courante et assigne le prochain index SRT disponible.
|
|
||||||
- \`Save\` écrit le manifeste via le serveur Vite local.
|
|
||||||
- \`Preview dialogue\` joue le dialogue sélectionné avec les sous-titres dans l'éditeur.
|
|
||||||
- \`Create FR SRT cue\` crée la cue française si elle manque.
|
|
||||||
- \`Delete dialogue\` supprime localement l'entrée sélectionnée.
|
|
||||||
|
|
||||||
Après \`Add\`, il faut cliquer \`Save\` pour conserver le dialogue dans le manifeste. La cue SRT FR est écrite directement, mais le manifeste reste local tant qu'il n'est pas sauvegardé.
|
|
||||||
|
|
||||||
Les nouveaux dialogues utilisent un chemin audio placeholder comme \`/sounds/dialogue/new_dialogue_24.mp3\`. Remplace-le par un vrai MP3 avant validation finale.
|
|
||||||
|
|
||||||
### Éditeur SRT
|
|
||||||
|
|
||||||
1. Choisir une voix : \`narrateur\`, \`fermier\` ou \`electricienne\`.
|
|
||||||
2. Choisir une langue : \`FR\` ou \`EN\`.
|
|
||||||
3. Modifier le texte SRT directement dans la textarea.
|
|
||||||
4. Utiliser la preview audio pour vérifier le dialogue sélectionné.
|
|
||||||
5. Utiliser \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster le timing de la cue sélectionnée avec l'audio.
|
|
||||||
6. Utiliser \`Save SRT\` en développement local, ou \`Export SRT\` pour télécharger le fichier manuellement.
|
|
||||||
|
|
||||||
Chaque fichier SRT appartient à une voix, pas à un dialogue. Les indexes de cue doivent correspondre aux valeurs \`subtitleCueIndex\` référencées par le manifeste de dialogues.
|
|
||||||
|
|
||||||
## Valider les assets de dialogue
|
|
||||||
|
|
||||||
Utilise \`Validate\` dans le panneau SRT pour vérifier le manifeste et les assets liés.
|
|
||||||
|
|
||||||
La validation vérifie :
|
|
||||||
|
|
||||||
- \`public/sounds/dialogue/dialogues.json\`
|
|
||||||
- les fichiers audio de dialogue référencés
|
|
||||||
- les fichiers SRT français
|
|
||||||
- les indexes de cue référencés par le manifeste
|
|
||||||
|
|
||||||
Les fichiers SRT anglais manquants sont des warnings parce que le runtime retombe sur les sous-titres français.
|
|
||||||
|
|
||||||
## Éditer les cinématiques
|
|
||||||
|
|
||||||
Le panneau \`Cinematics\` permet d'éditer \`public/cinematics.json\`.
|
|
||||||
|
|
||||||
Chaque cinématique contient :
|
|
||||||
|
|
||||||
- un \`id\`
|
|
||||||
- un \`timecode\` global optionnel
|
|
||||||
- au moins deux keyframes caméra
|
|
||||||
- des dialogue cues optionnelles synchronisées avec la timeline
|
|
||||||
|
|
||||||
Les keyframes caméra définissent un temps relatif, une position caméra et une cible de regard. Les dialogue cues définissent un temps relatif et un \`dialogueId\` issu de \`dialogues.json\`.
|
|
||||||
|
|
||||||
Actions disponibles :
|
|
||||||
|
|
||||||
- \`Reload\` recharge le manifeste.
|
|
||||||
- \`Add\` crée une cinématique locale avec deux keyframes.
|
|
||||||
- \`Save\` écrit \`public/cinematics.json\` via le serveur Vite local.
|
|
||||||
- \`Preview cinematic\` joue l'animation caméra dans le canvas éditeur.
|
|
||||||
- \`Add keyframe\` et \`Remove\` modifient le chemin caméra.
|
|
||||||
- \`Add dialogue\` et \`Remove\` modifient les dialogues synchronisés.
|
|
||||||
- \`Delete cinematic\` supprime localement la cinématique sélectionnée.
|
|
||||||
|
|
||||||
Les dialogue cues sont la manière recommandée de synchroniser un dialogue avec une cinématique. Évite de donner aussi un \`timecode\` global au même dialogue dans \`dialogues.json\`, sinon il peut être lancé deux fois.
|
|
||||||
|
|
||||||
## Inspecteur JSON
|
|
||||||
|
|
||||||
Le panneau latéral affiche le JSON brut de la carte :
|
|
||||||
|
|
||||||
- sans sélection, il affiche toute la liste des nodes
|
|
||||||
- avec un objet sélectionné, il met en évidence les lignes du node sélectionné
|
|
||||||
|
|
||||||
Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde.
|
|
||||||
|
|
||||||
## Limites actuelles
|
|
||||||
|
|
||||||
- L'éditeur modifie uniquement les nodes existants.
|
|
||||||
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
|
|
||||||
- La sauvegarde production n'est pas implémentée.
|
|
||||||
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
|
|
||||||
- La sauvegarde SRT est un helper local du serveur Vite, pas une API backend de production.
|
|
||||||
- Les sauvegardes dialogues et cinématiques sont aussi des helpers locaux du serveur Vite.
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { GameStep, MainGameState } from "@/types/game";
|
||||||
|
|
||||||
|
export const GAME_STEPS: readonly GameStep[] = [
|
||||||
|
"intro",
|
||||||
|
"start-intro",
|
||||||
|
"naming",
|
||||||
|
"bienvenue",
|
||||||
|
"star-move",
|
||||||
|
"mission2",
|
||||||
|
"searching",
|
||||||
|
"helped",
|
||||||
|
"manipulation",
|
||||||
|
"outOfFabrik",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MAIN_GAME_STATES: readonly MainGameState[] = [
|
||||||
|
"intro",
|
||||||
|
"ebike",
|
||||||
|
"pylon",
|
||||||
|
"farm",
|
||||||
|
"outro",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const GAME_STEP_VALUES: ReadonlySet<string> = new Set(GAME_STEPS);
|
||||||
|
const MAIN_GAME_STATE_VALUES: ReadonlySet<string> = new Set(MAIN_GAME_STATES);
|
||||||
|
|
||||||
|
export function isGameStep(value: unknown): value is GameStep {
|
||||||
|
return typeof value === "string" && GAME_STEP_VALUES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMainGameState(value: unknown): value is MainGameState {
|
||||||
|
return typeof value === "string" && MAIN_GAME_STATE_VALUES.has(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
export interface StageAnchorConfig {
|
||||||
|
color: string;
|
||||||
|
position: Vector3Tuple;
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTRO_STAGE_ANCHOR: StageAnchorConfig = {
|
||||||
|
color: "#7dd3fc",
|
||||||
|
position: [0, 4, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OUTRO_STAGE_ANCHOR: StageAnchorConfig = {
|
||||||
|
color: "#fb7185",
|
||||||
|
position: [0, 6, 10],
|
||||||
|
scale: 1.25,
|
||||||
|
};
|
||||||
@@ -1,5 +1,39 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import type {
|
||||||
|
RepairMissionId,
|
||||||
|
RepairMissionTriggerConfig,
|
||||||
|
} from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
export const EBIKE_REPAIR_POSITION = [
|
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
||||||
|
Record<RepairMissionId, string>
|
||||||
|
> = {
|
||||||
|
pylon: "repair:pylon",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EBIKE_REPAIR_POSITION = [
|
||||||
42.2399, 4.5484, 34.6468,
|
42.2399, 4.5484, 34.6468,
|
||||||
] as const satisfies Vector3Tuple;
|
] as const satisfies Vector3Tuple;
|
||||||
|
|
||||||
|
const REPAIR_MISSION_POSITIONS = {
|
||||||
|
ebike: EBIKE_REPAIR_POSITION,
|
||||||
|
pylon: [64, 0, -66],
|
||||||
|
farm: [-24, 0, 42],
|
||||||
|
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
||||||
|
|
||||||
|
export const REPAIR_MISSION_TRIGGERS = [
|
||||||
|
{
|
||||||
|
mission: "ebike",
|
||||||
|
label: "Réparer l'e-bike",
|
||||||
|
radius: 4,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly RepairMissionTriggerConfig[];
|
||||||
|
|
||||||
|
export const REPAIR_MISSION_POSITION_ENTRIES = Object.entries(
|
||||||
|
REPAIR_MISSION_POSITIONS,
|
||||||
|
).map(([mission, position]) => ({
|
||||||
|
mission: mission as RepairMissionId,
|
||||||
|
position,
|
||||||
|
})) satisfies readonly {
|
||||||
|
mission: RepairMissionId;
|
||||||
|
position: Vector3Tuple;
|
||||||
|
}[];
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type {
|
||||||
|
MissionStep,
|
||||||
|
RepairMissionId,
|
||||||
|
} from "@/types/gameplay/repairMission";
|
||||||
|
import { REPAIR_MISSION_IDS } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
|
const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
|
||||||
|
REPAIR_MISSION_IDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MISSION_STEPS = [
|
||||||
|
"locked",
|
||||||
|
"waiting",
|
||||||
|
"inspected",
|
||||||
|
"fragmented",
|
||||||
|
"scanning",
|
||||||
|
"repairing",
|
||||||
|
"reassembling",
|
||||||
|
"done",
|
||||||
|
] as const satisfies readonly MissionStep[];
|
||||||
|
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
|
||||||
|
|
||||||
|
export function isRepairMissionId(value: string): value is RepairMissionId {
|
||||||
|
return REPAIR_MISSION_ID_VALUES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMissionStep(value: string): value is MissionStep {
|
||||||
|
return MISSION_STEP_VALUES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextMissionStep(step: MissionStep): MissionStep {
|
||||||
|
switch (step) {
|
||||||
|
case "locked":
|
||||||
|
return "waiting";
|
||||||
|
case "waiting":
|
||||||
|
return "inspected";
|
||||||
|
case "inspected":
|
||||||
|
return "fragmented";
|
||||||
|
case "fragmented":
|
||||||
|
return "scanning";
|
||||||
|
case "scanning":
|
||||||
|
return "repairing";
|
||||||
|
case "repairing":
|
||||||
|
return "reassembling";
|
||||||
|
case "reassembling":
|
||||||
|
case "done":
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreviousMissionStep(step: MissionStep): MissionStep {
|
||||||
|
switch (step) {
|
||||||
|
case "locked":
|
||||||
|
case "waiting":
|
||||||
|
return "locked";
|
||||||
|
case "inspected":
|
||||||
|
return "waiting";
|
||||||
|
case "fragmented":
|
||||||
|
return "inspected";
|
||||||
|
case "scanning":
|
||||||
|
return "fragmented";
|
||||||
|
case "repairing":
|
||||||
|
return "scanning";
|
||||||
|
case "reassembling":
|
||||||
|
return "repairing";
|
||||||
|
case "done":
|
||||||
|
return "reassembling";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,8 @@
|
|||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
|
||||||
import type {
|
import type {
|
||||||
ModelTransformProps,
|
RepairMissionCaseConfig,
|
||||||
Vector3Scale,
|
RepairMissionConfig,
|
||||||
Vector3Tuple,
|
RepairMissionId,
|
||||||
} from "@/types/three/three";
|
} from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
export interface RepairMissionCaseConfig {
|
|
||||||
position: Vector3Tuple;
|
|
||||||
rotation: Vector3Tuple;
|
|
||||||
scale: Vector3Scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepairMissionPartConfig {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
nodeName?: string;
|
|
||||||
placeholderName?: string;
|
|
||||||
modelPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepairMissionConfig {
|
|
||||||
id: RepairMissionId;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
modelPath: string;
|
|
||||||
modelScale?: ModelTransformProps["scale"];
|
|
||||||
stageUiPath: string;
|
|
||||||
interactUiPath: string;
|
|
||||||
brokenUiPath: string;
|
|
||||||
case: RepairMissionCaseConfig;
|
|
||||||
reassemblySeconds?: number;
|
|
||||||
requiredReplacementPartId: string;
|
|
||||||
scanPartSeconds?: number;
|
|
||||||
brokenParts: readonly RepairMissionPartConfig[];
|
|
||||||
replacementParts: readonly RepairMissionPartConfig[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm";
|
const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm";
|
||||||
const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm";
|
const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm";
|
||||||
@@ -46,47 +14,47 @@ const DEFAULT_REPAIR_CASE = {
|
|||||||
} satisfies RepairMissionCaseConfig;
|
} satisfies RepairMissionCaseConfig;
|
||||||
|
|
||||||
export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||||
bike: {
|
ebike: {
|
||||||
id: "bike",
|
id: "ebike",
|
||||||
label: "E-bike",
|
label: "E-bike",
|
||||||
description:
|
description:
|
||||||
"Repair the damaged cooling module before relaunching the bike",
|
"Repair the damaged cooling module before relaunching the bike",
|
||||||
modelPath: "/models/ebike/model.gltf",
|
modelPath: "/models/ebike/model.gltf",
|
||||||
modelScale: 0.5,
|
modelScale: 0.3,
|
||||||
stageUiPath: "/assets/UI/ebike.webm",
|
stageUiPath: "/assets/UI/ebike.webm",
|
||||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
requiredReplacementPartId: "bike-cooling-core-replacement",
|
requiredReplacementPartId: "ebike-cooling-core-replacement",
|
||||||
brokenParts: [
|
brokenParts: [
|
||||||
{
|
{
|
||||||
id: "bike-cooling-core",
|
id: "ebike-cooling-core",
|
||||||
label: "Cooling core",
|
label: "Cooling core",
|
||||||
modelPath: "/models/refroidisseur/model.gltf",
|
modelPath: "/models/refroidisseur/model.gltf",
|
||||||
nodeName: "refroidisseur",
|
nodeName: "refroidisseur",
|
||||||
placeholderName: "placeholder_1",
|
caseSlotName: "placeholder_1",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
replacementParts: [
|
replacementParts: [
|
||||||
{
|
{
|
||||||
id: "bike-cooling-core-replacement",
|
id: "ebike-cooling-core-replacement",
|
||||||
label: "Replacement cooling core",
|
label: "Replacement cooling core",
|
||||||
modelPath: "/models/refroidisseur/model.gltf",
|
modelPath: "/models/refroidisseur/model.gltf",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bike-radio-decoy",
|
id: "ebike-radio-distractor",
|
||||||
label: "Radio module",
|
label: "Radio module",
|
||||||
modelPath: "/models/talkie/model.gltf",
|
modelPath: "/models/talkie/model.gltf",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bike-glove-decoy",
|
id: "ebike-glove-distractor",
|
||||||
label: "Insulation glove",
|
label: "Insulation glove",
|
||||||
modelPath: "/models/gant_l/model.gltf",
|
modelPath: "/models/gant_l/model.gltf",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
pylone: {
|
pylon: {
|
||||||
id: "pylone",
|
id: "pylon",
|
||||||
label: "Power pylon",
|
label: "Power pylon",
|
||||||
description:
|
description:
|
||||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||||
@@ -96,42 +64,42 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
reassemblySeconds: 1.8,
|
reassemblySeconds: 1.8,
|
||||||
requiredReplacementPartId: "pylone-grid-relay-replacement",
|
requiredReplacementPartId: "pylon-grid-relay-replacement",
|
||||||
scanPartSeconds: 1.4,
|
scanPartSeconds: 1.4,
|
||||||
brokenParts: [
|
brokenParts: [
|
||||||
{
|
{
|
||||||
id: "pylone-grid-relay",
|
id: "pylon-grid-relay",
|
||||||
label: "Grid relay",
|
label: "Grid relay",
|
||||||
nodeName: "lampe",
|
nodeName: "lampe",
|
||||||
placeholderName: "placeholder_1",
|
caseSlotName: "placeholder_1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pylone-damaged-panel",
|
id: "pylon-damaged-panel",
|
||||||
label: "Damaged solar panel",
|
label: "Damaged solar panel",
|
||||||
nodeName: "panneau2",
|
nodeName: "panneau2",
|
||||||
placeholderName: "placeholder_2",
|
caseSlotName: "placeholder_2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
replacementParts: [
|
replacementParts: [
|
||||||
{
|
{
|
||||||
id: "pylone-grid-relay-replacement",
|
id: "pylon-grid-relay-replacement",
|
||||||
label: "Replacement grid relay",
|
label: "Replacement grid relay",
|
||||||
modelPath: "/models/pylone/model.gltf",
|
modelPath: "/models/pylone/model.gltf",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pylone-stone-decoy",
|
id: "pylon-stone-distractor",
|
||||||
label: "Stone counterweight",
|
label: "Stone counterweight",
|
||||||
modelPath: "/models/galet/model.gltf",
|
modelPath: "/models/galet/model.gltf",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pylone-cooling-decoy",
|
id: "pylon-cooling-distractor",
|
||||||
label: "Cooling core",
|
label: "Cooling core",
|
||||||
modelPath: "/models/refroidisseur/model.gltf",
|
modelPath: "/models/refroidisseur/model.gltf",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ferme: {
|
farm: {
|
||||||
id: "ferme",
|
id: "farm",
|
||||||
label: "Vertical farm",
|
label: "Vertical farm",
|
||||||
description:
|
description:
|
||||||
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
||||||
@@ -141,33 +109,33 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
reassemblySeconds: 1.2,
|
reassemblySeconds: 1.2,
|
||||||
requiredReplacementPartId: "ferme-irrigation-pump-replacement",
|
requiredReplacementPartId: "farm-irrigation-pump-replacement",
|
||||||
scanPartSeconds: 0.9,
|
scanPartSeconds: 0.9,
|
||||||
brokenParts: [
|
brokenParts: [
|
||||||
{
|
{
|
||||||
id: "ferme-irrigation-pump",
|
id: "farm-irrigation-pump",
|
||||||
label: "Irrigation pump",
|
label: "Irrigation pump",
|
||||||
placeholderName: "placeholder_1",
|
caseSlotName: "placeholder_1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ferme-humidity-sensor",
|
id: "farm-humidity-sensor",
|
||||||
label: "Humidity sensor",
|
label: "Humidity sensor",
|
||||||
placeholderName: "placeholder_2",
|
caseSlotName: "placeholder_2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
replacementParts: [
|
replacementParts: [
|
||||||
{
|
{
|
||||||
id: "ferme-irrigation-pump-replacement",
|
id: "farm-irrigation-pump-replacement",
|
||||||
label: "Replacement irrigation pump",
|
label: "Replacement irrigation pump",
|
||||||
modelPath: "/models/fermeverticale/model.gltf",
|
modelPath: "/models/fermeverticale/model.gltf",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ferme-tree-decoy",
|
id: "farm-tree-distractor",
|
||||||
label: "Tree sensor",
|
label: "Tree sensor",
|
||||||
modelPath: "/models/sapin/model.gltf",
|
modelPath: "/models/sapin/model.gltf",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ferme-radio-decoy",
|
id: "farm-radio-distractor",
|
||||||
label: "Radio module",
|
label: "Radio module",
|
||||||
modelPath: "/models/talkie/model.gltf",
|
modelPath: "/models/talkie/model.gltf",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws";
|
|
||||||
const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws";
|
|
||||||
|
|
||||||
export const HAND_TRACKING_FRAME_WIDTH = 320;
|
export const HAND_TRACKING_FRAME_WIDTH = 320;
|
||||||
export const HAND_TRACKING_FRAME_HEIGHT = 240;
|
export const HAND_TRACKING_FRAME_HEIGHT = 240;
|
||||||
export const HAND_TRACKING_TARGET_FPS = 10;
|
export const HAND_TRACKING_TARGET_FPS = 10;
|
||||||
@@ -11,15 +8,3 @@ export const HAND_TRACKING_BROWSER_WASM_URL =
|
|||||||
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
|
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
|
||||||
export const HAND_TRACKING_BROWSER_MODEL_URL =
|
export const HAND_TRACKING_BROWSER_MODEL_URL =
|
||||||
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
|
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
|
||||||
|
|
||||||
export function getHandTrackingWsUrl(): string {
|
|
||||||
const configuredUrl = import.meta.env.VITE_HAND_TRACKING_WS_URL;
|
|
||||||
|
|
||||||
if (configuredUrl) {
|
|
||||||
return configuredUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return import.meta.env.DEV
|
|
||||||
? HAND_TRACKING_LOCAL_WS_URL
|
|
||||||
: HAND_TRACKING_PROD_WS_URL;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const PLAYER_EYE_HEIGHT = 1.75;
|
|||||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||||
|
|
||||||
export const PLAYER_WALK_SPEED = 11;
|
export const PLAYER_WALK_SPEED = 11;
|
||||||
|
export const PLAYER_EBIKE_SPEED = 25;
|
||||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||||
export const PLAYER_JUMP_SPEED = 9;
|
export const PLAYER_JUMP_SPEED = 9;
|
||||||
export const PLAYER_GRAVITY = 30;
|
export const PLAYER_GRAVITY = 30;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
export type CharacterId = "electricienne" | "gerant" | "fermier";
|
||||||
|
|
||||||
|
export interface CharacterConfig {
|
||||||
|
id: CharacterId;
|
||||||
|
label: string;
|
||||||
|
modelPath: string;
|
||||||
|
position: Vector3Tuple;
|
||||||
|
rotation: Vector3Tuple;
|
||||||
|
scale: Vector3Tuple;
|
||||||
|
animations: readonly string[];
|
||||||
|
defaultAnimation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHARACTER_CONFIGS = {
|
||||||
|
electricienne: {
|
||||||
|
id: "electricienne",
|
||||||
|
label: "Electricienne",
|
||||||
|
modelPath: "/models/electricienne-animated/model.gltf",
|
||||||
|
position: [-40.5, 0, 45.5],
|
||||||
|
rotation: [0, -0.35, 0],
|
||||||
|
scale: [1, 1, 1],
|
||||||
|
animations: ["Dance"],
|
||||||
|
defaultAnimation: "Dance",
|
||||||
|
},
|
||||||
|
gerant: {
|
||||||
|
id: "gerant",
|
||||||
|
label: "Gerant",
|
||||||
|
modelPath: "/models/gerant-animated/model.gltf",
|
||||||
|
position: [45.2, 0, 45.5],
|
||||||
|
rotation: [0, -1.55, 0],
|
||||||
|
scale: [1, 1, 1],
|
||||||
|
animations: ["idle", "walk"],
|
||||||
|
defaultAnimation: "idle",
|
||||||
|
},
|
||||||
|
fermier: {
|
||||||
|
id: "fermier",
|
||||||
|
label: "Fermier",
|
||||||
|
modelPath: "/models/fermier-animated/model.gltf",
|
||||||
|
position: [-6.5, 0, -69.5],
|
||||||
|
rotation: [0, -1.18, 0],
|
||||||
|
scale: [1, 1, 1],
|
||||||
|
animations: ["idle", "walk"],
|
||||||
|
defaultAnimation: "idle",
|
||||||
|
},
|
||||||
|
} satisfies Record<CharacterId, CharacterConfig>;
|
||||||
|
|
||||||
|
export const CHARACTER_IDS = [
|
||||||
|
"electricienne",
|
||||||
|
"gerant",
|
||||||
|
"fermier",
|
||||||
|
] as const satisfies readonly CharacterId[];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const CHUNK_CONFIG = {
|
||||||
|
enabled: true,
|
||||||
|
chunkSize: 35,
|
||||||
|
loadRadius: 50,
|
||||||
|
unloadRadius: 65,
|
||||||
|
updateInterval: 250,
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
|
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
|
||||||
export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
|
export const GAME_SCENE_SKY_FALLBACK_MODEL_PATH = "/models/sky/model.glb";
|
||||||
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
|
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
|
||||||
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
|
export const GAME_SCENE_SKY_FALLBACK_MODEL_SCALE = 1;
|
||||||
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
|
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
|
||||||
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
|
|
||||||
|
|
||||||
export type FogMode = "linear" | "exp2";
|
export type FogMode = "linear" | "exp2";
|
||||||
|
|
||||||
export const FOG_CONFIG = {
|
export const FOG_CONFIG = {
|
||||||
@@ -28,13 +26,3 @@ export interface FogState {
|
|||||||
mode: FogMode;
|
mode: FogMode;
|
||||||
near: number;
|
near: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CHUNK_CONFIG = {
|
|
||||||
enabled: true,
|
|
||||||
chunkSize: 35,
|
|
||||||
loadRadius: 50,
|
|
||||||
unloadRadius: 65,
|
|
||||||
updateInterval: 250,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GROUND_PLANE_COLOR = TERRAIN_COLORS.grass1.hex;
|
|
||||||
|
|||||||
@@ -6,8 +6,4 @@ export const GRAPHICS_DEFAULTS = {
|
|||||||
grassDensity: 1.0,
|
grassDensity: 1.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GRAPHICS_BOUNDS = {
|
|
||||||
grassDensity: { min: 0.1, max: 2.0, step: 0.1 },
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GraphicsState = typeof GRAPHICS_DEFAULTS;
|
export type GraphicsState = typeof GRAPHICS_DEFAULTS;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const GRASS_CONFIG = {
|
|||||||
patchSize: 30,
|
patchSize: 30,
|
||||||
bladeCount: 32000,
|
bladeCount: 32000,
|
||||||
bladeWidth: 0.08,
|
bladeWidth: 0.08,
|
||||||
maxBladeHeight: 0.56,
|
maxBladeHeight: 0.67,
|
||||||
randomHeightAmount: 0.25,
|
randomHeightAmount: 0.25,
|
||||||
surfaceOffset: 0.025,
|
surfaceOffset: 0.025,
|
||||||
heightTextureSize: 128,
|
heightTextureSize: 128,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const AMBIENT_LIGHT_COLOR = "#dfe7d8";
|
const AMBIENT_LIGHT_COLOR = "#dfe7d8";
|
||||||
export const SUN_LIGHT_COLOR = "#ffe2bf";
|
const SUN_LIGHT_COLOR = "#ffe2bf";
|
||||||
|
|
||||||
export const LIGHTING_DEFAULTS = {
|
export const LIGHTING_DEFAULTS = {
|
||||||
ambientColor: AMBIENT_LIGHT_COLOR,
|
ambientColor: AMBIENT_LIGHT_COLOR,
|
||||||
|
|||||||
+52
-11
@@ -2,6 +2,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
boiteauxlettres: {
|
boiteauxlettres: {
|
||||||
mapName: "boiteauxlettres",
|
mapName: "boiteauxlettres",
|
||||||
modelPath: "/models/boiteauxlettres/model.gltf",
|
modelPath: "/models/boiteauxlettres/model.gltf",
|
||||||
|
scaleMultiplier: 2,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -9,6 +10,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
pylone: {
|
pylone: {
|
||||||
mapName: "pylone",
|
mapName: "pylone",
|
||||||
modelPath: "/models/pylone/model.gltf",
|
modelPath: "/models/pylone/model.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -16,6 +18,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
immeuble1: {
|
immeuble1: {
|
||||||
mapName: "immeuble1",
|
mapName: "immeuble1",
|
||||||
modelPath: "/models/immeuble1/model.gltf",
|
modelPath: "/models/immeuble1/model.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -23,6 +26,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
maison1: {
|
maison1: {
|
||||||
mapName: "maison1",
|
mapName: "maison1",
|
||||||
modelPath: "/models/maison1/model.gltf",
|
modelPath: "/models/maison1/model.gltf",
|
||||||
|
scaleMultiplier: 3,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -30,6 +34,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
eolienne: {
|
eolienne: {
|
||||||
mapName: "eolienne",
|
mapName: "eolienne",
|
||||||
modelPath: "/models/eolienne/model.gltf",
|
modelPath: "/models/eolienne/model.gltf",
|
||||||
|
scaleMultiplier: 0.85,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -37,6 +42,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
parcebike: {
|
parcebike: {
|
||||||
mapName: "parcebike",
|
mapName: "parcebike",
|
||||||
modelPath: "/models/parcebike/model.gltf",
|
modelPath: "/models/parcebike/model.gltf",
|
||||||
|
scaleMultiplier: 2,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -44,6 +50,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
panneauaffichage: {
|
panneauaffichage: {
|
||||||
mapName: "panneauaffichage",
|
mapName: "panneauaffichage",
|
||||||
modelPath: "/models/panneauaffichage/model.gltf",
|
modelPath: "/models/panneauaffichage/model.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -51,6 +58,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
panneauclassique: {
|
panneauclassique: {
|
||||||
mapName: "panneauclassique",
|
mapName: "panneauclassique",
|
||||||
modelPath: "/models/panneauclassique/model.gltf",
|
modelPath: "/models/panneauclassique/model.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -58,6 +66,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
panneaufleche: {
|
panneaufleche: {
|
||||||
mapName: "panneaufleche",
|
mapName: "panneaufleche",
|
||||||
modelPath: "/models/panneaufleche/model.gltf",
|
modelPath: "/models/panneaufleche/model.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -65,23 +74,55 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
panneausolaire: {
|
panneausolaire: {
|
||||||
mapName: "panneausolaire",
|
mapName: "panneausolaire",
|
||||||
modelPath: "/models/panneausolaire/model.gltf",
|
modelPath: "/models/panneausolaire/model.gltf",
|
||||||
|
scaleMultiplier: 0.85,
|
||||||
castShadow: true,
|
castShadow: true,
|
||||||
receiveShadow: true,
|
receiveShadow: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type MapInstancingAssetType = keyof typeof MAP_INSTANCING_ASSETS;
|
const MAP_SINGLE_MODEL_SCALE_MULTIPLIERS = {
|
||||||
|
ebike: 0.3,
|
||||||
|
} as const satisfies Record<string, number>;
|
||||||
|
|
||||||
|
export function getMapSingleModelScaleMultiplier(name: string): number {
|
||||||
|
return (
|
||||||
|
MAP_SINGLE_MODEL_SCALE_MULTIPLIERS[
|
||||||
|
name as keyof typeof MAP_SINGLE_MODEL_SCALE_MULTIPLIERS
|
||||||
|
] ?? 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMapInstancedModelScaleMultiplier(name: string): number {
|
||||||
|
return (
|
||||||
|
Object.values(MAP_INSTANCING_ASSETS).find(
|
||||||
|
(config) => config.mapName === name,
|
||||||
|
)?.scaleMultiplier ?? 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMapModelScaleMultiplier(name: string): number {
|
||||||
|
return (
|
||||||
|
getMapSingleModelScaleMultiplier(name) *
|
||||||
|
getMapInstancedModelScaleMultiplier(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAP_INSTANCING_ASSET_TYPES = [
|
||||||
|
"boiteauxlettres",
|
||||||
|
"pylone",
|
||||||
|
"immeuble1",
|
||||||
|
"maison1",
|
||||||
|
"eolienne",
|
||||||
|
"parcebike",
|
||||||
|
"panneauaffichage",
|
||||||
|
"panneauclassique",
|
||||||
|
"panneaufleche",
|
||||||
|
"panneausolaire",
|
||||||
|
] as const satisfies readonly (keyof typeof MAP_INSTANCING_ASSETS)[];
|
||||||
|
|
||||||
|
export type MapInstancingAssetType =
|
||||||
|
(typeof MAP_INSTANCING_ASSET_TYPES)[number];
|
||||||
|
|
||||||
export type MapInstancingAssetConfig =
|
export type MapInstancingAssetConfig =
|
||||||
(typeof MAP_INSTANCING_ASSETS)[MapInstancingAssetType];
|
(typeof MAP_INSTANCING_ASSETS)[MapInstancingAssetType];
|
||||||
|
|
||||||
export const MAP_INSTANCED_NODE_NAMES: ReadonlySet<string> = new Set(
|
|
||||||
Object.values(MAP_INSTANCING_ASSETS)
|
|
||||||
.filter((config) => config.enabled)
|
|
||||||
.map((config) => config.mapName),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function isInstancedMapNodeName(name: string): boolean {
|
|
||||||
return MAP_INSTANCED_NODE_NAMES.has(name);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
export type MapPerformanceGroupName =
|
||||||
|
| "vegetation"
|
||||||
|
| "crops"
|
||||||
|
| "trees"
|
||||||
|
| "buildings"
|
||||||
|
| "landmarks"
|
||||||
|
| "props"
|
||||||
|
| "terrain"
|
||||||
|
| "sky";
|
||||||
|
|
||||||
|
export type MapPerformanceModelName =
|
||||||
|
| "buisson"
|
||||||
|
| "arbre"
|
||||||
|
| "sapin"
|
||||||
|
| "champdeble"
|
||||||
|
| "champdesoja"
|
||||||
|
| "champsdetournesol"
|
||||||
|
| "potager"
|
||||||
|
| "ecole"
|
||||||
|
| "generateur"
|
||||||
|
| "fermeverticale"
|
||||||
|
| "lafabrik"
|
||||||
|
| "immeuble1"
|
||||||
|
| "eolienne"
|
||||||
|
| "pylone"
|
||||||
|
| "boiteauxlettres"
|
||||||
|
| "maison1"
|
||||||
|
| "panneauaffichage"
|
||||||
|
| "panneauclassique"
|
||||||
|
| "panneaufleche"
|
||||||
|
| "panneausolaire"
|
||||||
|
| "parcebike"
|
||||||
|
| "terrain"
|
||||||
|
| "sky";
|
||||||
|
|
||||||
|
export const MAP_PERFORMANCE_GROUP_NAMES: readonly MapPerformanceGroupName[] = [
|
||||||
|
"vegetation",
|
||||||
|
"crops",
|
||||||
|
"trees",
|
||||||
|
"buildings",
|
||||||
|
"landmarks",
|
||||||
|
"props",
|
||||||
|
"terrain",
|
||||||
|
"sky",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MAP_PERFORMANCE_MODEL_NAMES: readonly MapPerformanceModelName[] = [
|
||||||
|
"buisson",
|
||||||
|
"arbre",
|
||||||
|
"sapin",
|
||||||
|
"champdeble",
|
||||||
|
"champdesoja",
|
||||||
|
"champsdetournesol",
|
||||||
|
"potager",
|
||||||
|
"ecole",
|
||||||
|
"generateur",
|
||||||
|
"fermeverticale",
|
||||||
|
"lafabrik",
|
||||||
|
"immeuble1",
|
||||||
|
"eolienne",
|
||||||
|
"pylone",
|
||||||
|
"boiteauxlettres",
|
||||||
|
"maison1",
|
||||||
|
"panneauaffichage",
|
||||||
|
"panneauclassique",
|
||||||
|
"panneaufleche",
|
||||||
|
"panneausolaire",
|
||||||
|
"parcebike",
|
||||||
|
"terrain",
|
||||||
|
"sky",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MAP_PERFORMANCE_MODEL_GROUPS: Record<
|
||||||
|
MapPerformanceModelName,
|
||||||
|
readonly MapPerformanceGroupName[]
|
||||||
|
> = {
|
||||||
|
buisson: ["vegetation"],
|
||||||
|
arbre: ["vegetation", "trees"],
|
||||||
|
sapin: ["vegetation", "trees"],
|
||||||
|
champdeble: ["vegetation", "crops"],
|
||||||
|
champdesoja: ["vegetation", "crops"],
|
||||||
|
champsdetournesol: ["vegetation", "crops"],
|
||||||
|
potager: ["vegetation", "crops"],
|
||||||
|
ecole: ["buildings", "landmarks"],
|
||||||
|
generateur: ["landmarks"],
|
||||||
|
fermeverticale: ["buildings", "landmarks"],
|
||||||
|
lafabrik: ["buildings", "landmarks"],
|
||||||
|
immeuble1: ["buildings"],
|
||||||
|
eolienne: ["props"],
|
||||||
|
pylone: ["props"],
|
||||||
|
boiteauxlettres: ["props"],
|
||||||
|
maison1: ["buildings"],
|
||||||
|
panneauaffichage: ["props"],
|
||||||
|
panneauclassique: ["props"],
|
||||||
|
panneaufleche: ["props"],
|
||||||
|
panneausolaire: ["props"],
|
||||||
|
parcebike: ["props"],
|
||||||
|
terrain: ["terrain"],
|
||||||
|
sky: ["sky"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
|
|
||||||
|
export const INITIAL_SCENE_LOADING_STATE: SceneLoadingState = {
|
||||||
|
currentStep: "Initialisation du jeu",
|
||||||
|
progress: 0,
|
||||||
|
status: "loading",
|
||||||
|
};
|
||||||
@@ -1,16 +1,9 @@
|
|||||||
import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
|
import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
|
||||||
|
|
||||||
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
||||||
export const TERRAIN_SURFACE_COLOR_TOLERANCE = 15;
|
|
||||||
export const TERRAIN_SURFACE_PROJECTION = {
|
|
||||||
flipX: false,
|
|
||||||
flipZ: true,
|
|
||||||
offsetX: 0,
|
|
||||||
offsetZ: 0,
|
|
||||||
};
|
|
||||||
export const TERRAIN_WATER_HEIGHT = 0.8;
|
export const TERRAIN_WATER_HEIGHT = 0.8;
|
||||||
export const TERRAIN_TILE_SIZE = 1;
|
|
||||||
export const GRASS_BASE_COLOR = "#1a3a1a";
|
const TERRAIN_TILE_SIZE = 1;
|
||||||
|
|
||||||
export const TERRAIN_COLORS = {
|
export const TERRAIN_COLORS = {
|
||||||
grass1: {
|
grass1: {
|
||||||
@@ -61,5 +54,3 @@ export const TERRAIN_COLORS = {
|
|||||||
kind: "rock",
|
kind: "rock",
|
||||||
},
|
},
|
||||||
} satisfies Record<string, TerrainSurfaceColorConfig>;
|
} satisfies Record<string, TerrainSurfaceColorConfig>;
|
||||||
|
|
||||||
export type TerrainColorKey = keyof typeof TERRAIN_COLORS;
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
export const VEGETATION_TYPES = {
|
||||||
|
buissons: {
|
||||||
|
mapName: "buisson",
|
||||||
|
modelPath: "/models/buisson/model.gltf",
|
||||||
|
scaleMultiplier: 1.5,
|
||||||
|
castShadow: true,
|
||||||
|
receiveShadow: true,
|
||||||
|
windStrength: 0.06,
|
||||||
|
rotationOffset: [0, 0, 0],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
sapin: {
|
||||||
|
mapName: "sapin",
|
||||||
|
modelPath: "/models/sapin/model.gltf",
|
||||||
|
scaleMultiplier: 4,
|
||||||
|
castShadow: true,
|
||||||
|
receiveShadow: true,
|
||||||
|
windStrength: 0.12,
|
||||||
|
rotationOffset: [0, 0, 0],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
arbre: {
|
||||||
|
mapName: "arbre",
|
||||||
|
modelPath: "/models/arbre/model.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
|
castShadow: true,
|
||||||
|
receiveShadow: true,
|
||||||
|
windStrength: 0.15,
|
||||||
|
rotationOffset: [0, 0, 0],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
champdeble: {
|
||||||
|
mapName: "champdeble",
|
||||||
|
modelPath: "/models/champdeble/model.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
|
castShadow: true,
|
||||||
|
receiveShadow: true,
|
||||||
|
windStrength: 0.15,
|
||||||
|
rotationOffset: [0, 0, 0],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
champdesoja: {
|
||||||
|
mapName: "champdesoja",
|
||||||
|
modelPath: "/models/champdesoja/model.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
|
castShadow: true,
|
||||||
|
receiveShadow: true,
|
||||||
|
windStrength: 0.15,
|
||||||
|
rotationOffset: [0, 0, 0],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
champsdetournesol: {
|
||||||
|
mapName: "champsdetournesol",
|
||||||
|
modelPath: "/models/champsdetournesol/model.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
|
castShadow: true,
|
||||||
|
receiveShadow: true,
|
||||||
|
windStrength: 0.15,
|
||||||
|
rotationOffset: [0, 0, 0],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
potager: {
|
||||||
|
mapName: "potager",
|
||||||
|
modelPath: "/models/potager/potager.gltf",
|
||||||
|
scaleMultiplier: 1,
|
||||||
|
castShadow: true,
|
||||||
|
receiveShadow: true,
|
||||||
|
windStrength: 0,
|
||||||
|
rotationOffset: [0, 0, 0],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const VEGETATION_TYPE_KEYS = [
|
||||||
|
"buissons",
|
||||||
|
"sapin",
|
||||||
|
"arbre",
|
||||||
|
"champdeble",
|
||||||
|
"champdesoja",
|
||||||
|
"champsdetournesol",
|
||||||
|
"potager",
|
||||||
|
] as const satisfies readonly (keyof typeof VEGETATION_TYPES)[];
|
||||||
|
|
||||||
|
export type VegetationType = (typeof VEGETATION_TYPE_KEYS)[number];
|
||||||
|
|
||||||
|
export const VEGETATION_MAP_NODE_NAMES: ReadonlySet<string> = new Set(
|
||||||
|
Object.values(VEGETATION_TYPES)
|
||||||
|
.filter((config) => config.enabled)
|
||||||
|
.map((config) => config.mapName),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getVegetationModelScaleMultiplier(name: string): number {
|
||||||
|
return (
|
||||||
|
Object.values(VEGETATION_TYPES).find((config) => config.mapName === name)
|
||||||
|
?.scaleMultiplier ?? 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES = new Set([
|
||||||
|
"Scene",
|
||||||
|
"blocking",
|
||||||
|
"terrain",
|
||||||
|
]);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export const WIND_DEFAULTS = {
|
export const WIND_DEFAULTS = {
|
||||||
speed: 1.5,
|
speed: 0.8,
|
||||||
direction: 0.5584,
|
direction: 0.5584,
|
||||||
strength: 1.5,
|
strength: 1.5,
|
||||||
noiseScale: 0.5,
|
noiseScale: 0.8,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WIND_BOUNDS = {
|
export const WIND_BOUNDS = {
|
||||||
@@ -13,12 +13,3 @@ export const WIND_BOUNDS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WindState = typeof WIND_DEFAULTS;
|
export type WindState = typeof WIND_DEFAULTS;
|
||||||
|
|
||||||
export function getWindVector(wind: WindState): { x: number; z: number } {
|
|
||||||
const intensity = wind.speed * wind.strength;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: Math.cos(wind.direction) * intensity,
|
|
||||||
z: Math.sin(wind.direction) * intensity,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
|
|
||||||
import { useAnimations } from "@react-three/drei";
|
|
||||||
import type { AnimationAction, AnimationMixer } from "three";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
|
||||||
|
|
||||||
export interface CharacterAnimationConfig {
|
|
||||||
modelPath: string;
|
|
||||||
initialAnimation?: string;
|
|
||||||
fadeDuration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseCharacterAnimationReturn {
|
|
||||||
scene: THREE.Group;
|
|
||||||
actions: { [key: string]: AnimationAction | null };
|
|
||||||
names: string[];
|
|
||||||
mixer: AnimationMixer;
|
|
||||||
groupRef: React.MutableRefObject<THREE.Group | null>;
|
|
||||||
currentAnimation: string;
|
|
||||||
play: (name: string) => void;
|
|
||||||
stop: () => void;
|
|
||||||
fadeTo: (name: string, duration?: number) => void;
|
|
||||||
setAnimationSpeed: (speed: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_FADE_DURATION = 0.3;
|
|
||||||
|
|
||||||
export function useCharacterAnimation(
|
|
||||||
config: CharacterAnimationConfig,
|
|
||||||
): UseCharacterAnimationReturn {
|
|
||||||
const {
|
|
||||||
modelPath,
|
|
||||||
initialAnimation = "Idle",
|
|
||||||
fadeDuration = DEFAULT_FADE_DURATION,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
const groupRef = useRef<THREE.Group | null>(null);
|
|
||||||
const { scene, animations } = useLoggedGLTF(modelPath, {
|
|
||||||
scope: "useCharacterAnimation",
|
|
||||||
});
|
|
||||||
const model = useMemo(() => scene.clone(true), [scene]);
|
|
||||||
const { actions, names, mixer } = useAnimations(animations, groupRef);
|
|
||||||
const [currentAnimation, setCurrentAnimation] = useState(initialAnimation);
|
|
||||||
|
|
||||||
const play = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
const action = actions[name];
|
|
||||||
if (action) {
|
|
||||||
Object.values(actions).forEach((a) => {
|
|
||||||
if (a && a !== action) a.fadeOut(fadeDuration);
|
|
||||||
});
|
|
||||||
action.reset().fadeIn(fadeDuration).play();
|
|
||||||
setCurrentAnimation(name);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[actions, fadeDuration],
|
|
||||||
);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
Object.values(actions).forEach((a) => a?.fadeOut(fadeDuration));
|
|
||||||
const defaultAction = actions[initialAnimation as string];
|
|
||||||
if (defaultAction) {
|
|
||||||
defaultAction.reset().fadeIn(fadeDuration).play();
|
|
||||||
setCurrentAnimation(initialAnimation);
|
|
||||||
}
|
|
||||||
}, [actions, initialAnimation, fadeDuration]);
|
|
||||||
|
|
||||||
const fadeTo = useCallback(
|
|
||||||
(name: string, duration = fadeDuration) => {
|
|
||||||
const targetAction = actions[name];
|
|
||||||
if (targetAction) {
|
|
||||||
Object.values(actions).forEach((a) => {
|
|
||||||
if (a && a !== targetAction) a.fadeOut(duration);
|
|
||||||
});
|
|
||||||
targetAction.reset().fadeIn(duration).play();
|
|
||||||
setCurrentAnimation(name);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[actions, fadeDuration],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setAnimationSpeed = useCallback(
|
|
||||||
(speed: number) => {
|
|
||||||
Object.values(actions).forEach((action) => {
|
|
||||||
action?.setEffectiveTimeScale(speed);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[actions],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const defaultAction = actions[initialAnimation as string];
|
|
||||||
if (defaultAction) {
|
|
||||||
defaultAction.play();
|
|
||||||
}
|
|
||||||
}, [actions, initialAnimation]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
scene: model,
|
|
||||||
actions,
|
|
||||||
names,
|
|
||||||
mixer,
|
|
||||||
groupRef,
|
|
||||||
currentAnimation,
|
|
||||||
play,
|
|
||||||
stop,
|
|
||||||
fadeTo,
|
|
||||||
setAnimationSpeed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import {
|
||||||
|
CHARACTER_CONFIGS,
|
||||||
|
CHARACTER_IDS,
|
||||||
|
} from "@/data/world/characters/characterConfig";
|
||||||
|
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
|
||||||
|
|
||||||
|
function createAnimationOptions(
|
||||||
|
animations: readonly string[],
|
||||||
|
): Record<string, string> {
|
||||||
|
if (animations.length === 0) {
|
||||||
|
return { None: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
animations.map((animation) => [animation || "None", animation]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCharacterDebug(): void {
|
||||||
|
useDebugFolder("Personnages", (folder) => {
|
||||||
|
const store = useCharacterDebugStore.getState();
|
||||||
|
|
||||||
|
for (const id of CHARACTER_IDS) {
|
||||||
|
const config = CHARACTER_CONFIGS[id];
|
||||||
|
const state = store.characters[id];
|
||||||
|
const characterFolder = folder.addFolder(config.label);
|
||||||
|
const controls = {
|
||||||
|
animation: state.animation,
|
||||||
|
positionX: state.position[0],
|
||||||
|
positionY: state.position[1],
|
||||||
|
positionZ: state.position[2],
|
||||||
|
rotationX: state.rotation[0],
|
||||||
|
rotationY: state.rotation[1],
|
||||||
|
rotationZ: state.rotation[2],
|
||||||
|
scaleX: state.scale[0],
|
||||||
|
scaleY: state.scale[1],
|
||||||
|
scaleZ: state.scale[2],
|
||||||
|
};
|
||||||
|
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "animation", createAnimationOptions(config.animations))
|
||||||
|
.name("Animation")
|
||||||
|
.onChange((animation: string) => {
|
||||||
|
useCharacterDebugStore.getState().setAnimation(id, animation);
|
||||||
|
});
|
||||||
|
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "positionX", -120, 120, 0.1)
|
||||||
|
.name("Position X")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useCharacterDebugStore.getState().setPosition(id, 0, value);
|
||||||
|
});
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "positionY", -20, 40, 0.1)
|
||||||
|
.name("Position Y")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useCharacterDebugStore.getState().setPosition(id, 1, value);
|
||||||
|
});
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "positionZ", -120, 120, 0.1)
|
||||||
|
.name("Position Z")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useCharacterDebugStore.getState().setPosition(id, 2, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "rotationX", -Math.PI, Math.PI, 0.01)
|
||||||
|
.name("Rotation X")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useCharacterDebugStore.getState().setRotation(id, 0, value);
|
||||||
|
});
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "rotationY", -Math.PI, Math.PI, 0.01)
|
||||||
|
.name("Rotation Y")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useCharacterDebugStore.getState().setRotation(id, 1, value);
|
||||||
|
});
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "rotationZ", -Math.PI, Math.PI, 0.01)
|
||||||
|
.name("Rotation Z")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useCharacterDebugStore.getState().setRotation(id, 2, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "scaleX", 0.1, 5, 0.05)
|
||||||
|
.name("Scale X")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useCharacterDebugStore.getState().setScale(id, 0, value);
|
||||||
|
});
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "scaleY", 0.1, 5, 0.05)
|
||||||
|
.name("Scale Y")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useCharacterDebugStore.getState().setScale(id, 1, value);
|
||||||
|
});
|
||||||
|
characterFolder
|
||||||
|
.add(controls, "scaleZ", 0.1, 5, 0.05)
|
||||||
|
.name("Scale Z")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useCharacterDebugStore.getState().setScale(id, 2, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
characterFolder.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { createSceneDataFromFiles } from "@/utils/editor/loadEditorScene";
|
import { createSceneDataFromFiles } from "@/utils/editor/loadEditorScene";
|
||||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||||
import type { SceneData } from "@/types/editor/editor";
|
import type { SceneData } from "@/types/map/mapScene";
|
||||||
|
|
||||||
interface UseEditorSceneDataResult {
|
interface UseEditorSceneDataResult {
|
||||||
hasMapJson: boolean;
|
hasMapJson: boolean;
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import { useGameStore } from "@/managers/stores/useGameStore";
|
|||||||
import type { MissionStep } from "@/types/gameplay/repairMission";
|
import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
export function useRepairMovementLocked(): boolean {
|
export function useRepairMovementLocked(): boolean {
|
||||||
return false;
|
|
||||||
|
|
||||||
return useGameStore((state) => {
|
return useGameStore((state) => {
|
||||||
switch (state.mainState) {
|
switch (state.mainState) {
|
||||||
case "bike":
|
case "ebike":
|
||||||
return isRepairMovementLocked(state.bike.currentStep);
|
return isRepairMovementLocked(state.ebike.currentStep);
|
||||||
case "pylone":
|
case "pylon":
|
||||||
return isRepairMovementLocked(state.pylone.currentStep);
|
return isRepairMovementLocked(state.pylon.currentStep);
|
||||||
case "ferme":
|
case "farm":
|
||||||
return isRepairMovementLocked(state.ferme.currentStep);
|
return isRepairMovementLocked(state.farm.currentStep);
|
||||||
case "intro":
|
case "intro":
|
||||||
case "outro":
|
case "outro":
|
||||||
return false;
|
return false;
|
||||||
@@ -25,6 +23,7 @@ function isRepairMovementLocked(step: MissionStep): boolean {
|
|||||||
step === "fragmented" ||
|
step === "fragmented" ||
|
||||||
step === "scanning" ||
|
step === "scanning" ||
|
||||||
step === "repairing" ||
|
step === "repairing" ||
|
||||||
step === "reassembling"
|
step === "reassembling" ||
|
||||||
|
step === "done"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
HAND_TRACKING_JPEG_QUALITY,
|
HAND_TRACKING_JPEG_QUALITY,
|
||||||
HAND_TRACKING_RESPONSE_TIMEOUT_MS,
|
HAND_TRACKING_RESPONSE_TIMEOUT_MS,
|
||||||
HAND_TRACKING_TARGET_FPS,
|
HAND_TRACKING_TARGET_FPS,
|
||||||
getHandTrackingWsUrl,
|
|
||||||
} from "@/data/handTrackingConfig";
|
} from "@/data/handTrackingConfig";
|
||||||
|
import { getHandTrackingWsUrl } from "@/utils/handTracking/handTrackingEndpoint";
|
||||||
import {
|
import {
|
||||||
INITIAL_HAND_TRACKING_SNAPSHOT,
|
INITIAL_HAND_TRACKING_SNAPSHOT,
|
||||||
getCameraStreamWithTimeout,
|
getCameraStreamWithTimeout,
|
||||||
|
|||||||
@@ -2,14 +2,53 @@ import { useEffect, useMemo } from "react";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { disposeObject3D } from "@/utils/three/dispose";
|
import { disposeObject3D } from "@/utils/three/dispose";
|
||||||
|
|
||||||
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
|
interface UseClonedObjectOptions {
|
||||||
const clone = useMemo(() => object.clone(true) as T, [object]);
|
cloneResources?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function cloneMaterial(
|
||||||
return () => {
|
material: THREE.Material | THREE.Material[],
|
||||||
disposeObject3D(clone);
|
): THREE.Material | THREE.Material[] {
|
||||||
};
|
return Array.isArray(material)
|
||||||
}, [clone]);
|
? material.map((item) => item.clone())
|
||||||
|
: material.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneObject<T extends THREE.Object3D>(
|
||||||
|
object: T,
|
||||||
|
cloneResources: boolean,
|
||||||
|
): T {
|
||||||
|
const clone = object.clone(true) as T;
|
||||||
|
|
||||||
|
if (!cloneResources) return clone;
|
||||||
|
|
||||||
|
clone.traverse((child) => {
|
||||||
|
if (!(child instanceof THREE.Mesh)) return;
|
||||||
|
|
||||||
|
child.geometry = child.geometry.clone();
|
||||||
|
child.material = cloneMaterial(child.material);
|
||||||
|
});
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClonedObject<T extends THREE.Object3D>(
|
||||||
|
object: T,
|
||||||
|
options: UseClonedObjectOptions = {},
|
||||||
|
): T {
|
||||||
|
const cloneResources = options.cloneResources ?? false;
|
||||||
|
const clone = useMemo(
|
||||||
|
() => cloneObject(object, cloneResources),
|
||||||
|
[cloneResources, object],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cloneResources) return undefined;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeObject3D(clone);
|
||||||
|
};
|
||||||
|
}, [clone, cloneResources]);
|
||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import type { Object3D } from "three";
|
import type { Object3D } from "three";
|
||||||
import { Octree } from "three/addons/math/Octree.js";
|
import { Octree } from "three-stdlib";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
|
|
||||||
export function useOctreeGraphNode(
|
export function useOctreeGraphNode(
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ interface TerrainHeightSampler {
|
|||||||
getHeight: (x: number, z: number) => number | null;
|
getHeight: (x: number, z: number) => number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CachedTerrainHeightSampler {
|
||||||
|
key: string;
|
||||||
|
sampler: TerrainHeightSampler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terrainSamplerCache = new WeakMap<
|
||||||
|
THREE.Object3D,
|
||||||
|
CachedTerrainHeightSampler
|
||||||
|
>();
|
||||||
|
|
||||||
|
function createTerrainSamplerCacheKey(
|
||||||
|
position: Vector3Tuple,
|
||||||
|
rotation: Vector3Tuple,
|
||||||
|
scale: Vector3Tuple,
|
||||||
|
): string {
|
||||||
|
return `${position.join(",")}|${rotation.join(",")}|${scale.join(",")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function createTerrainHeightSampler(
|
function createTerrainHeightSampler(
|
||||||
scene: THREE.Object3D,
|
scene: THREE.Object3D,
|
||||||
position: Vector3Tuple,
|
position: Vector3Tuple,
|
||||||
@@ -29,6 +47,9 @@ function createTerrainHeightSampler(
|
|||||||
new THREE.Vector3(...scale),
|
new THREE.Vector3(...scale),
|
||||||
);
|
);
|
||||||
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
||||||
|
const localOrigin = new THREE.Vector3();
|
||||||
|
const localDirection = DOWN.clone().transformDirection(inverseTerrainMatrix);
|
||||||
|
const hits: THREE.Intersection[] = [];
|
||||||
const raycaster = new THREE.Raycaster(
|
const raycaster = new THREE.Raycaster(
|
||||||
new THREE.Vector3(),
|
new THREE.Vector3(),
|
||||||
DOWN,
|
DOWN,
|
||||||
@@ -45,13 +66,11 @@ function createTerrainHeightSampler(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getHeight: (x, z) => {
|
getHeight: (x, z) => {
|
||||||
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
|
||||||
inverseTerrainMatrix,
|
|
||||||
);
|
|
||||||
const localDirection =
|
|
||||||
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
|
||||||
raycaster.set(localOrigin, localDirection);
|
raycaster.set(localOrigin, localDirection);
|
||||||
const hit = raycaster.intersectObjects(meshes, false)[0];
|
hits.length = 0;
|
||||||
|
raycaster.intersectObjects(meshes, false, hits);
|
||||||
|
const hit = hits[0];
|
||||||
return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
|
return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -64,10 +83,23 @@ export function useTerrainHeightSampler(): TerrainHeightSampler {
|
|||||||
const rotation = terrainNode?.rotation ?? DEFAULT_TERRAIN_ROTATION;
|
const rotation = terrainNode?.rotation ?? DEFAULT_TERRAIN_ROTATION;
|
||||||
const scale = terrainNode?.scale ?? DEFAULT_TERRAIN_SCALE;
|
const scale = terrainNode?.scale ?? DEFAULT_TERRAIN_SCALE;
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(() => {
|
||||||
() => createTerrainHeightSampler(scene, position, rotation, scale),
|
const key = createTerrainSamplerCacheKey(position, rotation, scale);
|
||||||
[position, rotation, scale, scene],
|
const cached = terrainSamplerCache.get(scene);
|
||||||
|
|
||||||
|
if (cached?.key === key) {
|
||||||
|
return cached.sampler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampler = createTerrainHeightSampler(
|
||||||
|
scene,
|
||||||
|
position,
|
||||||
|
rotation,
|
||||||
|
scale,
|
||||||
);
|
);
|
||||||
|
terrainSamplerCache.set(scene, { key, sampler });
|
||||||
|
return sampler;
|
||||||
|
}, [position, rotation, scale, scene]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTerrainSnappedPosition(
|
export function useTerrainSnappedPosition(
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
|
||||||
|
|
||||||
export function useActivityCity(): boolean {
|
|
||||||
return useGameStore((state) => state.missionFlow.activityCity);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,3 @@ import type { CloudState } from "@/data/world/cloudConfig";
|
|||||||
export function useCloudSettings(): CloudState {
|
export function useCloudSettings(): CloudState {
|
||||||
return useWorldSettingsStore((state) => state.clouds);
|
return useWorldSettingsStore((state) => state.clouds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetCloudSettings(): (clouds: Partial<CloudState>) => void {
|
|
||||||
return useWorldSettingsStore((state) => state.setClouds);
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user