Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7b4a07e41 | |||
| 89044a18ec | |||
| 95ca1bbfde | |||
| 093ffd726d | |||
| 4728690a11 | |||
| 343a122c06 | |||
| d5675fe82c | |||
| fcdbf7270c | |||
| 0b3d49e8d1 | |||
| 9bbed06ddc | |||
| ba50224e6e | |||
| 1a91b1d7ae | |||
| d9cf87d2d6 | |||
| fb466a63cb | |||
| a75c3fd896 | |||
| 603e521714 | |||
| 49ef8f58b4 | |||
| 0a322acf88 | |||
| a397febd52 | |||
| c15cad2ab0 | |||
| 011e7815a2 | |||
| 970253801a | |||
| 246da0019a | |||
| 09a9471814 | |||
| 6e9318457a | |||
| 54a353de03 | |||
| 8b619bfc28 | |||
| 4faa226326 | |||
| dd66966507 | |||
| 5893afe42a | |||
| 1ead7ab3a7 | |||
| 047c58678b | |||
| ed9051b0dc | |||
| 08be6bee48 | |||
| ce0eb90321 | |||
| 96d7ec7fc0 | |||
| 9ab4b4a002 | |||
| d13dd0fda0 | |||
| fbedb90bca | |||
| cff7744ad9 |
+16
-52
@@ -15,12 +15,9 @@ export class SomeManager {
|
||||
return SomeManager._instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
// init logic
|
||||
}
|
||||
private constructor() {}
|
||||
|
||||
destroy(): void {
|
||||
// cleanup logic
|
||||
SomeManager._instance = null;
|
||||
}
|
||||
}
|
||||
@@ -28,43 +25,12 @@ export class SomeManager {
|
||||
|
||||
## Managers in this project
|
||||
|
||||
| Manager | File | Role |
|
||||
| -------------------- | ------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| `AudioManager` | `src/managers/AudioManager.ts` | Music and SFX playback. |
|
||||
| `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. |
|
||||
| Manager | File | Role |
|
||||
| -------------------- | ------------------------------------ | -------------------------------------------------------------- |
|
||||
| `AudioManager` | `src/managers/AudioManager.ts` | Music and SFX playback. |
|
||||
| `InteractionManager` | `src/managers/InteractionManager.ts` | Focus, nearby, trigger, grab, and hand-grab interaction state. |
|
||||
|
||||
## Target-State GameManager
|
||||
|
||||
`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
|
||||
## Subscribe Pattern
|
||||
|
||||
```ts
|
||||
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
|
||||
// hooks/useGameState.ts
|
||||
export function useGameState() {
|
||||
const game = GameManager.getInstance();
|
||||
const [state, setState] = useState(game.getState());
|
||||
// hooks/interaction/useInteraction.ts
|
||||
const manager = InteractionManager.getInstance();
|
||||
|
||||
useEffect(() => {
|
||||
return game.subscribe(() => setState({ ...game.getState() }));
|
||||
}, [game]);
|
||||
|
||||
return state;
|
||||
export function useInteraction(): InteractionSnapshot {
|
||||
return useSyncExternalStore(
|
||||
manager.subscribe.bind(manager),
|
||||
manager.getState.bind(manager),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- 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.
|
||||
- Always call `destroy()` on cleanup when a manager owns external resources.
|
||||
- Never create manager instances with `new` — always use `.getInstance()`.
|
||||
|
||||
@@ -23,3 +23,6 @@
|
||||
# Video (cinematics)
|
||||
*.mp4 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
|
||||
run: npm ci
|
||||
|
||||
- name: 🧹 Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: 🎨 Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: 📦 Build
|
||||
run: npm run build
|
||||
|
||||
- name: 📏 Check bundle size
|
||||
run: |
|
||||
# Check generated app assets only; public/ model files are runtime assets copied to dist.
|
||||
SIZE=$(du -k dist/assets | cut -f1)
|
||||
# Check generated JS/CSS bundles only; public runtime assets are copied to dist/assets too.
|
||||
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"
|
||||
|
||||
# Threshold: 5000KB (configurable)
|
||||
THRESHOLD=5000
|
||||
|
||||
if [ "$SIZE" -gt "$THRESHOLD" ]; then
|
||||
|
||||
-164
@@ -1,164 +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 systèmes lisent `activityCity` depuis `useGameStore` pour adapter leur comportement
|
||||
- **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/managers/stores/useGameStore.ts` | Store Zustand pour l'état global du jeu |
|
||||
| `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/world/GameStageContent.tsx` | Monte les contenus de mission dans la scène |
|
||||
| `src/data/audioConfig.ts` | Chemins des fichiers audio |
|
||||
| `src/data/zones.ts` | Configuration des zones de transition |
|
||||
|
||||
---
|
||||
|
||||
## 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/managers/stores/useGameStore.ts
|
||||
interface GameState {
|
||||
mainState: MainGameState;
|
||||
missionFlow: {
|
||||
activityCity: boolean;
|
||||
canMove: boolean;
|
||||
playerName: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
- Progressive map/model/collision/stage loading overlay
|
||||
- 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
|
||||
- Shared interaction system for trigger and grab objects
|
||||
- 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
|
||||
- Dialogue manifest, SRT subtitles, subtitle overlay, and dialogue queueing
|
||||
- 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
|
||||
- `/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
|
||||
@@ -110,9 +110,15 @@ npm run format:check
|
||||
npm run build
|
||||
```
|
||||
|
||||
Regenerate runtime map data after editing `public/map_raw.json`:
|
||||
|
||||
```bash
|
||||
npm run map:transform
|
||||
```
|
||||
|
||||
## 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
|
||||
python3.11 -m venv backend/.venv
|
||||
@@ -154,8 +160,7 @@ WS ws://localhost:8000/ws
|
||||
## Current Caveats
|
||||
|
||||
- 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()` currently returns `false`, so the movement-lock rule and indicator are present but disabled on `develop`.
|
||||
- `useRepairMovementLocked()` locks player movement during focused repair steps and drives the repair movement indicator.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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 :
|
||||
|
||||
```txt
|
||||
intro -> bike -> pylone -> ferme -> outro
|
||||
intro -> ebike -> pylon -> farm -> outro
|
||||
```
|
||||
|
||||
Les trois piliers à connaître pour la review :
|
||||
@@ -62,7 +62,7 @@ HomePage
|
||||
-> World
|
||||
-> GameMap
|
||||
-> GameStageContent
|
||||
-> RepairGame bike/pylone/ferme
|
||||
-> RepairGame ebike/pylon/farm
|
||||
-> GameMusic
|
||||
-> GameDialogues
|
||||
-> Player
|
||||
@@ -121,7 +121,7 @@ Phrase à retenir :
|
||||
|
||||
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
|
||||
|
||||
@@ -324,7 +324,7 @@ Ouvrir dans cet ordre :
|
||||
`RepairGame` reçoit une mission :
|
||||
|
||||
```tsx
|
||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
||||
<RepairGame mission="ebike" position={[42.2399, 4.5484, 34.6468]} />
|
||||
```
|
||||
|
||||
Puis il vérifie :
|
||||
@@ -347,7 +347,7 @@ Les variations mission sont dans `repairMissions.ts` :
|
||||
### Pourquoi c'est bien
|
||||
|
||||
- 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 variations restent dans la data.
|
||||
- Le debug panel peut tester les mêmes steps que le vrai jeu.
|
||||
@@ -471,9 +471,9 @@ Main states :
|
||||
|
||||
```txt
|
||||
intro
|
||||
bike
|
||||
pylone
|
||||
ferme
|
||||
ebike
|
||||
pylon
|
||||
farm
|
||||
outro
|
||||
```
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ It supports:
|
||||
The debug physics scene currently uses it to preview:
|
||||
|
||||
```txt
|
||||
public/models/electricienne_animated/model.gltf
|
||||
public/models/electricienne-animated/model.gltf
|
||||
```
|
||||
|
||||
with the `Dance` animation.
|
||||
|
||||
@@ -107,7 +107,7 @@ It owns:
|
||||
|
||||
- `mainState`
|
||||
- intro state
|
||||
- `bike`, `pylone`, and `ferme` mission state
|
||||
- `ebike`, `pylon`, and `farm` mission state
|
||||
- outro state
|
||||
- `isCinematicPlaying`
|
||||
- progression actions
|
||||
@@ -297,8 +297,7 @@ public/models/{name}/model.gltf
|
||||
- The repository is still a prototype.
|
||||
- There is no central production `GameManager`.
|
||||
- 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.
|
||||
- The repair-runtime setting is stored in settings but not consumed by the repair-game implementation.
|
||||
- `useRepairMovementLocked()` locks player movement during focused repair steps.
|
||||
- Player collision and Rapier gameplay physics are separate systems.
|
||||
- Editor persistence is local development tooling only.
|
||||
- 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()`.
|
||||
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
|
||||
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`.
|
||||
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.
|
||||
- `Shift` + right click: add or remove a node from the multi-selection.
|
||||
- `Esc`: clear selection.
|
||||
- Click empty space: clear selection.
|
||||
- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection.
|
||||
- Selection lock button: prevent object clicks and `Esc` from changing the current selection.
|
||||
- Selection clear button: intentionally clear the current selection even when the lock is active.
|
||||
- `T`: translate 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
|
||||
|
||||
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()`
|
||||
- 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
|
||||
|
||||
@@ -190,9 +188,9 @@ The state is passed to:
|
||||
|
||||
- `EditorControls`, to render the lock/unlock button
|
||||
- `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
|
||||
|
||||
|
||||
@@ -1,164 +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 systèmes lisent `activityCity` depuis `useGameStore` pour adapter leur comportement
|
||||
- **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/managers/stores/useGameStore.ts` | Store Zustand pour l'état global du jeu |
|
||||
| `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/world/GameStageContent.tsx` | Monte les contenus de mission dans la scène |
|
||||
| `src/data/audioConfig.ts` | Chemins des fichiers audio |
|
||||
| `src/data/zones.ts` | Configuration des zones de transition |
|
||||
|
||||
---
|
||||
|
||||
## 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/managers/stores/useGameStore.ts
|
||||
interface GameState {
|
||||
mainState: MainGameState;
|
||||
missionFlow: {
|
||||
activityCity: boolean;
|
||||
canMove: boolean;
|
||||
playerName: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
- active `mainState` is `bike`, `pylone`, or `ferme`
|
||||
- active `mainState` is `ebike`, `pylon`, or `farm`
|
||||
- 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.
|
||||
|
||||
@@ -184,7 +184,7 @@ Input is ignored while:
|
||||
- the settings menu is open
|
||||
- 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
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ These vegetation and crop assets account for almost all of the current `~69M` tr
|
||||
|
||||
## 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:
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ The store owns the `missionFlow` slice:
|
||||
|
||||
```ts
|
||||
missionFlow: {
|
||||
step: GameStep;
|
||||
activityCity: boolean;
|
||||
playerName: string;
|
||||
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.
|
||||
- `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
|
||||
|
||||
- `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/three/interaction/CentralObject.tsx` and `VillageoisHelperObject.tsx` expose temporary interactive mission objects.
|
||||
- `src/pages/page.tsx` mounts mission HTML overlays: `IntroUI`, `BienvenueDisplay`, and `DialogMessage`.
|
||||
- `src/world/GameStageContent.tsx` mounts repair games and their mission-start triggers.
|
||||
- `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.
|
||||
|
||||
## Step Sequence
|
||||
|
||||
@@ -8,11 +8,11 @@ The repair game is the current core gameplay loop. It gives three missions the s
|
||||
|
||||
Implemented missions:
|
||||
|
||||
| Mission | Object | Role |
|
||||
| -------- | ------------- | --------------------------------------------- |
|
||||
| `bike` | E-bike | Repair a damaged cooling core |
|
||||
| `pylone` | Power pylon | Restore relay/panel-like broken parts |
|
||||
| `ferme` | Vertical farm | Stabilize irrigation/sensor-like broken parts |
|
||||
| Mission | Object | Role |
|
||||
| ------- | ------------- | --------------------------------------------- |
|
||||
| `ebike` | E-bike | Repair a damaged cooling core |
|
||||
| `pylon` | Power pylon | Restore relay/panel-like broken parts |
|
||||
| `farm` | Vertical farm | Stabilize irrigation/sensor-like broken parts |
|
||||
|
||||
## Main Files
|
||||
|
||||
@@ -79,7 +79,7 @@ src/managers/stores/useGameStore.ts
|
||||
- `setMissionStep(mission, nextStep)`
|
||||
- `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
|
||||
|
||||
@@ -159,7 +159,7 @@ The repair case appears near the mission object. The player can:
|
||||
|
||||
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
|
||||
|
||||
@@ -324,9 +324,9 @@ src/world/GameStageContent.tsx
|
||||
Current positions:
|
||||
|
||||
```tsx
|
||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
||||
<RepairGame mission="pylone" position={[64, 0, -66]} />
|
||||
<RepairGame mission="ferme" position={[-24, 0, 42]} />
|
||||
<RepairGame mission="ebike" position={[42.2399, 4.5484, 34.6468]} />
|
||||
<RepairGame mission="pylon" position={[64, 0, -66]} />
|
||||
<RepairGame mission="farm" position={[-24, 0, 42]} />
|
||||
```
|
||||
|
||||
Only the repair game whose `mission` matches `useGameStore().mainState` renders active content.
|
||||
|
||||
+22
-22
@@ -28,11 +28,11 @@ They are under `src/managers/stores/` because they are shared runtime state, not
|
||||
|
||||
## Store Responsibilities
|
||||
|
||||
| Store | Responsibility |
|
||||
| ------------------ | ----------------------------------------------------------------- |
|
||||
| `useGameStore` | Durable game progression, mission steps, cinematic input lock |
|
||||
| `useSettingsStore` | Menu visibility, volumes, subtitle options, repair-runtime toggle |
|
||||
| `useSubtitleStore` | Currently displayed subtitle cue |
|
||||
| Store | Responsibility |
|
||||
| ------------------ | ------------------------------------------------------------- |
|
||||
| `useGameStore` | Durable game progression, mission steps, cinematic input lock |
|
||||
| `useSettingsStore` | Menu visibility, volumes, and subtitle options |
|
||||
| `useSubtitleStore` | Currently displayed subtitle cue |
|
||||
|
||||
## Managers vs Stores
|
||||
|
||||
@@ -65,18 +65,18 @@ Main states:
|
||||
| Main state | Role |
|
||||
| ---------- | ------------------------------- |
|
||||
| `intro` | Onboarding and opening sequence |
|
||||
| `bike` | E-bike repair sequence |
|
||||
| `pylone` | Power pylon repair sequence |
|
||||
| `ferme` | Vertical farm repair sequence |
|
||||
| `ebike` | E-bike repair sequence |
|
||||
| `pylon` | Power pylon repair sequence |
|
||||
| `farm` | Vertical farm repair sequence |
|
||||
| `outro` | Ending sequence |
|
||||
|
||||
Other important state:
|
||||
|
||||
- `isCinematicPlaying`
|
||||
- `intro`
|
||||
- `bike`
|
||||
- `pylone`
|
||||
- `ferme`
|
||||
- `ebike`
|
||||
- `pylon`
|
||||
- `farm`
|
||||
- `outro`
|
||||
|
||||
Mission steps:
|
||||
@@ -125,28 +125,28 @@ For development and debug tooling, direct setters also exist:
|
||||
```ts
|
||||
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:
|
||||
|
||||
- `advanceGameState`
|
||||
- `completeBike`
|
||||
- `completePylone`
|
||||
- `completeFerme`
|
||||
- `completeEbike`
|
||||
- `completePylon`
|
||||
- `completeFarm`
|
||||
- `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
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
|
||||
setMissionStep("bike", "inspected");
|
||||
completeMission("bike");
|
||||
setMissionStep("ebike", "inspected");
|
||||
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
|
||||
|
||||
@@ -188,9 +188,9 @@ State/actions:
|
||||
Current production repair placement:
|
||||
|
||||
```tsx
|
||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
||||
<RepairGame mission="pylone" position={[64, 0, -66]} />
|
||||
<RepairGame mission="ferme" position={[-24, 0, 42]} />
|
||||
<RepairGame mission="ebike" position={[42.2399, 4.5484, 34.6468]} />
|
||||
<RepairGame mission="pylon" position={[64, 0, -66]} />
|
||||
<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`.
|
||||
|
||||
+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 |
|
||||
| Toggle multi-select | `Shift` + right click |
|
||||
| Deselect | `Esc` or click empty space |
|
||||
| Deselect | `Esc` |
|
||||
| Lock selection | `Lock` button in Selection |
|
||||
| Clear selection | `X` button in Selection |
|
||||
| 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.
|
||||
- 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.
|
||||
- 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 `Lock` button to protect the current selection while editing.
|
||||
- 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:
|
||||
|
||||
- clicking another object does not change the selection
|
||||
- clicking empty space does not clear the selection
|
||||
- pressing `Esc` does not clear the selection
|
||||
- the `X` button still clears the selection intentionally
|
||||
|
||||
|
||||
+9
-10
@@ -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 a cinematic is playing
|
||||
- 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
|
||||
|
||||
@@ -63,12 +63,12 @@ This document lists the user-visible and developer-facing features implemented i
|
||||
|
||||
## 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
|
||||
- Production repair positions:
|
||||
- `bike` at `[8, 0, -6]`
|
||||
- `pylone` at `[64, 0, -66]`
|
||||
- `ferme` at `[-24, 0, 42]`
|
||||
- `ebike` at `[42.2399, 4.5484, 34.6468]`
|
||||
- `pylon` at `[64, 0, -66]`
|
||||
- `farm` at `[-24, 0, 42]`
|
||||
- Debug physics repair playground zones for all three missions
|
||||
- Data-driven mission config in `src/data/gameplay/repairMissions.ts`
|
||||
- Mission flow: `locked -> waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done`
|
||||
@@ -95,7 +95,7 @@ This document lists the user-visible and developer-facing features implemented i
|
||||
## Game Progression Store
|
||||
|
||||
- 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 completion flags
|
||||
- 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
|
||||
- Subtitle visibility toggle
|
||||
- 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 `/`
|
||||
- Crosshair overlay
|
||||
- Interaction prompt
|
||||
- Subtitle overlay
|
||||
- Repair movement-lock indicator component, currently inactive because the lock hook returns `false`
|
||||
- Repair movement-lock indicator
|
||||
- Debug overlay layout
|
||||
- Scene loading overlay
|
||||
|
||||
@@ -192,7 +191,7 @@ This document lists the user-visible and developer-facing features implemented i
|
||||
- Debug game-state panel
|
||||
- Debug hand-tracking panel
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
- User docs for implemented features, main feature, editor usage, and code-review preparation
|
||||
|
||||
## Not Implemented Or Incomplete
|
||||
## Known Gaps
|
||||
|
||||
- Complete production mission manager/orchestrator
|
||||
- Full mission HUD or minimap
|
||||
|
||||
@@ -8,7 +8,7 @@ The main feature is a reusable repair flow mounted in the production game scene.
|
||||
|
||||
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.
|
||||
3. Aim at the object and press the interaction key when prompted.
|
||||
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.
|
||||
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`.
|
||||
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
|
||||
|
||||
@@ -39,11 +39,11 @@ In `inspected`, `RepairGame` can also move to `fragmented`. Keyboard input goes
|
||||
|
||||
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
|
||||
|
||||
- `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/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.
|
||||
@@ -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/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/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/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/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/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/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/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 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
|
||||
- model assets available under `public/models/`
|
||||
- sound assets available under `public/sounds/`
|
||||
|
||||
Generated
+1
@@ -22,6 +22,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "0.182.0",
|
||||
"three-stdlib": "^2.36.1",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"map:transform": "node scripts/transformMap.cjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
@@ -32,6 +33,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "0.182.0",
|
||||
"three-stdlib": "^2.36.1",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+2
-1
@@ -39565,7 +39565,8 @@
|
||||
"rotation": [0, 0.0027, 0.0819],
|
||||
"scale": [1, 1, 1]
|
||||
}
|
||||
]
|
||||
],
|
||||
"id": "repair:pylon"
|
||||
},
|
||||
{
|
||||
"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],
|
||||
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 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) {
|
||||
return {
|
||||
...(node.id ? { id: node.id } : {}),
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
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) {
|
||||
return {
|
||||
name,
|
||||
@@ -69,6 +152,15 @@ function getOrCreateModelGroup(parent, modelName) {
|
||||
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) {
|
||||
const mappedMesh = mapMeshNode(meshNode);
|
||||
const renderableNode = cloneNode(objectNode ?? meshNode);
|
||||
@@ -177,28 +269,53 @@ function getNearestGroup(groups, node) {
|
||||
}
|
||||
|
||||
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 zone = createGroup(`zone${index + 1}_residence`, sourceNode);
|
||||
residence.children.push(zone);
|
||||
return zone;
|
||||
});
|
||||
|
||||
addBuildingsByRange(rawData, zones[0], 831, 873);
|
||||
addBuildingsByRange(rawData, zones[1], 875, 891);
|
||||
addBuildingsByRange(rawData, zones[2], 893, 942);
|
||||
addObjectsByRange(rawData, zones[0], 831, 873, RESIDENCE_MESH_NAMES);
|
||||
addObjectsByRange(rawData, zones[1], 875, 891, RESIDENCE_MESH_NAMES);
|
||||
addObjectsByRange(rawData, zones[2], 893, 942, RESIDENCE_MESH_NAMES);
|
||||
addBuildingsByRange(rawData, zones[0], ...RAW_RANGES.residenceZone1);
|
||||
addBuildingsByRange(rawData, zones[1], ...RAW_RANGES.residenceZone2);
|
||||
addBuildingsByRange(rawData, zones[2], ...RAW_RANGES.residenceZone3);
|
||||
addObjectsByRange(
|
||||
rawData,
|
||||
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];
|
||||
if (node?.type === "Mesh" && node.name === "parcebike") {
|
||||
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];
|
||||
if (node?.type === "Mesh" && node.name === "boitesauxlettres") {
|
||||
addRenderable(getNearestGroup(zones, node), null, node);
|
||||
@@ -266,12 +383,27 @@ function transformMap() {
|
||||
const vegetation = createGroup("vegetation");
|
||||
const agriculture = createGroup("agriculture");
|
||||
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 energie = createGroup("energie", rawData[4800]);
|
||||
const direction = createGroup("direction", rawData[5]);
|
||||
const lafabrik = createGroup("lafabrik", rawData[4873]);
|
||||
const ecole = createGroup("ecole", rawData[4895]);
|
||||
const energie = createGroup(
|
||||
"energie",
|
||||
getRequiredRawNode(rawData, RAW_INDEX.energieGroup, "energie group"),
|
||||
);
|
||||
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;
|
||||
const unclassified = createGroup("unclassified");
|
||||
|
||||
@@ -288,20 +420,60 @@ function transformMap() {
|
||||
);
|
||||
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");
|
||||
createResidenceZones(rawData, residence);
|
||||
addObjectsByRange(rawData, energie, 61, 96, new Set(["pyloneelectrique"]));
|
||||
addObjectsByRange(rawData, vegetation, 98, 829, VEGETATION_MESH_NAMES);
|
||||
addObjectsByRange(rawData, agriculture, 944, 944, new Set(["tuyauxlac"]));
|
||||
addObjectsByRange(rawData, champs, 946, 4594, CHAMP_MESH_NAMES);
|
||||
addObjectsByRange(rawData, ferme, 4595, 4799, FERME_MESH_NAMES);
|
||||
addObjectsByRange(rawData, vegetation, 4750, 4797, VEGETATION_MESH_NAMES);
|
||||
addObjectsByRange(rawData, energie, 4801, 4872, ENERGIE_MESH_NAMES);
|
||||
addBuildingsByRange(rawData, lafabrik, 4874, 4894);
|
||||
addObjectsByRange(rawData, lafabrik, 4874, 4894, LAFABRIK_MESH_NAMES);
|
||||
addObjectsByRange(rawData, direction, 4896, 4897, DIRECTION_MESH_NAMES);
|
||||
addObjectsByRange(rawData, vegetation, 4898, 4997, VEGETATION_MESH_NAMES);
|
||||
addObjectsByRange(
|
||||
rawData,
|
||||
energie,
|
||||
...RAW_RANGES.energyPylones,
|
||||
new Set(["pyloneelectrique"]),
|
||||
);
|
||||
addObjectsByRange(
|
||||
rawData,
|
||||
vegetation,
|
||||
...RAW_RANGES.vegetationPrimary,
|
||||
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++) {
|
||||
const node = rawData[i];
|
||||
@@ -319,6 +491,8 @@ function transformMap() {
|
||||
blocking.children.push(unclassified);
|
||||
}
|
||||
|
||||
assignRepairPylonAnchorId(scene);
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(scene, null, 2));
|
||||
console.log(`Written hierarchical map to ${OUTPUT_PATH}`);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ interface DocsDocumentProps {
|
||||
title: string;
|
||||
meta: string;
|
||||
content: string;
|
||||
frContent: string;
|
||||
frContent?: string;
|
||||
}
|
||||
|
||||
export function DocsDocument({
|
||||
title,
|
||||
meta,
|
||||
content,
|
||||
frContent,
|
||||
frContent = content,
|
||||
}: DocsDocumentProps): React.JSX.Element {
|
||||
const { language, toggleLanguage } = useDocsLanguage();
|
||||
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,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||
@@ -41,6 +42,7 @@ interface EditorControlsProps {
|
||||
onClearSelection: () => void;
|
||||
snapToTerrain: boolean;
|
||||
onSnapToTerrainToggle: () => void;
|
||||
onSnapAllToTerrain: () => void;
|
||||
newNodeName: string;
|
||||
onNewNodeNameChange: (value: string) => void;
|
||||
onAddNode: () => void;
|
||||
@@ -70,7 +72,7 @@ const EDITOR_SHORTCUTS = [
|
||||
["Shift + Right click", "Toggle multi-selection"],
|
||||
["T / R / S", "Transform mode"],
|
||||
["Ctrl Z / Y", "Undo / redo"],
|
||||
["Esc", "Deselect"],
|
||||
["Esc / X button", "Clear selection"],
|
||||
["WASD", "Move when locked"],
|
||||
] 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({
|
||||
transformMode,
|
||||
onTransformModeChange,
|
||||
@@ -117,6 +165,7 @@ export function EditorControls({
|
||||
onClearSelection,
|
||||
snapToTerrain,
|
||||
onSnapToTerrainToggle,
|
||||
onSnapAllToTerrain,
|
||||
newNodeName,
|
||||
onNewNodeNameChange,
|
||||
onAddNode,
|
||||
@@ -228,6 +277,15 @@ export function EditorControls({
|
||||
/>
|
||||
<span>Snap terrain on move</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="editor-history-button"
|
||||
onClick={onSnapAllToTerrain}
|
||||
>
|
||||
<ScanSearch size={15} aria-hidden="true" />
|
||||
Snap all to terrain
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -292,20 +350,13 @@ export function EditorControls({
|
||||
{selectedNodeScale ? (
|
||||
<div className="editor-scale-fields">
|
||||
{selectedNodeScale.map((value, axis) => (
|
||||
<label key={axis}>
|
||||
<span>{["X", "Y", "Z"][axis]}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={Number(value.toFixed(4))}
|
||||
onChange={(event) =>
|
||||
onSelectedScaleChange(
|
||||
axis as 0 | 1 | 2,
|
||||
Number(event.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<EditorScaleField
|
||||
key={`${axis}:${value}`}
|
||||
axis={axis as 0 | 1 | 2}
|
||||
label={["X", "Y", "Z"][axis] ?? "?"}
|
||||
value={value}
|
||||
onCommit={onSelectedScaleChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -496,10 +496,16 @@ export function EditorSrtPanel(): React.JSX.Element {
|
||||
setContent(await response.text());
|
||||
setStatus(`Charge depuis ${srtPath}`);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error: unknown) => {
|
||||
if (!mounted) return;
|
||||
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 () => {
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { Grid, TransformControls } from "@react-three/drei";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
Suspense,
|
||||
} from "react";
|
||||
import { TransformControls } from "@react-three/drei";
|
||||
import type { ThreeEvent } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
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 {
|
||||
isEditorVisibleMapNode,
|
||||
getTerrainMapNode,
|
||||
} from "@/utils/map/mapRuntimeClassification";
|
||||
import { getMapModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
|
||||
import { getVegetationModelScaleMultiplier } from "@/data/world/vegetationConfig";
|
||||
|
||||
interface EditorMapProps {
|
||||
sceneData: SceneData;
|
||||
@@ -28,6 +40,8 @@ interface EditorMapProps {
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||
snapAllToTerrainRequest: number;
|
||||
onSnapAllToTerrain: (mapNodes: MapNode[]) => void;
|
||||
}
|
||||
|
||||
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_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 {
|
||||
object.position.set(...node.position);
|
||||
object.rotation.set(...node.rotation);
|
||||
@@ -177,15 +241,21 @@ export function EditorMap({
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
snapAllToTerrainRequest,
|
||||
onSnapAllToTerrain,
|
||||
}: EditorMapProps): React.JSX.Element {
|
||||
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
||||
const transformGroupRef = useRef<THREE.Group>(null);
|
||||
const transformSnapshotRef = useRef<TransformSnapshot | null>(null);
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const lastSnapAllToTerrainRequestRef = useRef(0);
|
||||
|
||||
const selectedIndexSet = new Set(selectedNodeIndexes);
|
||||
const isMultiSelection = selectedNodeIndexes.length > 1;
|
||||
|
||||
const selectedNodeName =
|
||||
selectedNodeIndex !== null
|
||||
? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null)
|
||||
: null;
|
||||
const getTransformObject = useCallback(() => {
|
||||
if (isMultiSelection) {
|
||||
return transformGroupRef.current;
|
||||
@@ -333,44 +403,62 @@ export function EditorMap({
|
||||
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.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const selectedObject = getTransformObject();
|
||||
|
||||
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>
|
||||
{terrainNode ? (
|
||||
<EditorTerrainNode
|
||||
index={terrainNodeIndex}
|
||||
node={terrainNode}
|
||||
isSelected={selectedIndexSet.has(terrainNodeIndex)}
|
||||
isHovered={hoveredNodeIndex === terrainNodeIndex}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<EditorTerrainNode
|
||||
index={terrainNodeIndex}
|
||||
node={terrainNode}
|
||||
isSelected={selectedIndexSet.has(terrainNodeIndex)}
|
||||
isHovered={hoveredNodeIndex === terrainNodeIndex}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{sceneData.mapNodes.map((node, index) => {
|
||||
if (!isEditorVisibleMapNode(node)) {
|
||||
if (!shouldRenderEditorNode(node, selectedNodeName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -378,19 +466,35 @@ export function EditorMap({
|
||||
|
||||
if (modelUrl) {
|
||||
return (
|
||||
<EditorModelNode
|
||||
<Suspense
|
||||
key={index}
|
||||
index={index}
|
||||
node={node}
|
||||
modelUrl={modelUrl}
|
||||
isSelected={selectedIndexSet.has(index)}
|
||||
isHovered={hoveredNodeIndex === index}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
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}
|
||||
node={node}
|
||||
modelUrl={modelUrl}
|
||||
isSelected={selectedIndexSet.has(index)}
|
||||
isHovered={hoveredNodeIndex === index}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
@@ -451,6 +555,18 @@ function EditorModelNode({
|
||||
scale: node.scale,
|
||||
});
|
||||
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(
|
||||
index,
|
||||
onSelectNode,
|
||||
@@ -512,14 +628,19 @@ function EditorModelNode({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<primitive
|
||||
<group
|
||||
ref={groupRef}
|
||||
object={sceneInstance}
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
>
|
||||
<primitive
|
||||
object={sceneInstance}
|
||||
position={[0, visualYOffset, 0]}
|
||||
scale={visualScaleMultiplier}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
import { Suspense, useCallback, useEffect, useRef } from "react";
|
||||
import { Grid, OrbitControls } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||
import { FlyController } from "@/controls/editor/FlyController";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
||||
import type {
|
||||
EditorCinematicPreviewRequest,
|
||||
MapNode,
|
||||
TransformMode,
|
||||
SceneData,
|
||||
} from "@/types/editor/editor";
|
||||
|
||||
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
|
||||
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
export interface EditorCinematicPreviewRequest {
|
||||
id: string;
|
||||
cinematic: CinematicDefinition;
|
||||
function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
return (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
interface EditorSceneProps {
|
||||
@@ -33,6 +43,8 @@ interface EditorSceneProps {
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||
snapAllToTerrainRequest: number;
|
||||
onSnapAllToTerrain: (mapNodes: MapNode[]) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
resetCameraRequest: number;
|
||||
@@ -58,6 +70,8 @@ export function EditorScene({
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
snapAllToTerrainRequest,
|
||||
onSnapAllToTerrain,
|
||||
onUndo,
|
||||
onRedo,
|
||||
resetCameraRequest,
|
||||
@@ -144,6 +158,8 @@ export function EditorScene({
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isEditableShortcutTarget(e.target)) return;
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === "z" || e.key === "Z") {
|
||||
e.preventDefault();
|
||||
@@ -209,22 +225,41 @@ export function EditorScene({
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditorMap
|
||||
sceneData={sceneData}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
selectedNodeIndexes={selectedNodeIndexes}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={onHoverNode}
|
||||
transformMode={transformMode}
|
||||
snapToTerrain={snapToTerrain}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
onTransformStart={onTransformStart}
|
||||
onTransformEnd={onTransformEnd}
|
||||
onNodeTransform={onNodeTransform}
|
||||
<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
|
||||
sceneData={sceneData}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
selectedNodeIndexes={selectedNodeIndexes}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={onHoverNode}
|
||||
transformMode={transformMode}
|
||||
snapToTerrain={snapToTerrain}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
onTransformStart={onTransformStart}
|
||||
onTransformEnd={onTransformEnd}
|
||||
onNodeTransform={onNodeTransform}
|
||||
snapAllToTerrainRequest={snapAllToTerrainRequest}
|
||||
onSnapAllToTerrain={onSnapAllToTerrain}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<ambientLight intensity={0.6} />
|
||||
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
||||
|
||||
@@ -8,6 +8,7 @@ export function GameFlow(): null {
|
||||
const setStep = useGameStore((state) => state.setIntroStep);
|
||||
const setActivityCity = useGameStore((state) => state.setActivityCity);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,10 +56,17 @@ export function GameFlow(): null {
|
||||
|
||||
if (step === "manipulation") {
|
||||
setCanMove(false);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
completeIntro();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [step, setStep, setActivityCity, setCanMove]);
|
||||
}, [completeIntro, step, setStep, setActivityCity, setCanMove]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGam
|
||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||
import type {
|
||||
MissionStep,
|
||||
RepairMissionConfig,
|
||||
@@ -66,6 +67,7 @@ export function RepairGame({
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const snappedPosition = useTerrainSnappedPosition(position);
|
||||
const readyForFragmentation = step === "inspected";
|
||||
|
||||
useRepairFragmentationInput({
|
||||
@@ -105,7 +107,7 @@ export function RepairGame({
|
||||
if (step === "locked") return null;
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
<group position={snappedPosition} rotation={rotation} scale={parsedScale}>
|
||||
<Suspense fallback={null}>
|
||||
<RepairMissionAssetPreloader config={config} />
|
||||
</Suspense>
|
||||
@@ -113,7 +115,7 @@ export function RepairGame({
|
||||
{step === "waiting" ? (
|
||||
<RepairInspectionObject
|
||||
config={config}
|
||||
worldPosition={position}
|
||||
worldPosition={snappedPosition}
|
||||
onInspect={() => setMissionStep(mission, "inspected")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Component, useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
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 {
|
||||
useHandTrackingGloveStatus,
|
||||
@@ -255,7 +255,7 @@ function HandTrackingGloveModel({
|
||||
throw new Error(`Missing glove root node ${config.rootNodeName}`);
|
||||
}
|
||||
|
||||
const clonedRootNode = clone(rootNode);
|
||||
const clonedRootNode = SkeletonUtils.clone(rootNode);
|
||||
clonedRootNode.visible = false;
|
||||
|
||||
return clonedRootNode;
|
||||
|
||||
@@ -41,8 +41,27 @@ type InteractableObjectProps =
|
||||
const _cameraPos = new THREE.Vector3();
|
||||
const _cameraDir = new THREE.Vector3();
|
||||
const _objectPos = new THREE.Vector3();
|
||||
const _objectBounds = new THREE.Box3();
|
||||
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(
|
||||
props: InteractableObjectProps,
|
||||
): InteractableHandle {
|
||||
@@ -158,7 +177,7 @@ export function InteractableObject(
|
||||
const t = bodyRef.current.translation();
|
||||
_objectPos.set(t.x, t.y, t.z);
|
||||
} else if (group) {
|
||||
group.getWorldPosition(_objectPos);
|
||||
getInteractableWorldPosition(group, debugSphereRef.current);
|
||||
} else {
|
||||
_objectPos.set(...position);
|
||||
}
|
||||
|
||||
@@ -68,32 +68,6 @@ export function AnimatedModel({
|
||||
}
|
||||
}, [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(
|
||||
(name: string, fade = fadeDuration) => {
|
||||
const action = actions[name];
|
||||
@@ -107,6 +81,19 @@ export function AnimatedModel({
|
||||
},
|
||||
[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(
|
||||
(newSpeed: number) => {
|
||||
@@ -140,10 +127,21 @@ export function AnimatedModel({
|
||||
}
|
||||
|
||||
if (defaultAction) {
|
||||
defaultAction.play();
|
||||
Object.values(actions).forEach((action) => {
|
||||
if (action && action !== defaultAction) action.fadeOut(fadeDuration);
|
||||
});
|
||||
defaultAction.reset().fadeIn(fadeDuration).play();
|
||||
onLoaded?.();
|
||||
}
|
||||
}, [actions, defaultAnimation, modelPath, names, autoPlay, onLoaded]);
|
||||
}, [
|
||||
actions,
|
||||
defaultAnimation,
|
||||
fadeDuration,
|
||||
modelPath,
|
||||
names,
|
||||
autoPlay,
|
||||
onLoaded,
|
||||
]);
|
||||
|
||||
const contextValue: AnimatedModelContextValue = {
|
||||
play,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { disposeObject3D } from "@/utils/three/dispose";
|
||||
|
||||
function applyShadowSettings(
|
||||
object: THREE.Object3D,
|
||||
@@ -42,18 +42,12 @@ export function SimpleModel({
|
||||
rotation,
|
||||
scale,
|
||||
});
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
const model = useClonedObject(scene, { cloneResources: true });
|
||||
|
||||
useEffect(() => {
|
||||
applyShadowSettings(model, castShadow, receiveShadow);
|
||||
}, [castShadow, model, receiveShadow]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeObject3D(model);
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
const parsedScale =
|
||||
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
||||
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import {
|
||||
MergedStaticMapModel,
|
||||
type MergedStaticMapModelProps,
|
||||
} from "@/components/three/world/MergedStaticMapModel";
|
||||
|
||||
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
|
||||
|
||||
interface EcoleModelProps {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||
|
||||
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
|
||||
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import {
|
||||
MergedStaticMapModel,
|
||||
type MergedStaticMapModelProps,
|
||||
} from "@/components/three/world/MergedStaticMapModel";
|
||||
|
||||
const FERME_VERTICALE_MODEL_PATH = "/models/fermeverticale/model.gltf";
|
||||
|
||||
interface FermeVerticaleModelProps {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
type FermeVerticaleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||
|
||||
export function FermeVerticaleModel(
|
||||
props: FermeVerticaleModelProps,
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import {
|
||||
MergedStaticMapModel,
|
||||
type MergedStaticMapModelProps,
|
||||
} from "@/components/three/world/MergedStaticMapModel";
|
||||
|
||||
const GENERATEUR_MODEL_PATH = "/models/generateur/model.gltf";
|
||||
|
||||
interface GenerateurModelProps {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
type GenerateurModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||
|
||||
export function GenerateurModel(
|
||||
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 { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
|
||||
interface MergedStaticMapModelProps {
|
||||
export interface MergedStaticMapModelProps {
|
||||
modelPath: string;
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useGLTF } from "@react-three/drei";
|
||||
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
interface SkyModelProps {
|
||||
fallbackModelScale?: number | undefined;
|
||||
fallbackModelPath?: string | undefined;
|
||||
modelPath: string;
|
||||
fallbackColor?: string | undefined;
|
||||
fallbackModelPath?: string | undefined;
|
||||
fallbackScale?: number | undefined;
|
||||
scale?: number | undefined;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,8 @@ interface SkyModelContentProps {
|
||||
interface SkyModelErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
label: string;
|
||||
modelPath: string;
|
||||
}
|
||||
|
||||
interface SkyModelErrorBoundaryState {
|
||||
@@ -29,7 +32,6 @@ interface SkyModelErrorBoundaryState {
|
||||
const SKY_MODEL_SCALE = 1;
|
||||
const SKY_MODEL_RENDER_ORDER = -1000;
|
||||
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
|
||||
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
|
||||
|
||||
class SkyModelErrorBoundary extends Component<
|
||||
SkyModelErrorBoundaryProps,
|
||||
@@ -44,6 +46,17 @@ class SkyModelErrorBoundary extends Component<
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logger.warn(
|
||||
"SkyModel",
|
||||
`${this.props.label} model failed; using fallback`,
|
||||
{
|
||||
error,
|
||||
modelPath: this.props.modelPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
@@ -55,8 +68,8 @@ class SkyModelErrorBoundary extends Component<
|
||||
|
||||
export function SkyModel({
|
||||
fallbackColor,
|
||||
fallbackModelScale = SKY_MODEL_SCALE,
|
||||
fallbackModelPath,
|
||||
fallbackScale = SKY_MODEL_SCALE,
|
||||
modelPath,
|
||||
scale = SKY_MODEL_SCALE,
|
||||
}: SkyModelProps): React.JSX.Element {
|
||||
@@ -64,15 +77,28 @@ export function SkyModel({
|
||||
<color attach="background" args={[fallbackColor]} />
|
||||
) : null;
|
||||
const fallback = fallbackModelPath ? (
|
||||
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
|
||||
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
|
||||
<SkyModelErrorBoundary
|
||||
key={fallbackModelPath}
|
||||
fallback={colorFallback}
|
||||
label="Fallback sky"
|
||||
modelPath={fallbackModelPath}
|
||||
>
|
||||
<SkyModelContent
|
||||
modelPath={fallbackModelPath}
|
||||
scale={fallbackModelScale}
|
||||
/>
|
||||
</SkyModelErrorBoundary>
|
||||
) : (
|
||||
colorFallback
|
||||
);
|
||||
|
||||
return (
|
||||
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
|
||||
<SkyModelErrorBoundary
|
||||
key={modelPath}
|
||||
fallback={fallback}
|
||||
label="Primary sky"
|
||||
modelPath={modelPath}
|
||||
>
|
||||
<SkyModelContent modelPath={modelPath} scale={scale} />
|
||||
</SkyModelErrorBoundary>
|
||||
);
|
||||
@@ -154,4 +180,3 @@ function disposeSkyModelMaterials(model: THREE.Object3D): void {
|
||||
}
|
||||
|
||||
useGLTF.preload(SKYBOX_MODEL_PATH);
|
||||
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { RotateCcw, StepBack, StepForward } from "lucide-react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
|
||||
import {
|
||||
GAME_STEPS,
|
||||
isGameStep,
|
||||
MAIN_GAME_STATES,
|
||||
type MainGameState,
|
||||
} from "@/types/game";
|
||||
} from "@/data/game/gameStateConfig";
|
||||
import {
|
||||
isMissionStep,
|
||||
MISSION_STEPS,
|
||||
} from "@/data/gameplay/repairMissionState";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { MainGameState } from "@/types/game";
|
||||
|
||||
function toPascalCase(value: string): string {
|
||||
return value
|
||||
@@ -18,28 +21,28 @@ function toPascalCase(value: string): string {
|
||||
|
||||
export function GameStateDebugPanel(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
|
||||
const fermeStep = useGameStore((state) => state.ferme.currentStep);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const farmStep = useGameStore((state) => state.farm.currentStep);
|
||||
const detail = useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "intro":
|
||||
return state.intro.currentStep;
|
||||
case "bike":
|
||||
return state.bike.currentStep;
|
||||
case "pylone":
|
||||
return state.pylone.currentStep;
|
||||
case "ferme":
|
||||
return state.ferme.currentStep;
|
||||
case "ebike":
|
||||
return state.ebike.currentStep;
|
||||
case "pylon":
|
||||
return state.pylon.currentStep;
|
||||
case "farm":
|
||||
return state.farm.currentStep;
|
||||
case "outro":
|
||||
return state.outro.hasStarted ? "started" : "waiting";
|
||||
}
|
||||
});
|
||||
const setMainState = useGameStore((state) => state.setMainState);
|
||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||
const setBikeState = useGameStore((state) => state.setBikeState);
|
||||
const setPyloneState = useGameStore((state) => state.setPyloneState);
|
||||
const setFermeState = useGameStore((state) => state.setFermeState);
|
||||
const setEbikeState = useGameStore((state) => state.setEbikeState);
|
||||
const setPylonState = useGameStore((state) => state.setPylonState);
|
||||
const setFarmState = useGameStore((state) => state.setFarmState);
|
||||
const setOutroState = useGameStore((state) => state.setOutroState);
|
||||
const advanceGameState = useGameStore((state) => state.advanceGameState);
|
||||
const rewindGameState = useGameStore((state) => state.rewindGameState);
|
||||
@@ -67,18 +70,18 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
|
||||
if (!isMissionStep(nextSubState)) return;
|
||||
|
||||
if (mainState === "bike") {
|
||||
setBikeState({ currentStep: nextSubState });
|
||||
if (mainState === "ebike") {
|
||||
setEbikeState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "pylone") {
|
||||
setPyloneState({ currentStep: nextSubState });
|
||||
if (mainState === "pylon") {
|
||||
setPylonState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "ferme") {
|
||||
setFermeState({ currentStep: nextSubState });
|
||||
if (mainState === "farm") {
|
||||
setFarmState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -86,18 +89,34 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
function setDebugMainState(nextMainState: MainGameState): void {
|
||||
setMainState(nextMainState);
|
||||
|
||||
if (nextMainState === "bike" && bikeStep === "locked") {
|
||||
setBikeState({ currentStep: "waiting" });
|
||||
if (
|
||||
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;
|
||||
}
|
||||
|
||||
if (nextMainState === "pylone" && pyloneStep === "locked") {
|
||||
setPyloneState({ currentStep: "waiting" });
|
||||
if (nextMainState === "pylon" && pylonStep === "locked") {
|
||||
setPylonState({ currentStep: "waiting" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextMainState === "ferme" && fermeStep === "locked") {
|
||||
setFermeState({ currentStep: "waiting" });
|
||||
if (nextMainState === "farm" && farmStep === "locked") {
|
||||
setFarmState({ currentStep: "waiting" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as THREE from "three";
|
||||
import { ZONES } from "@/data/zones";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
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 _zonePos = new THREE.Vector3();
|
||||
|
||||
@@ -5,3 +5,11 @@ export const AUDIO_PATHS = {
|
||||
searching: "/sounds/effect/fa.mp3",
|
||||
helped: "/sounds/effect/fa.mp3",
|
||||
} 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 { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
|
||||
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_TUBE_RADIUS = 0.045;
|
||||
|
||||
export const TEST_SCENE_REPAIR_ZONES = [
|
||||
export const GAME_REPAIR_ZONES = [
|
||||
{
|
||||
mission: "bike",
|
||||
label: "Bike",
|
||||
mission: "ebike",
|
||||
label: "E-bike",
|
||||
color: "#38bdf8",
|
||||
position: [-12, 0, -12],
|
||||
},
|
||||
{
|
||||
mission: "pylone",
|
||||
label: "Pylone",
|
||||
mission: "pylon",
|
||||
label: "Pylon",
|
||||
color: "#facc15",
|
||||
position: [0, 0, -12],
|
||||
},
|
||||
{
|
||||
mission: "ferme",
|
||||
mission: "farm",
|
||||
label: "Farm",
|
||||
color: "#86efac",
|
||||
position: [12, 0, -12],
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
mission: "bike" | "pylone" | "ferme";
|
||||
mission: RepairMissionId;
|
||||
label: string;
|
||||
color: string;
|
||||
position: Vector3Tuple;
|
||||
}[];
|
||||
|
||||
export const TEST_SCENE_REPAIR_ZONES = GAME_REPAIR_ZONES;
|
||||
|
||||
@@ -38,53 +38,59 @@ export const docGroups: DocGroup[] = [
|
||||
subtitle: "Gameplay implementation",
|
||||
meta: "04",
|
||||
},
|
||||
{
|
||||
path: "/docs/mission-flow",
|
||||
title: "Mission Flow",
|
||||
subtitle: "Intro and mission progression",
|
||||
meta: "05",
|
||||
},
|
||||
{
|
||||
path: "/docs/interaction",
|
||||
title: "Interaction System",
|
||||
subtitle: "Trigger, grab, hand input",
|
||||
meta: "05",
|
||||
meta: "06",
|
||||
},
|
||||
{
|
||||
path: "/docs/target-architecture",
|
||||
title: "Target Architecture",
|
||||
subtitle: "Next direction",
|
||||
meta: "06",
|
||||
meta: "07",
|
||||
},
|
||||
{
|
||||
path: "/docs/technical-editor",
|
||||
title: "Editor Technical Notes",
|
||||
subtitle: "Implementation details",
|
||||
meta: "07",
|
||||
meta: "08",
|
||||
},
|
||||
{
|
||||
path: "/docs/audio",
|
||||
title: "Audio Technical Notes",
|
||||
subtitle: "Music, dialogue, SRT, and SFX",
|
||||
meta: "08",
|
||||
meta: "09",
|
||||
},
|
||||
{
|
||||
path: "/docs/hand-tracking",
|
||||
title: "Hand Tracking Technical Notes",
|
||||
subtitle: "Webcam interaction pipeline",
|
||||
meta: "09",
|
||||
meta: "10",
|
||||
},
|
||||
{
|
||||
path: "/docs/zustand",
|
||||
title: "Zustand Stores",
|
||||
subtitle: "Game, settings, subtitles",
|
||||
meta: "10",
|
||||
meta: "11",
|
||||
},
|
||||
{
|
||||
path: "/docs/three-debugging",
|
||||
title: "Three Debugging",
|
||||
subtitle: "Step into Three.js internals",
|
||||
meta: "11",
|
||||
meta: "12",
|
||||
},
|
||||
{
|
||||
path: "/docs/map-performance",
|
||||
title: "Map Performance",
|
||||
subtitle: "Draw calls, triangles, and streaming",
|
||||
meta: "12",
|
||||
meta: "13",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -95,25 +101,25 @@ export const docGroups: DocGroup[] = [
|
||||
path: "/docs/features",
|
||||
title: "Features",
|
||||
subtitle: "Implemented scope",
|
||||
meta: "13",
|
||||
meta: "14",
|
||||
},
|
||||
{
|
||||
path: "/docs/main-feature",
|
||||
title: "Main Feature",
|
||||
subtitle: "Repair-game prototype",
|
||||
meta: "14",
|
||||
meta: "15",
|
||||
},
|
||||
{
|
||||
path: "/docs/editor",
|
||||
title: "Editor User Guide",
|
||||
subtitle: "Editing workflow",
|
||||
meta: "15",
|
||||
meta: "16",
|
||||
},
|
||||
{
|
||||
path: "/docs/animation",
|
||||
title: "Animation & 3D Model System",
|
||||
subtitle: "Components and usage",
|
||||
meta: "16",
|
||||
meta: "17",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -124,7 +130,7 @@ export const docGroups: DocGroup[] = [
|
||||
path: "/docs/code-review",
|
||||
title: "Code Review Prep",
|
||||
subtitle: "Presentation support",
|
||||
meta: "17",
|
||||
meta: "18",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,21 +1,39 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type {
|
||||
RepairMissionId,
|
||||
RepairMissionTriggerConfig,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
|
||||
export const BIKE_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,
|
||||
] as const satisfies Vector3Tuple;
|
||||
|
||||
const REPAIR_MISSION_POSITIONS = {
|
||||
bike: BIKE_REPAIR_POSITION,
|
||||
pylone: [64, 0, -66],
|
||||
ferme: [-24, 0, 42],
|
||||
ebike: EBIKE_REPAIR_POSITION,
|
||||
pylon: [64, 0, -66],
|
||||
farm: [-24, 0, 42],
|
||||
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
||||
|
||||
export const REPAIR_MISSION_POSITION_ENTRIES = [
|
||||
{ mission: "bike", position: REPAIR_MISSION_POSITIONS.bike },
|
||||
{ mission: "pylone", position: REPAIR_MISSION_POSITIONS.pylone },
|
||||
{ mission: "ferme", position: REPAIR_MISSION_POSITIONS.ferme },
|
||||
] as const satisfies readonly {
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -14,21 +14,21 @@ const DEFAULT_REPAIR_CASE = {
|
||||
} satisfies RepairMissionCaseConfig;
|
||||
|
||||
export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
bike: {
|
||||
id: "bike",
|
||||
ebike: {
|
||||
id: "ebike",
|
||||
label: "E-bike",
|
||||
description:
|
||||
"Repair the damaged cooling module before relaunching the bike",
|
||||
modelPath: "/models/ebike/model.gltf",
|
||||
modelScale: 0.5,
|
||||
modelScale: 0.3,
|
||||
stageUiPath: "/assets/UI/ebike.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
requiredReplacementPartId: "bike-cooling-core-replacement",
|
||||
requiredReplacementPartId: "ebike-cooling-core-replacement",
|
||||
brokenParts: [
|
||||
{
|
||||
id: "bike-cooling-core",
|
||||
id: "ebike-cooling-core",
|
||||
label: "Cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
nodeName: "refroidisseur",
|
||||
@@ -37,24 +37,24 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "bike-cooling-core-replacement",
|
||||
id: "ebike-cooling-core-replacement",
|
||||
label: "Replacement cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "bike-radio-distractor",
|
||||
id: "ebike-radio-distractor",
|
||||
label: "Radio module",
|
||||
modelPath: "/models/talkie/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "bike-glove-distractor",
|
||||
id: "ebike-glove-distractor",
|
||||
label: "Insulation glove",
|
||||
modelPath: "/models/gant_l/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
pylone: {
|
||||
id: "pylone",
|
||||
pylon: {
|
||||
id: "pylon",
|
||||
label: "Power pylon",
|
||||
description:
|
||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||
@@ -64,17 +64,17 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
reassemblySeconds: 1.8,
|
||||
requiredReplacementPartId: "pylone-grid-relay-replacement",
|
||||
requiredReplacementPartId: "pylon-grid-relay-replacement",
|
||||
scanPartSeconds: 1.4,
|
||||
brokenParts: [
|
||||
{
|
||||
id: "pylone-grid-relay",
|
||||
id: "pylon-grid-relay",
|
||||
label: "Grid relay",
|
||||
nodeName: "lampe",
|
||||
caseSlotName: "placeholder_1",
|
||||
},
|
||||
{
|
||||
id: "pylone-damaged-panel",
|
||||
id: "pylon-damaged-panel",
|
||||
label: "Damaged solar panel",
|
||||
nodeName: "panneau2",
|
||||
caseSlotName: "placeholder_2",
|
||||
@@ -82,24 +82,24 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "pylone-grid-relay-replacement",
|
||||
id: "pylon-grid-relay-replacement",
|
||||
label: "Replacement grid relay",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "pylone-stone-distractor",
|
||||
id: "pylon-stone-distractor",
|
||||
label: "Stone counterweight",
|
||||
modelPath: "/models/galet/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "pylone-cooling-distractor",
|
||||
id: "pylon-cooling-distractor",
|
||||
label: "Cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
ferme: {
|
||||
id: "ferme",
|
||||
farm: {
|
||||
id: "farm",
|
||||
label: "Vertical farm",
|
||||
description:
|
||||
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
||||
@@ -109,33 +109,33 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
reassemblySeconds: 1.2,
|
||||
requiredReplacementPartId: "ferme-irrigation-pump-replacement",
|
||||
requiredReplacementPartId: "farm-irrigation-pump-replacement",
|
||||
scanPartSeconds: 0.9,
|
||||
brokenParts: [
|
||||
{
|
||||
id: "ferme-irrigation-pump",
|
||||
id: "farm-irrigation-pump",
|
||||
label: "Irrigation pump",
|
||||
caseSlotName: "placeholder_1",
|
||||
},
|
||||
{
|
||||
id: "ferme-humidity-sensor",
|
||||
id: "farm-humidity-sensor",
|
||||
label: "Humidity sensor",
|
||||
caseSlotName: "placeholder_2",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "ferme-irrigation-pump-replacement",
|
||||
id: "farm-irrigation-pump-replacement",
|
||||
label: "Replacement irrigation pump",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "ferme-tree-distractor",
|
||||
id: "farm-tree-distractor",
|
||||
label: "Tree sensor",
|
||||
modelPath: "/models/sapin/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "ferme-radio-distractor",
|
||||
id: "farm-radio-distractor",
|
||||
label: "Radio module",
|
||||
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_HEIGHT = 240;
|
||||
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";
|
||||
export const HAND_TRACKING_BROWSER_MODEL_URL =
|
||||
"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_WALK_SPEED = 11;
|
||||
export const PLAYER_EBIKE_SPEED = 25;
|
||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||
export const PLAYER_JUMP_SPEED = 9;
|
||||
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[];
|
||||
@@ -1,6 +1,6 @@
|
||||
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_FALLBACK_SKY_MODEL_SCALE = 1;
|
||||
export const GAME_SCENE_SKY_FALLBACK_MODEL_SCALE = 1;
|
||||
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
|
||||
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
||||
|
||||
@@ -3,7 +3,7 @@ export const GRASS_CONFIG = {
|
||||
patchSize: 30,
|
||||
bladeCount: 32000,
|
||||
bladeWidth: 0.08,
|
||||
maxBladeHeight: 0.56,
|
||||
maxBladeHeight: 0.67,
|
||||
randomHeightAmount: 0.25,
|
||||
surfaceOffset: 0.025,
|
||||
heightTextureSize: 128,
|
||||
@@ -2,6 +2,7 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
boiteauxlettres: {
|
||||
mapName: "boiteauxlettres",
|
||||
modelPath: "/models/boiteauxlettres/model.gltf",
|
||||
scaleMultiplier: 2,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
@@ -9,6 +10,7 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
pylone: {
|
||||
mapName: "pylone",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
@@ -16,6 +18,7 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
immeuble1: {
|
||||
mapName: "immeuble1",
|
||||
modelPath: "/models/immeuble1/model.gltf",
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
@@ -23,6 +26,7 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
maison1: {
|
||||
mapName: "maison1",
|
||||
modelPath: "/models/maison1/model.gltf",
|
||||
scaleMultiplier: 3,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
@@ -30,6 +34,7 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
eolienne: {
|
||||
mapName: "eolienne",
|
||||
modelPath: "/models/eolienne/model.gltf",
|
||||
scaleMultiplier: 0.85,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
@@ -37,6 +42,7 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
parcebike: {
|
||||
mapName: "parcebike",
|
||||
modelPath: "/models/parcebike/model.gltf",
|
||||
scaleMultiplier: 2,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
@@ -44,6 +50,7 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
panneauaffichage: {
|
||||
mapName: "panneauaffichage",
|
||||
modelPath: "/models/panneauaffichage/model.gltf",
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
@@ -51,6 +58,7 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
panneauclassique: {
|
||||
mapName: "panneauclassique",
|
||||
modelPath: "/models/panneauclassique/model.gltf",
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
@@ -58,6 +66,7 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
panneaufleche: {
|
||||
mapName: "panneaufleche",
|
||||
modelPath: "/models/panneaufleche/model.gltf",
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
@@ -65,12 +74,40 @@ export const MAP_INSTANCING_ASSETS = {
|
||||
panneausolaire: {
|
||||
mapName: "panneausolaire",
|
||||
modelPath: "/models/panneausolaire/model.gltf",
|
||||
scaleMultiplier: 0.85,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
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",
|
||||
@@ -89,13 +126,3 @@ export type MapInstancingAssetType =
|
||||
|
||||
export type MapInstancingAssetConfig =
|
||||
(typeof MAP_INSTANCING_ASSETS)[MapInstancingAssetType];
|
||||
|
||||
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",
|
||||
};
|
||||
@@ -2,19 +2,21 @@ export const VEGETATION_TYPES = {
|
||||
buissons: {
|
||||
mapName: "buisson",
|
||||
modelPath: "/models/buisson/model.gltf",
|
||||
scaleMultiplier: 2,
|
||||
scaleMultiplier: 1.5,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
windStrength: 0.08,
|
||||
windStrength: 0.06,
|
||||
rotationOffset: [0, 0, 0],
|
||||
enabled: true,
|
||||
},
|
||||
sapin: {
|
||||
mapName: "sapin",
|
||||
modelPath: "/models/sapin/model.gltf",
|
||||
scaleMultiplier: 5,
|
||||
scaleMultiplier: 4,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
windStrength: 0.04,
|
||||
windStrength: 0.12,
|
||||
rotationOffset: [0, 0, 0],
|
||||
enabled: true,
|
||||
},
|
||||
arbre: {
|
||||
@@ -23,7 +25,8 @@ export const VEGETATION_TYPES = {
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
windStrength: 0.06,
|
||||
windStrength: 0.15,
|
||||
rotationOffset: [0, 0, 0],
|
||||
enabled: true,
|
||||
},
|
||||
champdeble: {
|
||||
@@ -32,7 +35,8 @@ export const VEGETATION_TYPES = {
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
windStrength: 0.18,
|
||||
windStrength: 0.15,
|
||||
rotationOffset: [0, 0, 0],
|
||||
enabled: true,
|
||||
},
|
||||
champdesoja: {
|
||||
@@ -41,7 +45,8 @@ export const VEGETATION_TYPES = {
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
windStrength: 0.16,
|
||||
windStrength: 0.15,
|
||||
rotationOffset: [0, 0, 0],
|
||||
enabled: true,
|
||||
},
|
||||
champsdetournesol: {
|
||||
@@ -50,7 +55,18 @@ export const VEGETATION_TYPES = {
|
||||
scaleMultiplier: 1,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
windStrength: 0.14,
|
||||
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;
|
||||
@@ -62,11 +78,25 @@ export const VEGETATION_TYPE_KEYS = [
|
||||
"champdeble",
|
||||
"champdesoja",
|
||||
"champsdetournesol",
|
||||
"potager",
|
||||
] as const satisfies readonly (keyof typeof VEGETATION_TYPES)[];
|
||||
|
||||
export type VegetationType = (typeof VEGETATION_TYPE_KEYS)[number];
|
||||
|
||||
export const INSTANCED_MAP_EXCEPTIONS = new Set([
|
||||
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",
|
||||
@@ -13,12 +13,3 @@ export const WIND_BOUNDS = {
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||
export function useRepairMovementLocked(): boolean {
|
||||
return useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "bike":
|
||||
return isRepairMovementLocked(state.bike.currentStep);
|
||||
case "pylone":
|
||||
return isRepairMovementLocked(state.pylone.currentStep);
|
||||
case "ferme":
|
||||
return isRepairMovementLocked(state.ferme.currentStep);
|
||||
case "ebike":
|
||||
return isRepairMovementLocked(state.ebike.currentStep);
|
||||
case "pylon":
|
||||
return isRepairMovementLocked(state.pylon.currentStep);
|
||||
case "farm":
|
||||
return isRepairMovementLocked(state.farm.currentStep);
|
||||
case "intro":
|
||||
case "outro":
|
||||
return false;
|
||||
@@ -23,6 +23,7 @@ function isRepairMovementLocked(step: MissionStep): boolean {
|
||||
step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
step === "repairing" ||
|
||||
step === "reassembling"
|
||||
step === "reassembling" ||
|
||||
step === "done"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
HAND_TRACKING_JPEG_QUALITY,
|
||||
HAND_TRACKING_RESPONSE_TIMEOUT_MS,
|
||||
HAND_TRACKING_TARGET_FPS,
|
||||
getHandTrackingWsUrl,
|
||||
} from "@/data/handTrackingConfig";
|
||||
import { getHandTrackingWsUrl } from "@/utils/handTracking/handTrackingEndpoint";
|
||||
import {
|
||||
INITIAL_HAND_TRACKING_SNAPSHOT,
|
||||
getCameraStreamWithTimeout,
|
||||
|
||||
@@ -2,14 +2,53 @@ import { useEffect, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { disposeObject3D } from "@/utils/three/dispose";
|
||||
|
||||
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
|
||||
const clone = useMemo(() => object.clone(true) as T, [object]);
|
||||
interface UseClonedObjectOptions {
|
||||
cloneResources?: boolean;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeObject3D(clone);
|
||||
};
|
||||
}, [clone]);
|
||||
function cloneMaterial(
|
||||
material: THREE.Material | THREE.Material[],
|
||||
): THREE.Material | THREE.Material[] {
|
||||
return Array.isArray(material)
|
||||
? 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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
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";
|
||||
|
||||
export function useOctreeGraphNode(
|
||||
|
||||
@@ -16,6 +16,24 @@ interface TerrainHeightSampler {
|
||||
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(
|
||||
scene: THREE.Object3D,
|
||||
position: Vector3Tuple,
|
||||
@@ -29,6 +47,9 @@ function createTerrainHeightSampler(
|
||||
new THREE.Vector3(...scale),
|
||||
);
|
||||
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(
|
||||
new THREE.Vector3(),
|
||||
DOWN,
|
||||
@@ -45,13 +66,11 @@ function createTerrainHeightSampler(
|
||||
|
||||
return {
|
||||
getHeight: (x, z) => {
|
||||
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
||||
inverseTerrainMatrix,
|
||||
);
|
||||
const localDirection =
|
||||
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
||||
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -64,10 +83,23 @@ export function useTerrainHeightSampler(): TerrainHeightSampler {
|
||||
const rotation = terrainNode?.rotation ?? DEFAULT_TERRAIN_ROTATION;
|
||||
const scale = terrainNode?.scale ?? DEFAULT_TERRAIN_SCALE;
|
||||
|
||||
return useMemo(
|
||||
() => createTerrainHeightSampler(scene, position, rotation, scale),
|
||||
[position, rotation, scale, scene],
|
||||
);
|
||||
return useMemo(() => {
|
||||
const key = createTerrainSamplerCacheKey(position, rotation, scale);
|
||||
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(
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
|
||||
export function useActivityCity(): boolean {
|
||||
return useGameStore((state) => state.missionFlow.activityCity);
|
||||
}
|
||||
@@ -4,16 +4,11 @@ import {
|
||||
MAP_INSTANCING_ASSET_TYPES,
|
||||
type MapInstancingAssetType,
|
||||
} from "@/data/world/mapInstancingConfig";
|
||||
import type { MapNode } from "@/types/map/mapScene";
|
||||
import {
|
||||
type MapNodeInstanceTransform,
|
||||
mapNodeToInstanceTransform,
|
||||
} from "@/utils/map/mapInstanceTransform";
|
||||
import type { MapAssetInstance, MapNode } from "@/types/map/mapScene";
|
||||
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import { getMapNodes, loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||
|
||||
export type MapAssetInstance = MapNodeInstanceTransform;
|
||||
|
||||
export type MapInstancingData = Map<MapInstancingAssetType, MapAssetInstance[]>;
|
||||
|
||||
function extractMapInstancingData(mapNodes: MapNode[]): MapInstancingData {
|
||||
@@ -65,6 +60,11 @@ export function useMapInstancingData(): {
|
||||
logger.error("MapInstancing", "Failed to load map instancing data", {
|
||||
error: error instanceof Error ? error : String(error),
|
||||
});
|
||||
if (!cancelled) {
|
||||
setData(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = getMapNodes();
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
|
||||
import type {
|
||||
TerrainSurfaceBounds,
|
||||
TerrainSurfaceData,
|
||||
} from "@/types/world/terrainSurface";
|
||||
import { createTerrainSurfaceImageData } from "@/utils/world/terrainSurfaceSampler";
|
||||
|
||||
function findTerrainBaseColorTexture(
|
||||
scene: THREE.Object3D,
|
||||
): THREE.Texture | null {
|
||||
let texture: THREE.Texture | null = null;
|
||||
|
||||
scene.traverse((child) => {
|
||||
if (texture || !(child instanceof THREE.Mesh)) return;
|
||||
|
||||
const materials = Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material];
|
||||
|
||||
for (const material of materials) {
|
||||
if (material instanceof THREE.MeshStandardMaterial && material.map) {
|
||||
texture = material.map;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createTerrainSurfaceBounds(
|
||||
scene: THREE.Object3D,
|
||||
): TerrainSurfaceBounds {
|
||||
scene.updateWorldMatrix(true, true);
|
||||
|
||||
const box = new THREE.Box3().setFromObject(scene);
|
||||
return {
|
||||
minX: box.min.x,
|
||||
maxX: box.max.x,
|
||||
minZ: box.min.z,
|
||||
maxZ: box.max.z,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTerrainSurfaceData(): TerrainSurfaceData | null {
|
||||
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
||||
|
||||
return useMemo(() => {
|
||||
const texture = findTerrainBaseColorTexture(scene);
|
||||
if (!texture) return null;
|
||||
|
||||
const imageData = createTerrainSurfaceImageData(texture);
|
||||
if (!imageData) return null;
|
||||
|
||||
return {
|
||||
bounds: createTerrainSurfaceBounds(scene),
|
||||
imageData,
|
||||
raycastTarget: scene,
|
||||
};
|
||||
}, [scene]);
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { INSTANCED_MAP_EXCEPTIONS } from "@/world/vegetation/vegetationConfig";
|
||||
import type { MapNode } from "@/types/map/mapScene";
|
||||
import {
|
||||
type MapNodeInstanceTransform,
|
||||
mapNodeToInstanceTransform,
|
||||
} from "@/utils/map/mapInstanceTransform";
|
||||
import { VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES } from "@/data/world/vegetationConfig";
|
||||
import type { MapNode, VegetationInstance } from "@/types/map/mapScene";
|
||||
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||
|
||||
export type VegetationInstance = MapNodeInstanceTransform;
|
||||
|
||||
interface InstancedMapEntry {
|
||||
modelPath: string;
|
||||
instances: VegetationInstance[];
|
||||
@@ -23,23 +18,33 @@ function extractVegetationData(
|
||||
): VegetationData {
|
||||
const data: VegetationData = new Map();
|
||||
|
||||
function addInstance(
|
||||
mapName: string,
|
||||
modelPath: string,
|
||||
node: MapNode,
|
||||
): void {
|
||||
const entry = data.get(mapName);
|
||||
const instance = mapNodeToInstanceTransform(node);
|
||||
|
||||
if (entry) {
|
||||
entry.instances.push(instance);
|
||||
return;
|
||||
}
|
||||
|
||||
data.set(mapName, {
|
||||
modelPath,
|
||||
instances: [instance],
|
||||
});
|
||||
}
|
||||
|
||||
for (const node of mapNodes) {
|
||||
if (node.type !== "Object3D") continue;
|
||||
if (INSTANCED_MAP_EXCEPTIONS.has(node.name)) continue;
|
||||
if (VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES.has(node.name)) continue;
|
||||
|
||||
const modelPath = models.get(node.name);
|
||||
if (!modelPath) continue;
|
||||
|
||||
const entry = data.get(node.name);
|
||||
|
||||
if (entry) {
|
||||
entry.instances.push(mapNodeToInstanceTransform(node));
|
||||
} else {
|
||||
data.set(node.name, {
|
||||
modelPath,
|
||||
instances: [mapNodeToInstanceTransform(node)],
|
||||
});
|
||||
}
|
||||
addInstance(node.name, modelPath, node);
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -64,6 +69,11 @@ export function useVegetationData(): {
|
||||
logger.error("Vegetation", "Failed to load vegetation data", {
|
||||
error: error instanceof Error ? error : String(error),
|
||||
});
|
||||
if (!cancelled) {
|
||||
setData(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
|
||||
): readonly TChunk[] {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
|
||||
const activeChunkKeysRef = useRef<Set<string>>(new Set());
|
||||
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
@@ -32,7 +33,7 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
|
||||
chunk.centerX - cameraX,
|
||||
chunk.centerZ - cameraZ,
|
||||
);
|
||||
const wasActive = activeChunkKeys.has(chunk.key);
|
||||
const wasActive = activeChunkKeysRef.current.has(chunk.key);
|
||||
const radius = wasActive
|
||||
? CHUNK_CONFIG.unloadRadius
|
||||
: CHUNK_CONFIG.loadRadius;
|
||||
@@ -42,10 +43,11 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
|
||||
}
|
||||
}
|
||||
|
||||
if (areSetsEqual(nextKeys, activeChunkKeys)) return;
|
||||
if (areSetsEqual(nextKeys, activeChunkKeysRef.current)) return;
|
||||
|
||||
activeChunkKeysRef.current = nextKeys;
|
||||
setActiveChunkKeys(nextKeys);
|
||||
}, [activeChunkKeys, camera, chunks]);
|
||||
}, [camera, chunks]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!streamingEnabled) return;
|
||||
@@ -57,18 +59,26 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
|
||||
updateActiveChunks();
|
||||
});
|
||||
|
||||
if (!streamingEnabled) return chunks;
|
||||
return useMemo(() => {
|
||||
if (!streamingEnabled) return chunks;
|
||||
|
||||
return chunks.filter((chunk) => {
|
||||
if (activeChunkKeys.size > 0) {
|
||||
return activeChunkKeys.has(chunk.key);
|
||||
}
|
||||
return chunks.filter((chunk) => {
|
||||
if (activeChunkKeys.size > 0) {
|
||||
return activeChunkKeys.has(chunk.key);
|
||||
}
|
||||
|
||||
return (
|
||||
Math.hypot(
|
||||
chunk.centerX - camera.position.x,
|
||||
chunk.centerZ - camera.position.z,
|
||||
) <= CHUNK_CONFIG.loadRadius
|
||||
);
|
||||
});
|
||||
return (
|
||||
Math.hypot(
|
||||
chunk.centerX - camera.position.x,
|
||||
chunk.centerZ - camera.position.z,
|
||||
) <= CHUNK_CONFIG.loadRadius
|
||||
);
|
||||
});
|
||||
}, [
|
||||
activeChunkKeys,
|
||||
camera.position.x,
|
||||
camera.position.z,
|
||||
chunks,
|
||||
streamingEnabled,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Octree } from "three/addons/math/Octree.js";
|
||||
import type { Octree } from "three-stdlib";
|
||||
import type { SceneMode } from "@/types/debug/debug";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import {
|
||||
DEFAULT_CATEGORY_VOLUMES,
|
||||
type AudioCategory,
|
||||
} from "@/data/audioConfig";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
export type AudioCategory = "music" | "sfx" | "dialogue";
|
||||
export type { AudioCategory } from "@/data/audioConfig";
|
||||
export type OneShotAudioCategory = Exclude<AudioCategory, "music">;
|
||||
|
||||
interface AudioContextWindow extends Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||
music: 1,
|
||||
sfx: 1,
|
||||
dialogue: 1,
|
||||
};
|
||||
|
||||
interface PlaySoundOptions {
|
||||
category?: OneShotAudioCategory;
|
||||
pan?: number;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
CHARACTER_CONFIGS,
|
||||
CHARACTER_IDS,
|
||||
type CharacterId,
|
||||
} from "@/data/world/characters/characterConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface CharacterDebugState {
|
||||
animation: string;
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
}
|
||||
|
||||
interface CharacterDebugStore {
|
||||
characters: Record<CharacterId, CharacterDebugState>;
|
||||
setAnimation: (id: CharacterId, animation: string) => void;
|
||||
setPosition: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
|
||||
setRotation: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
|
||||
setScale: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
|
||||
}
|
||||
|
||||
function updateVector(
|
||||
vector: Vector3Tuple,
|
||||
axis: 0 | 1 | 2,
|
||||
value: number,
|
||||
): Vector3Tuple {
|
||||
const next: Vector3Tuple = [...vector];
|
||||
next[axis] = value;
|
||||
return next;
|
||||
}
|
||||
|
||||
const initialCharacters = Object.fromEntries(
|
||||
CHARACTER_IDS.map((id) => {
|
||||
const config = CHARACTER_CONFIGS[id];
|
||||
|
||||
return [
|
||||
id,
|
||||
{
|
||||
animation: config.defaultAnimation,
|
||||
position: [...config.position],
|
||||
rotation: [...config.rotation],
|
||||
scale: [...config.scale],
|
||||
},
|
||||
];
|
||||
}),
|
||||
) as Record<CharacterId, CharacterDebugState>;
|
||||
|
||||
export const useCharacterDebugStore = create<CharacterDebugStore>((set) => ({
|
||||
characters: initialCharacters,
|
||||
setAnimation: (id, animation) =>
|
||||
set((state) => ({
|
||||
characters: {
|
||||
...state.characters,
|
||||
[id]: { ...state.characters[id], animation },
|
||||
},
|
||||
})),
|
||||
setPosition: (id, axis, value) =>
|
||||
set((state) => ({
|
||||
characters: {
|
||||
...state.characters,
|
||||
[id]: {
|
||||
...state.characters[id],
|
||||
position: updateVector(state.characters[id].position, axis, value),
|
||||
},
|
||||
},
|
||||
})),
|
||||
setRotation: (id, axis, value) =>
|
||||
set((state) => ({
|
||||
characters: {
|
||||
...state.characters,
|
||||
[id]: {
|
||||
...state.characters[id],
|
||||
rotation: updateVector(state.characters[id].rotation, axis, value),
|
||||
},
|
||||
},
|
||||
})),
|
||||
setScale: (id, axis, value) =>
|
||||
set((state) => ({
|
||||
characters: {
|
||||
...state.characters,
|
||||
[id]: {
|
||||
...state.characters[id],
|
||||
scale: updateVector(state.characters[id].scale, axis, value),
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
@@ -1,15 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
import { isGameStep, isMainGameState } from "@/data/game/gameStateConfig";
|
||||
import {
|
||||
isGameStep,
|
||||
isMainGameState,
|
||||
type GameStep,
|
||||
type MainGameState,
|
||||
} from "@/types/game";
|
||||
import {
|
||||
isRepairMissionId,
|
||||
isMissionStep,
|
||||
getNextMissionStep,
|
||||
getPreviousMissionStep,
|
||||
isMissionStep,
|
||||
isRepairMissionId,
|
||||
} from "@/data/gameplay/repairMissionState";
|
||||
import {
|
||||
PLAYER_EBIKE_SPEED,
|
||||
PLAYER_WALK_SPEED,
|
||||
} from "@/data/player/playerConfig";
|
||||
import type { GameStep, MainGameState } from "@/types/game";
|
||||
import {
|
||||
type MissionStep,
|
||||
type RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
@@ -20,13 +22,14 @@ import {
|
||||
} from "@/utils/debug/debugGameStateCookie";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
|
||||
export type PlayerMovementMode = "walk" | "ebike";
|
||||
export type { MissionStep, RepairMissionId };
|
||||
|
||||
interface IntroState {
|
||||
currentStep: GameStep;
|
||||
dialogueAudio: string | null;
|
||||
hasCompleted: boolean;
|
||||
isBikeUnlocked: boolean;
|
||||
isEbikeUnlocked: boolean;
|
||||
}
|
||||
|
||||
interface MissionState {
|
||||
@@ -45,14 +48,15 @@ export interface GameState {
|
||||
mainState: MainGameState;
|
||||
isCinematicPlaying: boolean;
|
||||
missionFlow: MissionFlowState;
|
||||
player: PlayerState;
|
||||
intro: IntroState;
|
||||
bike: MissionState & {
|
||||
ebike: MissionState & {
|
||||
isRepaired: boolean;
|
||||
};
|
||||
pylone: MissionState & {
|
||||
pylon: MissionState & {
|
||||
isPowered: boolean;
|
||||
};
|
||||
ferme: MissionState & {
|
||||
farm: MissionState & {
|
||||
irrigationFixed: boolean;
|
||||
};
|
||||
outro: {
|
||||
@@ -61,24 +65,30 @@ export interface GameState {
|
||||
};
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
movementMode: PlayerMovementMode;
|
||||
currentSpeed: number;
|
||||
}
|
||||
|
||||
interface GameActions {
|
||||
setMainState: (mainState: MainGameState) => void;
|
||||
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
|
||||
hideDialog: () => void;
|
||||
setActivityCity: (activityCity: boolean) => void;
|
||||
setCanMove: (canMove: boolean) => void;
|
||||
setPlayerMovementMode: (mode: PlayerMovementMode) => void;
|
||||
setIntroStep: (step: GameStep) => void;
|
||||
setIntroState: (intro: Partial<IntroState>) => void;
|
||||
setPlayerName: (playerName: string) => void;
|
||||
setBikeState: (bike: Partial<GameState["bike"]>) => void;
|
||||
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
|
||||
setFermeState: (ferme: Partial<GameState["ferme"]>) => void;
|
||||
setEbikeState: (ebike: Partial<GameState["ebike"]>) => void;
|
||||
setPylonState: (pylon: Partial<GameState["pylon"]>) => void;
|
||||
setFarmState: (farm: Partial<GameState["farm"]>) => void;
|
||||
setOutroState: (outro: Partial<GameState["outro"]>) => void;
|
||||
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
|
||||
completeIntro: () => void;
|
||||
completeBike: () => void;
|
||||
completePylone: () => void;
|
||||
completeFerme: () => void;
|
||||
completeEbike: () => void;
|
||||
completePylon: () => void;
|
||||
completeFarm: () => void;
|
||||
completeMission: (mission: RepairMissionId) => void;
|
||||
startOutro: () => void;
|
||||
advanceGameState: () => void;
|
||||
@@ -102,56 +112,64 @@ function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
|
||||
function isPlayerMovementMode(value: unknown): value is PlayerMovementMode {
|
||||
return value === "walk" || value === "ebike";
|
||||
}
|
||||
|
||||
function completeIntroState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "bike",
|
||||
mainState: "ebike",
|
||||
intro: {
|
||||
...state.intro,
|
||||
hasCompleted: true,
|
||||
isBikeUnlocked: true,
|
||||
isEbikeUnlocked: true,
|
||||
},
|
||||
bike: {
|
||||
...state.bike,
|
||||
missionFlow: {
|
||||
...state.missionFlow,
|
||||
canMove: true,
|
||||
},
|
||||
ebike: {
|
||||
...state.ebike,
|
||||
currentStep: "locked",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeBikeState(state: GameState): GameStateUpdate {
|
||||
function completeEbikeState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "pylone",
|
||||
bike: {
|
||||
...state.bike,
|
||||
mainState: "pylon",
|
||||
ebike: {
|
||||
...state.ebike,
|
||||
currentStep: "done",
|
||||
isRepaired: true,
|
||||
},
|
||||
pylone: {
|
||||
...state.pylone,
|
||||
pylon: {
|
||||
...state.pylon,
|
||||
currentStep: "waiting",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completePyloneState(state: GameState): GameStateUpdate {
|
||||
function completePylonState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "ferme",
|
||||
pylone: {
|
||||
...state.pylone,
|
||||
mainState: "farm",
|
||||
pylon: {
|
||||
...state.pylon,
|
||||
currentStep: "done",
|
||||
isPowered: true,
|
||||
},
|
||||
ferme: {
|
||||
...state.ferme,
|
||||
farm: {
|
||||
...state.farm,
|
||||
currentStep: "waiting",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeFermeState(state: GameState): GameStateUpdate {
|
||||
function completeFarmState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "outro",
|
||||
ferme: {
|
||||
...state.ferme,
|
||||
farm: {
|
||||
...state.farm,
|
||||
currentStep: "done",
|
||||
irrigationFixed: true,
|
||||
},
|
||||
@@ -180,12 +198,12 @@ function completeMissionState(
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
switch (mission) {
|
||||
case "bike":
|
||||
return completeBikeState(state);
|
||||
case "pylone":
|
||||
return completePyloneState(state);
|
||||
case "ferme":
|
||||
return completeFermeState(state);
|
||||
case "ebike":
|
||||
return completeEbikeState(state);
|
||||
case "pylon":
|
||||
return completePylonState(state);
|
||||
case "farm":
|
||||
return completeFarmState(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,23 +250,27 @@ function createInitialGameState(): GameState {
|
||||
dialogMessage: null,
|
||||
playerName: "",
|
||||
},
|
||||
player: {
|
||||
movementMode: "walk",
|
||||
currentSpeed: PLAYER_WALK_SPEED,
|
||||
},
|
||||
intro: {
|
||||
currentStep: "intro",
|
||||
dialogueAudio: null,
|
||||
hasCompleted: false,
|
||||
isBikeUnlocked: false,
|
||||
isEbikeUnlocked: false,
|
||||
},
|
||||
bike: {
|
||||
ebike: {
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
isRepaired: false,
|
||||
},
|
||||
pylone: {
|
||||
pylon: {
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
isPowered: false,
|
||||
},
|
||||
ferme: {
|
||||
farm: {
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
irrigationFixed: false,
|
||||
@@ -273,9 +295,9 @@ function hydrateIntroState(initial: IntroState, value: unknown): IntroState {
|
||||
hasCompleted: isBoolean(value.hasCompleted)
|
||||
? value.hasCompleted
|
||||
: initial.hasCompleted,
|
||||
isBikeUnlocked: isBoolean(value.isBikeUnlocked)
|
||||
? value.isBikeUnlocked
|
||||
: initial.isBikeUnlocked,
|
||||
isEbikeUnlocked: isBoolean(value.isEbikeUnlocked)
|
||||
? value.isEbikeUnlocked
|
||||
: initial.isEbikeUnlocked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -317,12 +339,26 @@ function hydrateMissionFlowState(
|
||||
};
|
||||
}
|
||||
|
||||
function hydratePlayerState(initial: PlayerState, value: unknown): PlayerState {
|
||||
if (!isRecord(value)) return initial;
|
||||
|
||||
return {
|
||||
movementMode: isPlayerMovementMode(value.movementMode)
|
||||
? value.movementMode
|
||||
: initial.movementMode,
|
||||
currentSpeed:
|
||||
typeof value.currentSpeed === "number"
|
||||
? value.currentSpeed
|
||||
: initial.currentSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
|
||||
if (!isRecord(value)) return initial;
|
||||
|
||||
const bike = hydrateMissionState(initial.bike, value.bike);
|
||||
const pylone = hydrateMissionState(initial.pylone, value.pylone);
|
||||
const ferme = hydrateMissionState(initial.ferme, value.ferme);
|
||||
const ebike = hydrateMissionState(initial.ebike, value.ebike);
|
||||
const pylon = hydrateMissionState(initial.pylon, value.pylon);
|
||||
const farm = hydrateMissionState(initial.farm, value.farm);
|
||||
const outro = isRecord(value.outro) ? value.outro : null;
|
||||
|
||||
return {
|
||||
@@ -336,27 +372,28 @@ function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
|
||||
initial.missionFlow,
|
||||
value.missionFlow,
|
||||
),
|
||||
player: hydratePlayerState(initial.player, value.player),
|
||||
intro: hydrateIntroState(initial.intro, value.intro),
|
||||
bike: {
|
||||
...bike,
|
||||
ebike: {
|
||||
...ebike,
|
||||
isRepaired:
|
||||
isRecord(value.bike) && isBoolean(value.bike.isRepaired)
|
||||
? value.bike.isRepaired
|
||||
: initial.bike.isRepaired,
|
||||
isRecord(value.ebike) && isBoolean(value.ebike.isRepaired)
|
||||
? value.ebike.isRepaired
|
||||
: initial.ebike.isRepaired,
|
||||
},
|
||||
pylone: {
|
||||
...pylone,
|
||||
pylon: {
|
||||
...pylon,
|
||||
isPowered:
|
||||
isRecord(value.pylone) && isBoolean(value.pylone.isPowered)
|
||||
? value.pylone.isPowered
|
||||
: initial.pylone.isPowered,
|
||||
isRecord(value.pylon) && isBoolean(value.pylon.isPowered)
|
||||
? value.pylon.isPowered
|
||||
: initial.pylon.isPowered,
|
||||
},
|
||||
ferme: {
|
||||
...ferme,
|
||||
farm: {
|
||||
...farm,
|
||||
irrigationFixed:
|
||||
isRecord(value.ferme) && isBoolean(value.ferme.irrigationFixed)
|
||||
? value.ferme.irrigationFixed
|
||||
: initial.ferme.irrigationFixed,
|
||||
isRecord(value.farm) && isBoolean(value.farm.irrigationFixed)
|
||||
? value.farm.irrigationFixed
|
||||
: initial.farm.irrigationFixed,
|
||||
},
|
||||
outro: {
|
||||
dialogueAudio:
|
||||
@@ -383,10 +420,11 @@ function pickGameState(state: GameStore): GameState {
|
||||
mainState: state.mainState,
|
||||
isCinematicPlaying: state.isCinematicPlaying,
|
||||
missionFlow: state.missionFlow,
|
||||
player: state.player,
|
||||
intro: state.intro,
|
||||
bike: state.bike,
|
||||
pylone: state.pylone,
|
||||
ferme: state.ferme,
|
||||
ebike: state.ebike,
|
||||
pylon: state.pylon,
|
||||
farm: state.farm,
|
||||
outro: state.outro,
|
||||
};
|
||||
}
|
||||
@@ -403,6 +441,14 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, activityCity },
|
||||
})),
|
||||
setPlayerMovementMode: (mode) =>
|
||||
set((state) => ({
|
||||
player: {
|
||||
...state.player,
|
||||
movementMode: mode,
|
||||
currentSpeed: mode === "ebike" ? PLAYER_EBIKE_SPEED : PLAYER_WALK_SPEED,
|
||||
},
|
||||
})),
|
||||
setCanMove: (canMove) =>
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, canMove },
|
||||
@@ -415,20 +461,20 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, playerName },
|
||||
})),
|
||||
setBikeState: (bike) =>
|
||||
set((state) => ({ bike: { ...state.bike, ...bike } })),
|
||||
setPyloneState: (pylone) =>
|
||||
set((state) => ({ pylone: { ...state.pylone, ...pylone } })),
|
||||
setFermeState: (ferme) =>
|
||||
set((state) => ({ ferme: { ...state.ferme, ...ferme } })),
|
||||
setEbikeState: (ebike) =>
|
||||
set((state) => ({ ebike: { ...state.ebike, ...ebike } })),
|
||||
setPylonState: (pylon) =>
|
||||
set((state) => ({ pylon: { ...state.pylon, ...pylon } })),
|
||||
setFarmState: (farm) =>
|
||||
set((state) => ({ farm: { ...state.farm, ...farm } })),
|
||||
setOutroState: (outro) =>
|
||||
set((state) => ({ outro: { ...state.outro, ...outro } })),
|
||||
setMissionStep: (mission, step) =>
|
||||
set((state) => setMissionStepState(state, mission, step)),
|
||||
completeIntro: () => set(completeIntroState),
|
||||
completeBike: () => set((state) => completeMissionState(state, "bike")),
|
||||
completePylone: () => set((state) => completeMissionState(state, "pylone")),
|
||||
completeFerme: () => set((state) => completeMissionState(state, "ferme")),
|
||||
completeEbike: () => set((state) => completeMissionState(state, "ebike")),
|
||||
completePylon: () => set((state) => completeMissionState(state, "pylon")),
|
||||
completeFarm: () => set((state) => completeMissionState(state, "farm")),
|
||||
completeMission: (mission) =>
|
||||
set((state) => completeMissionState(state, mission)),
|
||||
startOutro: () => set(startOutroState),
|
||||
|
||||
@@ -1,38 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
MAP_PERFORMANCE_GROUP_NAMES,
|
||||
MAP_PERFORMANCE_MODEL_GROUPS,
|
||||
MAP_PERFORMANCE_MODEL_NAMES,
|
||||
type MapPerformanceGroupName,
|
||||
type MapPerformanceModelName,
|
||||
} from "@/data/world/mapPerformanceConfig";
|
||||
|
||||
export type MapPerformanceGroupName =
|
||||
| "vegetation"
|
||||
| "crops"
|
||||
| "trees"
|
||||
| "buildings"
|
||||
| "landmarks"
|
||||
| "props"
|
||||
| "terrain"
|
||||
| "sky";
|
||||
|
||||
export type MapPerformanceModelName =
|
||||
| "buisson"
|
||||
| "arbre"
|
||||
| "sapin"
|
||||
| "champdeble"
|
||||
| "champdesoja"
|
||||
| "champsdetournesol"
|
||||
| "ecole"
|
||||
| "generateur"
|
||||
| "fermeverticale"
|
||||
| "lafabrik"
|
||||
| "immeuble1"
|
||||
| "eolienne"
|
||||
| "pylone"
|
||||
| "boiteauxlettres"
|
||||
| "maison1"
|
||||
| "panneauaffichage"
|
||||
| "panneauclassique"
|
||||
| "panneaufleche"
|
||||
| "panneausolaire"
|
||||
| "parcebike"
|
||||
| "terrain"
|
||||
| "sky";
|
||||
export { MAP_PERFORMANCE_GROUP_NAMES, MAP_PERFORMANCE_MODEL_NAMES };
|
||||
|
||||
export interface MapPerformanceVisibility {
|
||||
groups: Record<MapPerformanceGroupName, boolean>;
|
||||
@@ -47,70 +22,6 @@ interface MapPerformanceActions {
|
||||
|
||||
type MapPerformanceStore = MapPerformanceVisibility & MapPerformanceActions;
|
||||
|
||||
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",
|
||||
"ecole",
|
||||
"generateur",
|
||||
"fermeverticale",
|
||||
"lafabrik",
|
||||
"immeuble1",
|
||||
"eolienne",
|
||||
"pylone",
|
||||
"boiteauxlettres",
|
||||
"maison1",
|
||||
"panneauaffichage",
|
||||
"panneauclassique",
|
||||
"panneaufleche",
|
||||
"panneausolaire",
|
||||
"parcebike",
|
||||
"terrain",
|
||||
"sky",
|
||||
];
|
||||
|
||||
const MODEL_GROUPS: Record<
|
||||
MapPerformanceModelName,
|
||||
readonly MapPerformanceGroupName[]
|
||||
> = {
|
||||
buisson: ["vegetation"],
|
||||
arbre: ["vegetation", "trees"],
|
||||
sapin: ["vegetation", "trees"],
|
||||
champdeble: ["vegetation", "crops"],
|
||||
champdesoja: ["vegetation", "crops"],
|
||||
champsdetournesol: ["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"],
|
||||
};
|
||||
|
||||
function createVisibleRecord<T extends string>(
|
||||
keys: readonly T[],
|
||||
): Record<T, boolean> {
|
||||
@@ -140,7 +51,9 @@ export function isMapModelVisible(
|
||||
if (!isMapPerformanceModelName(name)) return true;
|
||||
if (!visibility.models[name]) return false;
|
||||
|
||||
return MODEL_GROUPS[name].every((group) => visibility.groups[group]);
|
||||
return MAP_PERFORMANCE_MODEL_GROUPS[name].every(
|
||||
(group) => visibility.groups[group],
|
||||
);
|
||||
}
|
||||
|
||||
export const useMapPerformanceStore = create<MapPerformanceStore>()((set) => ({
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairMissionAnchorStore {
|
||||
anchors: Partial<Record<RepairMissionId, Vector3Tuple>>;
|
||||
setAnchors: (anchors: Partial<Record<RepairMissionId, Vector3Tuple>>) => void;
|
||||
}
|
||||
|
||||
export const useRepairMissionAnchorStore = create<RepairMissionAnchorStore>(
|
||||
(set) => ({
|
||||
anchors: {},
|
||||
setAnchors: (anchors) => set({ anchors }),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,357 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { MapControls, OrthographicCamera, useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 1. Terrain Scene
|
||||
// ----------------------------------------------------------------------------
|
||||
function TerrainScene() {
|
||||
const { scene } = useGLTF("/models/terrain/terrain.glb");
|
||||
return (
|
||||
<group>
|
||||
<ambientLight intensity={1.5} />
|
||||
<directionalLight position={[10, 20, 10]} intensity={2} />
|
||||
<primitive object={scene} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 2. Waypoint Overlay (Debug visualization)
|
||||
// ----------------------------------------------------------------------------
|
||||
function WaypointOverlay({
|
||||
waypoints,
|
||||
visible,
|
||||
}: {
|
||||
waypoints: any[];
|
||||
visible: boolean;
|
||||
}) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<group>
|
||||
{waypoints.map((w) => (
|
||||
<mesh key={w.id} position={[w.x, w.y + 1, w.z]}>
|
||||
<sphereGeometry args={[0.3, 16, 16]} />
|
||||
<meshBasicMaterial color="#10b981" />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 3. Camera Manager (Handles Orthographic Math & Downloads)
|
||||
// ----------------------------------------------------------------------------
|
||||
function CameraManager({
|
||||
autoBounds,
|
||||
boundsTextRef,
|
||||
}: {
|
||||
autoBounds: any;
|
||||
boundsTextRef: React.RefObject<HTMLPreElement | null>;
|
||||
}) {
|
||||
const { camera, gl, scene } = useThree();
|
||||
const controlsRef = useRef<any>(null);
|
||||
|
||||
// Apply Auto-Bounds function
|
||||
useEffect(() => {
|
||||
const applyAutoBounds = () => {
|
||||
if (camera instanceof THREE.OrthographicCamera && autoBounds) {
|
||||
const width = autoBounds.maxX - autoBounds.minX;
|
||||
const height = autoBounds.maxZ - autoBounds.minZ;
|
||||
const centerX = (autoBounds.minX + autoBounds.maxX) / 2;
|
||||
const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2;
|
||||
|
||||
camera.position.set(centerX, 200, centerZ);
|
||||
camera.left = -width / 2;
|
||||
camera.right = width / 2;
|
||||
camera.top = height / 2;
|
||||
camera.bottom = -height / 2;
|
||||
camera.zoom = 1;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.target.set(centerX, 0, centerZ);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).applyAutoBounds = applyAutoBounds;
|
||||
// Initial apply
|
||||
applyAutoBounds();
|
||||
|
||||
return () => {
|
||||
delete (window as any).applyAutoBounds;
|
||||
};
|
||||
}, [camera, autoBounds]);
|
||||
|
||||
// Track dynamic bounds without triggering React re-renders!
|
||||
useFrame(() => {
|
||||
if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) {
|
||||
const width = (camera.right - camera.left) / camera.zoom;
|
||||
const height = (camera.top - camera.bottom) / camera.zoom;
|
||||
const minX = Math.round(camera.position.x - width / 2);
|
||||
const maxX = Math.round(camera.position.x + width / 2);
|
||||
const minZ = Math.round(camera.position.z - height / 2);
|
||||
const maxZ = Math.round(camera.position.z + height / 2);
|
||||
|
||||
// Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!)
|
||||
boundsTextRef.current.innerText = JSON.stringify(
|
||||
{ minX, maxX, minZ, maxZ },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach screenshot capture logic
|
||||
useEffect(() => {
|
||||
(window as any).downloadMapScreenshot = () => {
|
||||
// Force an immediate render frame to ensure no UI overlays are missing
|
||||
gl.render(scene, camera);
|
||||
const dataUrl = gl.domElement.toDataURL("image/png");
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = "/assets/gps/map_background.png";
|
||||
a.click();
|
||||
};
|
||||
return () => {
|
||||
delete (window as any).downloadMapScreenshot;
|
||||
};
|
||||
}, [gl, camera, scene]);
|
||||
|
||||
return (
|
||||
<MapControls ref={controlsRef} enableRotate={false} dampingFactor={0.05} />
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 4. Main Page Route Component
|
||||
// ----------------------------------------------------------------------------
|
||||
export function BackgroundMapPage() {
|
||||
const [waypoints, setWaypoints] = useState<any[]>([]);
|
||||
const [showWaypoints, setShowWaypoints] = useState(true);
|
||||
const boundsTextRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Load road network waypoints to compute perfect GPS bounds
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("la-fabrik-waypoints");
|
||||
if (saved) {
|
||||
setWaypoints(JSON.parse(saved));
|
||||
} else {
|
||||
fetch("/roadNetwork.json")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setWaypoints(data))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Compute exact bounds that the EbikeGPSMap will use by default
|
||||
const autoBounds = useMemo(() => {
|
||||
if (waypoints.length === 0) return null;
|
||||
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);
|
||||
|
||||
// CRITICAL: We MUST force the camera bounds to be a PERFECT SQUARE.
|
||||
// If the camera is rectangular, the exported PNG will be distorted when drawn
|
||||
// on the EbikeGPSMap's 1024x1024 canvas!
|
||||
const width = maxX - minX;
|
||||
const height = maxZ - minZ;
|
||||
const maxDim = Math.max(width, height);
|
||||
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerZ = (minZ + maxZ) / 2;
|
||||
|
||||
const paddedDim = maxDim * 1.15 || 100;
|
||||
|
||||
return {
|
||||
minX: centerX - paddedDim / 2,
|
||||
maxX: centerX + paddedDim / 2,
|
||||
minZ: centerZ - paddedDim / 2,
|
||||
maxZ: centerZ + paddedDim / 2,
|
||||
};
|
||||
}, [waypoints]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
background: "#050505",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
CRITICAL: The DOM element MUST be a perfect square so the resulting PNG
|
||||
is exactly 1:1, preventing stretching in the EbikeGPSMap canvas texture!
|
||||
*/}
|
||||
<div
|
||||
style={{
|
||||
width: "min(100vw, 100vh)",
|
||||
height: "min(100vw, 100vh)",
|
||||
background: "#000",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Canvas
|
||||
gl={{ preserveDrawingBuffer: true, antialias: true, alpha: false }}
|
||||
>
|
||||
<OrthographicCamera
|
||||
makeDefault
|
||||
position={[0, 200, 0]}
|
||||
near={0.1}
|
||||
far={1000}
|
||||
/>
|
||||
<TerrainScene />
|
||||
<WaypointOverlay waypoints={waypoints} visible={showWaypoints} />
|
||||
<CameraManager
|
||||
autoBounds={autoBounds}
|
||||
boundsTextRef={boundsTextRef}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
{/* Premium Glassmorphic UI Dashboard */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 24,
|
||||
left: 24,
|
||||
background: "rgba(15, 23, 42, 0.85)",
|
||||
padding: 24,
|
||||
borderRadius: 16,
|
||||
border: "1px solid #334155",
|
||||
color: "white",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
backdropFilter: "blur(12px)",
|
||||
width: 360,
|
||||
boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.5)",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{ margin: "0 0 16px 0", fontSize: "1.4rem", color: "#38bdf8" }}
|
||||
>
|
||||
GPS Map Generator
|
||||
</h2>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "#94a3b8",
|
||||
marginBottom: 20,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
1. Cadrez votre carte (ou utilisez le <b>Cadrage Automatique</b>).
|
||||
<br />
|
||||
2. Masquez les waypoints (fond visuel seul).
|
||||
<br />
|
||||
3. Cliquez sur <b>Capturer la carte</b>.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setShowWaypoints(!showWaypoints)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
marginBottom: 12,
|
||||
background: showWaypoints ? "#1e293b" : "#334155",
|
||||
border: "1px solid #475569",
|
||||
color: "white",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{showWaypoints ? "👁️ Masquer Waypoints" : "👁️🗨️ Afficher Waypoints"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if ((window as any).applyAutoBounds)
|
||||
(window as any).applyAutoBounds();
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
marginBottom: 16,
|
||||
background: "#1e293b",
|
||||
border: "1px solid #475569",
|
||||
color: "#10b981",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
🎯 Cadrage Automatique
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if ((window as any).downloadMapScreenshot)
|
||||
(window as any).downloadMapScreenshot();
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "14px",
|
||||
background: "#0ea5e9",
|
||||
border: "none",
|
||||
color: "white",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
fontWeight: "bold",
|
||||
fontSize: "1rem",
|
||||
boxShadow: "0 4px 6px -1px rgba(14, 165, 233, 0.4)",
|
||||
}}
|
||||
>
|
||||
📸 Capturer la carte (.png)
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
background: "#020617",
|
||||
borderRadius: 10,
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#64748b", marginBottom: 8, fontWeight: 600 }}>
|
||||
Limites Actuelles (worldBounds):
|
||||
</div>
|
||||
<pre
|
||||
ref={boundsTextRef}
|
||||
style={{ margin: 0, color: "#10b981", fontFamily: "monospace" }}
|
||||
>
|
||||
Calcul...
|
||||
</pre>
|
||||
<div
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
marginTop: 12,
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
*Si vous décadrez à la souris, vous devrez copier ces valeurs
|
||||
exactes dans la prop <code>worldBounds</code> de votre composant{" "}
|
||||
<b>EbikeGPSMap</b> !
|
||||
<br />
|
||||
<br />
|
||||
Astuce : Utilisez le <b>Cadrage Automatique</b> pour ne rien avoir à
|
||||
configurer.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ export function DocsAnimationPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={animation}
|
||||
frContent={animation}
|
||||
meta="15"
|
||||
title="Animation & 3D Model System"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,6 @@ export function DocsArchitecturePage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={architecture}
|
||||
frContent={architecture}
|
||||
meta="02"
|
||||
title="Current Architecture"
|
||||
/>
|
||||
|
||||
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsAudioPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={audio}
|
||||
frContent={audio}
|
||||
meta="08"
|
||||
title="Audio Technical Notes"
|
||||
/>
|
||||
<DocsDocument content={audio} meta="08" title="Audio Technical Notes" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsCodeReviewPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={codeReview}
|
||||
frContent={codeReview}
|
||||
meta="16"
|
||||
title="Code Review Prep"
|
||||
/>
|
||||
<DocsDocument content={codeReview} meta="16" title="Code Review Prep" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,5 @@ import editor from "../../../../docs/user/editor.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsEditorPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={editor}
|
||||
frContent={editor}
|
||||
meta="14"
|
||||
title="Editor User Guide"
|
||||
/>
|
||||
);
|
||||
return <DocsDocument content={editor} meta="14" title="Editor User Guide" />;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,5 @@ import features from "../../../../docs/user/features.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsFeaturesPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={features}
|
||||
frContent={features}
|
||||
meta="12"
|
||||
title="Features"
|
||||
/>
|
||||
);
|
||||
return <DocsDocument content={features} meta="12" title="Features" />;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export function DocsHandTrackingPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={handTracking}
|
||||
frContent={handTracking}
|
||||
meta="09"
|
||||
title="Hand Tracking Technical Notes"
|
||||
/>
|
||||
|
||||
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsInteractionPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={interaction}
|
||||
frContent={interaction}
|
||||
meta="05"
|
||||
title="Interaction System"
|
||||
/>
|
||||
<DocsDocument content={interaction} meta="05" title="Interaction System" />
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user