Feat/map-environment #6

Merged
math-pixel merged 116 commits from feat/map-environment into develop 2026-05-29 00:00:51 +00:00
73 changed files with 890 additions and 1457 deletions
Showing only changes of commit d654565f87 - Show all commits
+10 -33
View File
@@ -52,7 +52,7 @@ intro → start-intro → naming → bienvenue → star-move → mission2 → se
- **Actions** : - **Actions** :
- Stocke `activityCity: false` dans le store Zustand - Stocke `activityCity: false` dans le store Zustand
- Joue l'audio `alertCentral` - Joue l'audio `alertCentral`
- **État** : Les objets avec hook `useActivityCity()` détectent le changement et jouent leurs animations - **État** : Les systèmes lisent `activityCity` depuis `useGameStore` pour adapter leur comportement
- **Attente** : Le joueur atteint la zone de trigger pour `searching_problem` - **Attente** : Le joueur atteint la zone de trigger pour `searching_problem`
### 7. `searching_problem` ### 7. `searching_problem`
@@ -81,15 +81,13 @@ intro → start-intro → naming → bienvenue → star-move → mission2 → se
| Fichier | Rôle | | Fichier | Rôle |
| --------------------------------------- | --------------------------------------------------------- | | --------------------------------------- | --------------------------------------------------------- |
| `src/stores/gameStore.ts` | Store Zustand pour l'état global du jeu | | `src/managers/stores/useGameStore.ts` | Store Zustand pour l'état global du jeu |
| `src/stateManager/GameStepManager.ts` | Synchronise avec le store Zustand |
| `src/components/game/GameFlow.tsx` | Gère les transitions automatiques et la lecture audio | | `src/components/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/ui/IntroUI.tsx` | Affiche l'input pour le prénom et le message de bienvenue |
| `src/components/zone/ZoneDetection.tsx` | Détecte quand le joueur entre dans une zone | | `src/components/zone/ZoneDetection.tsx` | Détecte quand le joueur entre dans une zone |
| `src/components/3d/CentralObject.tsx` | Objet interactif "central" pour la mission 2 | | `src/world/GameStageContent.tsx` | Monte les contenus de mission dans la scène |
| `src/data/audioConfig.ts` | Chemins des fichiers audio | | `src/data/audioConfig.ts` | Chemins des fichiers audio |
| `src/data/zones.ts` | Configuration des zones de transition | | `src/data/zones.ts` | Configuration des zones de transition |
| `src/hooks/useActivityCity.ts` | Hook pour détecter le changement d'activité de la ville |
--- ---
@@ -134,35 +132,14 @@ export const ZONES: Zone[] = [
## Store Zustand ## Store Zustand
```typescript ```typescript
// src/stores/gameStore.ts // src/managers/stores/useGameStore.ts
interface GameState { interface GameState {
step: GameStep; mainState: MainGameState;
activityCity: boolean; missionFlow: {
playerName: string; activityCity: boolean;
canMove: boolean; canMove: boolean;
setStep: (step: GameStep) => void; playerName: string;
setActivityCity: (value: boolean) => void; };
setPlayerName: (name: string) => void;
setCanMove: (canMove: boolean) => void;
}
```
---
## Hooks personnalisés
### useActivityCity
Permet aux objets 3D de réagir au changement d'activité de la ville :
```typescript
import { useActivityCity } from "@/hooks/useActivityCity";
function MyAnimatedObject() {
const activityCity = useActivityCity(); // true par défaut, false en mission2
// L'animation se déclenche quand activityCity change à false
// Utiliser useEffect pour réagir au changement
} }
``` ```
+1 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -13,7 +13,6 @@ class ClientConnection:
websocket: WebSocket websocket: WebSocket
is_processing: bool = False is_processing: bool = False
last_frame_at: float = 0.0 last_frame_at: float = 0.0
metadata: dict[str, Any] = field(default_factory=dict)
class ConnectionManager: class ConnectionManager:
+11 -20
View File
@@ -509,12 +509,7 @@ Gère :
- menu ouvert/fermé ; - menu ouvert/fermé ;
- volumes ; - volumes ;
- sous-titres ; - sous-titres ;
- langue ; - langue.
- `repairRuntime`.
Piège :
`repairRuntime` est stocké et affiché, mais pas encore utilisé par `RepairGame`.
### Subtitle store ### Subtitle store
@@ -531,16 +526,15 @@ Phrase simple :
Si on te pose une question précise, réponds vrai. Si on te pose une question précise, réponds vrai.
| Sujet | Réponse honnête | | Sujet | Réponse honnête |
| ------------------------- | ------------------------------------------------------------------------ | | ------------------------- | -------------------------------------------------------------------- |
| Lock mouvement réparation | Le hook existe mais retourne `false`, donc pas actif actuellement. | | Lock mouvement réparation | Les étapes repair actives bloquent le déplacement via le hook dédié. |
| `repairRuntime` JS/Python | Le choix est stocké dans settings, mais pas consommé par le repair game. | | Old debug flags | `noMusic`, `noMap`, `noDialogues`, etc. ne sont plus branchés. |
| Old debug flags | `noMusic`, `noMap`, `noDialogues`, etc. ne sont plus branchés. | | Player physics | Le joueur n'est pas Rapier, il utilise capsule + octree. |
| Player physics | Le joueur n'est pas Rapier, il utilise capsule + octree. | | Collision map | L'octree vient de nodes dédiés, actuellement `terrain`. |
| Collision map | L'octree vient de nodes dédiés, actuellement `terrain`. | | Editor save | Ce sont des endpoints Vite dev, pas une API de prod. |
| Editor save | Ce sont des endpoints Vite dev, pas une API de prod. | | Cinematics | `GameCinematics` est monté seulement pendant `outro` dans `World`. |
| Cinematics | `GameCinematics` est monté seulement pendant `outro` dans `World`. | | Hand tracking depth | Le `z` MediaPipe est relatif, pas une vraie profondeur monde stable. |
| Hand tracking depth | Le `z` MediaPipe est relatif, pas une vraie profondeur monde stable. |
## Si l'évaluateur ouvre un fichier au hasard ## Si l'évaluateur ouvre un fichier au hasard
@@ -833,8 +827,6 @@ Pour réutiliser le même flow sur plusieurs missions et garder les variations d
### Qu'est-ce qui est incomplet ? ### Qu'est-ce qui est incomplet ?
- pas de vrai `GameManager` global ; - pas de vrai `GameManager` global ;
- lock mouvement réparation désactivé ;
- `repairRuntime` pas consommé ;
- editor save uniquement en dev ; - editor save uniquement en dev ;
- hand tracking encore approximatif sur profondeur et smoothing. - hand tracking encore approximatif sur profondeur et smoothing.
@@ -869,8 +861,7 @@ Fichiers à avoir en tête :
Réponses pièges à réviser : Réponses pièges à réviser :
- lock mouvement repair désactivé actuellement ; - lock mouvement repair actif sur les étapes dédiées ;
- `repairRuntime` pas consommé ;
- player pas Rapier ; - player pas Rapier ;
- save editor pas production ; - save editor pas production ;
- old boot flags non branchés. - old boot flags non branchés.
-2
View File
@@ -51,8 +51,6 @@ public/models/electricienne_animated/model.gltf
with the `Dance` animation. with the `Dance` animation.
`src/hooks/animation/useCharacterAnimation.ts` is a hook-level alternative for components that need to own their group ref and animation controls directly.
## GLTF Reuse ## GLTF Reuse
Use `useClonedObject` when a GLTF scene is reused by a component instance. It memoizes `scene.clone(true)` and keeps clone creation out of render churn. Use `useClonedObject` when a GLTF scene is reused by a component instance. It memoizes `scene.clone(true)` and keeps clone creation out of render churn.
+10 -33
View File
@@ -52,7 +52,7 @@ intro → start-intro → naming → bienvenue → star-move → mission2 → se
- **Actions** : - **Actions** :
- Stocke `activityCity: false` dans le store Zustand - Stocke `activityCity: false` dans le store Zustand
- Joue l'audio `alertCentral` - Joue l'audio `alertCentral`
- **État** : Les objets avec hook `useActivityCity()` détectent le changement et jouent leurs animations - **État** : Les systèmes lisent `activityCity` depuis `useGameStore` pour adapter leur comportement
- **Attente** : Le joueur atteint la zone de trigger pour `searching_problem` - **Attente** : Le joueur atteint la zone de trigger pour `searching_problem`
### 7. `searching_problem` ### 7. `searching_problem`
@@ -81,15 +81,13 @@ intro → start-intro → naming → bienvenue → star-move → mission2 → se
| Fichier | Rôle | | Fichier | Rôle |
| --------------------------------------- | --------------------------------------------------------- | | --------------------------------------- | --------------------------------------------------------- |
| `src/stores/gameStore.ts` | Store Zustand pour l'état global du jeu | | `src/managers/stores/useGameStore.ts` | Store Zustand pour l'état global du jeu |
| `src/stateManager/GameStepManager.ts` | Synchronise avec le store Zustand |
| `src/components/game/GameFlow.tsx` | Gère les transitions automatiques et la lecture audio | | `src/components/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/ui/IntroUI.tsx` | Affiche l'input pour le prénom et le message de bienvenue |
| `src/components/zone/ZoneDetection.tsx` | Détecte quand le joueur entre dans une zone | | `src/components/zone/ZoneDetection.tsx` | Détecte quand le joueur entre dans une zone |
| `src/components/3d/CentralObject.tsx` | Objet interactif "central" pour la mission 2 | | `src/world/GameStageContent.tsx` | Monte les contenus de mission dans la scène |
| `src/data/audioConfig.ts` | Chemins des fichiers audio | | `src/data/audioConfig.ts` | Chemins des fichiers audio |
| `src/data/zones.ts` | Configuration des zones de transition | | `src/data/zones.ts` | Configuration des zones de transition |
| `src/hooks/useActivityCity.ts` | Hook pour détecter le changement d'activité de la ville |
--- ---
@@ -134,35 +132,14 @@ export const ZONES: Zone[] = [
## Store Zustand ## Store Zustand
```typescript ```typescript
// src/stores/gameStore.ts // src/managers/stores/useGameStore.ts
interface GameState { interface GameState {
step: GameStep; mainState: MainGameState;
activityCity: boolean; missionFlow: {
playerName: string; activityCity: boolean;
canMove: boolean; canMove: boolean;
setStep: (step: GameStep) => void; playerName: string;
setActivityCity: (value: boolean) => void; };
setPlayerName: (name: string) => void;
setCanMove: (canMove: boolean) => void;
}
```
---
## Hooks personnalisés
### useActivityCity
Permet aux objets 3D de réagir au changement d'activité de la ville :
```typescript
import { useActivityCity } from "@/hooks/useActivityCity";
function MyAnimatedObject() {
const activityCity = useActivityCity(); // true par défaut, false en mission2
// L'animation se déclenche quand activityCity change à false
// Utiliser useEffect pour réagir au changement
} }
``` ```
+1 -8
View File
@@ -160,7 +160,6 @@ State:
- `dialogueVolume` - `dialogueVolume`
- `subtitlesEnabled` - `subtitlesEnabled`
- `subtitleLanguage` - `subtitleLanguage`
- `repairRuntime`
Audio setters clamp values between `0` and `1`, then call: Audio setters clamp values between `0` and `1`, then call:
@@ -170,8 +169,6 @@ AudioManager.getInstance().setCategoryVolume(category, nextVolume);
This keeps UI state and browser audio state synchronized. This keeps UI state and browser audio state synchronized.
Current caveat: `repairRuntime` is stored and displayed in the settings menu, but the repair game does not consume it yet. Treat it as a staged architecture hook rather than an active runtime switch.
## Subtitle Store ## Subtitle Store
`useSubtitleStore` is intentionally tiny. `useSubtitleStore` is intentionally tiny.
@@ -222,13 +219,11 @@ Current overlays:
- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states - `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states
- `Crosshair`: player aiming helper - `Crosshair`: player aiming helper
- `InteractPrompt`: interaction prompt - `InteractPrompt`: interaction prompt
- `RepairMovementLockIndicator`: indicator intended for repair movement lock - `RepairMovementLockIndicator`: indicator shown while repair steps lock movement
- `HandTrackingVisualizer`: hand tracking SVG fallback/debug visualization - `HandTrackingVisualizer`: hand tracking SVG fallback/debug visualization
- `Subtitles`: active dialogue subtitle overlay - `Subtitles`: active dialogue subtitle overlay
- `GameSettingsMenu`: options menu and settings controls - `GameSettingsMenu`: options menu and settings controls
Current caveat: `useRepairMovementLocked()` returns `false` immediately on the current branch, so the movement-lock rule and indicator exist but are disabled at runtime.
## Regression Rules ## Regression Rules
- Do not store per-frame values in Zustand. - Do not store per-frame values in Zustand.
@@ -241,6 +236,4 @@ Current caveat: `useRepairMovementLocked()` returns `false` immediately on the c
## Next Steps ## Next Steps
- Decide whether `repairRuntime` should be removed, implemented, or clearly labeled as experimental.
- Re-enable or remove the repair movement-lock rule depending on desired gameplay.
- Move broader mission orchestration into a clearer layer if intro, mission, dialogue, and cinematic branching grows. - Move broader mission orchestration into a clearer layer if intro, mission, dialogue, and cinematic branching grows.
+2 -2
View File
@@ -80,9 +80,9 @@ This document lists the user-visible and developer-facing features implemented i
- Fragmentation through repair-case trigger or two-fists hand gesture - Fragmentation through repair-case trigger or two-fists hand gesture
- Exploded model visualization through `ExplodableModel` - Exploded model visualization through `ExplodableModel`
- Scan visual that steps through exploded parts - Scan visual that steps through exploded parts
- Broken-part detection by configured `nodeName`, with fallback to first scanned parts - Broken-part detection by configured `nodeName`, with diagnostics when configured parts are missing
- Persistent broken-part highlight and broken-part prompt after discovery - Persistent broken-part highlight and broken-part prompt after discovery
- Grabbable replacement part choices, including decoys - Grabbable replacement part choices, including distractor parts
- Grabbable broken parts that must be deposited back into the case - Grabbable broken parts that must be deposited back into the case
- Snap-to-placeholder placement - Snap-to-placeholder placement
- Correct-part, wrong-part, and stored-part visual feedback - Correct-part, wrong-part, and stored-part visual feedback
+2 -2
View File
@@ -33,11 +33,11 @@ For implementation details, see `docs/technical/repair-game.md`.
In `waiting`, the active mission renders its repair object and the `interagir.webm` prompt in the game scene. The interaction uses the shared focus/raycast interaction system, so the player still gets the normal `E` prompt. In `waiting`, the active mission renders its repair object and the `interagir.webm` prompt in the game scene. The interaction uses the shared focus/raycast interaction system, so the player still gets the normal `E` prompt.
When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config with a small pop animation. When the player is close enough, the case model floats upward and rotates gently to signal interactivity. The codebase also contains a shared repair movement-lock hook and HTML indicator, but `useRepairMovementLocked()` currently returns `false`, so movement remains available during the repair flow on the current branch. When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config with a small pop animation. When the player is close enough, the case model floats upward and rotates gently to signal interactivity. The shared repair movement-lock hook and HTML indicator keep movement disabled during active repair steps.
In `inspected`, `RepairGame` can also move to `fragmented`. Keyboard input goes through the shared focus/raycast interaction system on the repair case, so the player must be close enough and aim at the case before pressing `E`. The hand-tracking path still uses a two-fists hold gesture and is state-based, so it does not depend on being inside a local object interaction radius. In `inspected`, `RepairGame` can also move to `fragmented`. Keyboard input goes through the shared focus/raycast interaction system on the repair case, so the player must be close enough and aim at the case before pressing `E`. The hand-tracking path still uses a two-fists hold gesture and is state-based, so it does not depend on being inside a local object interaction radius.
In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan can match a specific `nodeName` when mission data provides one, otherwise it falls back to the first scanned parts as placeholder broken parts. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those placeholder positions, and releasing a part near a placeholder snaps it into place with a short GSAP animation. Scanned broken parts are also rendered as grabbable objects and must be deposited into a compatible placeholder before the final install target validates. If `brokenParts[].placeholderName` is configured, that broken part snaps only to the matching placeholder; otherwise it can use any available placeholder. If the current case asset has no placeholder nodes, the flow keeps using fallback focus positions. Replacement parts show green or red placement feedback after snapping, broken parts show stored feedback after deposit, and the install target gives a short blocked feedback if the player tries to validate too early. The install target only validates when the configured correct replacement part is placed and all scanned broken parts have been deposited. In `reassembling`, the exploded model animates back into its assembled position with green completion particles before the flow moves to `done`. In `done`, the repaired object remains visible with a completion target; validating closes the repair case first, then plays the case exit animation before advancing the global mission progression. In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan matches configured broken parts by `nodeName` and reports diagnostics when a configured node is missing. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those slot positions, and releasing a part near a slot snaps it into place with a short GSAP animation. Scanned broken parts are also rendered as grabbable objects and must be deposited into a compatible slot before the final install target validates. If `brokenParts[].caseSlotName` is configured, that broken part snaps only to the matching slot; otherwise it can use any available slot. If the current case asset has no slot nodes, the flow keeps using fallback focus positions and logs the fallback. Replacement parts show green or red placement feedback after snapping, broken parts show stored feedback after deposit, and the install target gives a short blocked feedback if the player tries to validate too early. The install target only validates when the configured correct replacement part is placed and all scanned broken parts have been deposited. In `reassembling`, the exploded model animates back into its assembled position with green completion particles before the flow moves to `done`. In `done`, the repaired object remains visible with a completion target; validating closes the repair case first, then plays the case exit animation before advancing the global mission progression.
The mission config now carries the mission-specific variations. `bike` repairs one cooling core, `pylone` scans and stores both the lamp relay and a damaged panel with slower scan/reassembly timing, and `ferme` scans and stores an irrigation pump plus humidity sensor with faster scan/reassembly timing. The mission config now carries the mission-specific variations. `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.
@@ -445,11 +445,14 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
Voix Voix
<select <select
value={selectedDialogue.voice} value={selectedDialogue.voice}
onChange={(event) => onChange={(event) => {
updateSelectedDialogue({ const selectedVoice = voices.find(
voice: event.target.value as DialogueVoiceId, (voice) => voice.id === event.target.value,
}) );
} if (!selectedVoice) return;
updateSelectedDialogue({ voice: selectedVoice.id });
}}
> >
{voices.map((voice) => ( {voices.map((voice) => (
<option key={voice.id} value={voice.id}> <option key={voice.id} value={voice.id}>
+36 -26
View File
@@ -1,14 +1,19 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Download, RefreshCw, Save } from "lucide-react"; import { Download, RefreshCw, Save } from "lucide-react";
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore"; import type { SubtitleLanguage } from "@/types/settings/settings";
import type { import type {
DialogueDefinition, DialogueDefinition,
DialogueManifest, DialogueManifest,
DialogueSpeaker, DialogueSpeaker,
DialogueVoiceId, DialogueVoiceId,
} from "@/types/dialogues/dialogues"; } from "@/types/dialogues/dialogues";
import { logger } from "@/utils/core/Logger";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { parseSrt } from "@/utils/subtitles/parseSrt"; import {
parseSrt,
parseSrtTime,
parseSrtWithDiagnostics,
} from "@/utils/subtitles/parseSrt";
interface SrtVoiceOption { interface SrtVoiceOption {
id: DialogueVoiceId; id: DialogueVoiceId;
@@ -88,21 +93,6 @@ function formatPreviewTime(totalSeconds: number): string {
return `${Math.max(0, totalSeconds).toFixed(1)}s`; return `${Math.max(0, totalSeconds).toFixed(1)}s`;
} }
function parseSrtTime(value: string): number | null {
const match = value.match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
if (!match) return null;
const [, hours, minutes, seconds, milliseconds] = match;
if (!hours || !minutes || !seconds || !milliseconds) return null;
return (
Number(hours) * 3600 +
Number(minutes) * 60 +
Number(seconds) +
Number(milliseconds) / 1000
);
}
function padTime(value: number): string { function padTime(value: number): string {
return value.toString().padStart(2, "0"); return value.toString().padStart(2, "0");
} }
@@ -120,7 +110,7 @@ function getSrtDiagnostic(
.trim() .trim()
.split(/\n{2,}/) .split(/\n{2,}/)
.filter(Boolean); .filter(Boolean);
const cues = parseSrt(content); const { cues, diagnostics } = parseSrtWithDiagnostics(content);
const errors: string[] = []; const errors: string[] = [];
const indexes = new Set<number>(); const indexes = new Set<number>();
@@ -164,6 +154,10 @@ function getSrtDiagnostic(
); );
} }
for (const diagnostic of diagnostics) {
errors.push(`Bloc ${diagnostic.blockIndex + 1}: ${diagnostic.reason}.`);
}
const cueIndexes = new Set(cues.map((cue) => cue.index)); const cueIndexes = new Set(cues.map((cue) => cue.index));
const missingCueIndexes = expectedCueIndexes.filter( const missingCueIndexes = expectedCueIndexes.filter(
(cueIndex) => !cueIndexes.has(cueIndex), (cueIndex) => !cueIndexes.has(cueIndex),
@@ -470,8 +464,14 @@ export function EditorSrtPanel(): React.JSX.Element {
.then((loadedManifest) => { .then((loadedManifest) => {
if (mounted) setManifest(loadedManifest); if (mounted) setManifest(loadedManifest);
}) })
.catch(() => { .catch((error) => {
if (mounted) setManifest(null); if (!mounted) return;
setManifest(null);
setStatus("Erreur de chargement du manifeste dialogues");
logger.error("EditorSrt", "Failed to load dialogue manifest", {
error: error instanceof Error ? error : String(error),
});
}); });
return () => { return () => {
@@ -519,9 +519,14 @@ export function EditorSrtPanel(): React.JSX.Element {
Voix Voix
<select <select
value={voice} value={voice}
onChange={(event) => onChange={(event) => {
setVoice(event.target.value as DialogueVoiceId) const selectedVoice = SRT_VOICES.find(
} (item) => item.id === event.target.value,
);
if (selectedVoice) {
setVoice(selectedVoice.id);
}
}}
> >
{SRT_VOICES.map((item) => ( {SRT_VOICES.map((item) => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
@@ -535,9 +540,14 @@ export function EditorSrtPanel(): React.JSX.Element {
Langue Langue
<select <select
value={language} value={language}
onChange={(event) => onChange={(event) => {
setLanguage(event.target.value as SubtitleLanguage) const selectedLanguage = SRT_LANGUAGES.find(
} (item) => item === event.target.value,
);
if (selectedLanguage) {
setLanguage(selectedLanguage);
}
}}
> >
{SRT_LANGUAGES.map((item) => ( {SRT_LANGUAGES.map((item) => (
<option key={item} value={item}> <option key={item} value={item}>
@@ -5,7 +5,7 @@ import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig"; import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig"; import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions"; import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
interface RepairCompletionStepProps { interface RepairCompletionStepProps {
config: RepairMissionConfig; config: RepairMissionConfig;
+4 -8
View File
@@ -7,21 +7,17 @@ import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspec
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep"; import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep"; import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
import { import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
RepairScanSequence,
type RepairScannedBrokenPart,
} from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig"; import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig"; import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
import { import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
REPAIR_MISSIONS,
type RepairMissionConfig,
} from "@/data/gameplay/repairMissions";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput"; import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep"; import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import type { import type {
MissionStep, MissionStep,
RepairMissionConfig,
RepairMissionId, RepairMissionId,
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission"; } from "@/types/gameplay/repairMission";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
@@ -2,7 +2,7 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel"; import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo"; import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig"; import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions"; import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
interface RepairInspectionObjectProps { interface RepairInspectionObjectProps {
@@ -10,7 +10,7 @@ import {
REPAIR_CASE_MODEL_PATH, REPAIR_CASE_MODEL_PATH,
} from "@/data/gameplay/repairCaseConfig"; } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig"; import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions"; import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
interface RepairMissionCaseProps { interface RepairMissionCaseProps {
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles"; import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
import { ExplodableModel } from "@/components/three/models/ExplodableModel"; import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig"; import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions"; import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
interface RepairReassemblyStepProps { interface RepairReassemblyStepProps {
config: RepairMissionConfig; config: RepairMissionConfig;
@@ -3,7 +3,6 @@ import * as THREE from "three";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel"; import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel"; import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo"; import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { import {
@@ -15,7 +14,9 @@ import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { import type {
RepairMissionConfig, RepairMissionConfig,
RepairMissionPartConfig, RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions"; RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { logger } from "@/utils/core/Logger";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0]; const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
@@ -34,6 +35,7 @@ const REPAIR_INSTALL_RADIUS = 1.1;
const VALID_PART_COLOR = "#22c55e"; const VALID_PART_COLOR = "#22c55e";
const INVALID_PART_COLOR = "#ef4444"; const INVALID_PART_COLOR = "#ef4444";
const STORED_BROKEN_PART_COLOR = "#38bdf8"; const STORED_BROKEN_PART_COLOR = "#38bdf8";
let hasWarnedMissingPlaceholders = false;
interface RepairRepairingStepProps { interface RepairRepairingStepProps {
brokenParts: readonly RepairScannedBrokenPart[]; brokenParts: readonly RepairScannedBrokenPart[];
@@ -400,6 +402,14 @@ function getPlaceholderTargets(
return placeholders; return placeholders;
} }
if (!hasWarnedMissingPlaceholders) {
hasWarnedMissingPlaceholders = true;
logger.warn(
"RepairGame",
"Repair case placeholders missing, using fallback slots",
);
}
return FALLBACK_PLACEHOLDER_OFFSETS.map( return FALLBACK_PLACEHOLDER_OFFSETS.map(
(offset, index): RepairCasePlaceholder => ({ (offset, index): RepairCasePlaceholder => ({
name: `placeholder_${index + 1}`, name: `placeholder_${index + 1}`,
@@ -416,12 +426,12 @@ function getBrokenPartTargetPositions(
part: RepairScannedBrokenPart, part: RepairScannedBrokenPart,
placeholderTargets: readonly RepairCasePlaceholder[], placeholderTargets: readonly RepairCasePlaceholder[],
): readonly Vector3Tuple[] { ): readonly Vector3Tuple[] {
if (!part.placeholderName) { if (!part.caseSlotName) {
return placeholderTargets.map((placeholder) => placeholder.position); return placeholderTargets.map((placeholder) => placeholder.position);
} }
const matchingPlaceholder = placeholderTargets.find( const matchingPlaceholder = placeholderTargets.find(
(placeholder) => placeholder.name === part.placeholderName, (placeholder) => placeholder.name === part.caseSlotName,
); );
return matchingPlaceholder return matchingPlaceholder
@@ -475,6 +485,6 @@ function getBrokenPartsToDeposit(
id: part.id, id: part.id,
label: part.label, label: part.label,
modelPath: part.modelPath ?? config.modelPath, modelPath: part.modelPath ?? config.modelPath,
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}), ...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}),
})); }));
} }
@@ -8,7 +8,9 @@ import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { import type {
RepairMissionConfig, RepairMissionConfig,
RepairMissionPartConfig, RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions"; RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { logger } from "@/utils/core/Logger";
import type { ExplodedPart } from "@/utils/three/ExplodedModel"; import type { ExplodedPart } from "@/utils/three/ExplodedModel";
interface RepairScanSequenceProps { interface RepairScanSequenceProps {
@@ -16,13 +18,13 @@ interface RepairScanSequenceProps {
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void; onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
} }
export interface RepairScannedBrokenPart { interface RepairBrokenPartMatch {
id: string; config: RepairMissionPartConfig;
label: string; partIndex: number;
modelPath: string;
placeholderName?: string;
} }
const warnedMissingScanParts = new Set<string>();
export function RepairScanSequence({ export function RepairScanSequence({
config, config,
onComplete, onComplete,
@@ -31,9 +33,9 @@ export function RepairScanSequence({
const [activePartIndex, setActivePartIndex] = useState(0); const [activePartIndex, setActivePartIndex] = useState(0);
const activePart = parts[activePartIndex]; const activePart = parts[activePartIndex];
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS; const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts); const brokenPartMatches = getBrokenPartMatches(parts, config);
const visibleBrokenPartIndexes = brokenPartIndexes.filter( const visibleBrokenPartMatches = brokenPartMatches.filter(
(partIndex) => partIndex <= activePartIndex, (match) => match.partIndex <= activePartIndex,
); );
useEffect(() => { useEffect(() => {
@@ -65,8 +67,8 @@ export function RepairScanSequence({
onPartsReady={setParts} onPartsReady={setParts}
/> />
<RepairScanVisual target={activePart?.object} /> <RepairScanVisual target={activePart?.object} />
{visibleBrokenPartIndexes.map((partIndex) => { {visibleBrokenPartMatches.map((match) => {
const part = parts[partIndex]; const part = parts[match.partIndex];
if (!part) return null; if (!part) return null;
return ( return (
@@ -87,29 +89,25 @@ function getScannedBrokenParts(
parts: readonly ExplodedPart[], parts: readonly ExplodedPart[],
config: RepairMissionConfig, config: RepairMissionConfig,
): readonly RepairScannedBrokenPart[] { ): readonly RepairScannedBrokenPart[] {
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts); return getBrokenPartMatches(parts, config).map((match) => {
return brokenPartIndexes.map((_, index) => {
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
return { return {
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`, id: match.config.id,
label: configuredPart?.label ?? `${config.label} broken part`, label: match.config.label,
modelPath: configuredPart?.modelPath ?? config.modelPath, modelPath: match.config.modelPath ?? config.modelPath,
...(configuredPart?.placeholderName ...(match.config.caseSlotName
? { placeholderName: configuredPart.placeholderName } ? { caseSlotName: match.config.caseSlotName }
: {}), : {}),
}; };
}); });
} }
function getBrokenPartIndexes( function getBrokenPartMatches(
parts: readonly ExplodedPart[], parts: readonly ExplodedPart[],
brokenParts: readonly RepairMissionPartConfig[], config: RepairMissionConfig,
): number[] { ): RepairBrokenPartMatch[] {
if (parts.length === 0 || brokenParts.length === 0) return []; if (parts.length === 0 || config.brokenParts.length === 0) return [];
const matchedIndexes = brokenParts.flatMap((brokenPart) => { const matches = config.brokenParts.flatMap((brokenPart) => {
const { nodeName } = brokenPart; const { nodeName } = brokenPart;
if (!nodeName) return []; if (!nodeName) return [];
@@ -117,12 +115,30 @@ function getBrokenPartIndexes(
objectContainsNodeName(part.object, nodeName), objectContainsNodeName(part.object, nodeName),
); );
return index >= 0 ? [index] : []; return index >= 0 ? [{ config: brokenPart, partIndex: index }] : [];
}); });
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)]; if (matches.length !== config.brokenParts.length) {
const matchedIds = new Set(matches.map((match) => match.config.id));
const missingIds = config.brokenParts
.filter((brokenPart) => !matchedIds.has(brokenPart.id))
.map((brokenPart) => brokenPart.id);
return parts.slice(0, brokenParts.length).map((_, index) => index); const warningKey = `${config.id}:${missingIds.join(",")}`;
if (!warnedMissingScanParts.has(warningKey)) {
warnedMissingScanParts.add(warningKey);
logger.warn("RepairScan", "Broken parts missing from exploded model", {
missionId: config.id,
missingIds,
});
}
}
return matches.filter(
(match, index, allMatches) =>
allMatches.findIndex((item) => item.partIndex === match.partIndex) ===
index,
);
} }
function objectContainsNodeName( function objectContainsNodeName(
+17 -5
View File
@@ -7,8 +7,9 @@ import {
} from "@/hooks/animation/useAnimatedModel"; } from "@/hooks/animation/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps } from "@/types/three/three"; import type { ModelTransformProps } from "@/types/three/three";
import { logger } from "@/utils/core/Logger";
export interface AnimatedModelConfig extends ModelTransformProps { interface AnimatedModelConfig extends ModelTransformProps {
modelPath: string; modelPath: string;
animations?: string[]; animations?: string[];
defaultAnimation?: string; defaultAnimation?: string;
@@ -121,17 +122,28 @@ export function AnimatedModel({
return; return;
} }
let defaultAction = actions[defaultAnimation as string]; let defaultAction = actions[defaultAnimation];
if (!defaultAction && names.length > 0) { const fallbackAnimation = names[0];
defaultAction = actions[names[0] as string]; if (!defaultAction && fallbackAnimation) {
logger.warn(
"AnimatedModel",
"Default animation missing, using fallback",
{
modelPath,
defaultAnimation,
fallbackAnimation,
availableAnimations: names,
},
);
defaultAction = actions[fallbackAnimation];
} }
if (defaultAction) { if (defaultAction) {
defaultAction.play(); defaultAction.play();
onLoaded?.(); onLoaded?.();
} }
}, [actions, defaultAnimation, names, autoPlay, onLoaded]); }, [actions, defaultAnimation, modelPath, names, autoPlay, onLoaded]);
const contextValue: AnimatedModelContextValue = { const contextValue: AnimatedModelContextValue = {
play, play,
+1 -1
View File
@@ -17,7 +17,7 @@ function applyShadowSettings(
}); });
} }
export interface SimpleModelConfig extends ModelTransformProps { interface SimpleModelConfig extends ModelTransformProps {
modelPath: string; modelPath: string;
castShadow?: boolean; castShadow?: boolean;
receiveShadow?: boolean; receiveShadow?: boolean;
@@ -93,8 +93,11 @@ function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
return [...groups.values()] return [...groups.values()]
.map((group) => { .map((group) => {
if (group.geometries.length === 1) { if (group.geometries.length === 1) {
const [geometry] = group.geometries;
if (!geometry) return null;
return { return {
geometry: group.geometries[0] as THREE.BufferGeometry, geometry,
material: group.material, material: group.material,
}; };
} }
+1 -28
View File
@@ -2,10 +2,7 @@ import { useEffect } from "react";
import { RotateCcw, X } from "lucide-react"; import { RotateCcw, X } from "lucide-react";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { import type { SubtitleLanguage } from "@/types/settings/settings";
RepairRuntime,
SubtitleLanguage,
} from "@/managers/stores/useSettingsStore";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled"; import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
function formatPercent(value: number): string { function formatPercent(value: number): string {
@@ -62,14 +59,12 @@ export function GameSettingsMenu(): React.JSX.Element | null {
dialogueVolume, dialogueVolume,
subtitlesEnabled, subtitlesEnabled,
subtitleLanguage, subtitleLanguage,
repairRuntime,
setMusicVolume, setMusicVolume,
setSfxVolume, setSfxVolume,
setDialogueVolume, setDialogueVolume,
setSettingsMenuOpen, setSettingsMenuOpen,
setSubtitlesEnabled, setSubtitlesEnabled,
setSubtitleLanguage, setSubtitleLanguage,
setRepairRuntime,
} = useSettingsStore(); } = useSettingsStore();
useEffect(() => { useEffect(() => {
@@ -178,28 +173,6 @@ export function GameSettingsMenu(): React.JSX.Element | null {
</div> </div>
</section> </section>
<section
className="game-settings-menu__section"
aria-labelledby="repair-settings-heading"
>
<h3 id="repair-settings-heading">Repair game</h3>
<div className="game-settings-menu__choice-group game-settings-menu__choice-group--stacked">
{(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
<button
key={runtime}
type="button"
className={repairRuntime === runtime ? "active" : undefined}
onClick={() => setRepairRuntime(runtime)}
aria-pressed={repairRuntime === runtime}
>
{runtime === "js"
? "Repair game en JS (local)"
: "Repair game en Python (server)"}
</button>
))}
</div>
</section>
{showDebugRestart ? ( {showDebugRestart ? (
<button <button
className="game-settings-menu__restart" className="game-settings-menu__restart"
+1 -1
View File
@@ -2,7 +2,7 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { DialogueSpeaker } from "@/types/dialogues/dialogues"; import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
export type SubtitleSpeaker = DialogueSpeaker; type SubtitleSpeaker = DialogueSpeaker;
interface SubtitlesProps { interface SubtitlesProps {
speaker?: SubtitleSpeaker | null; speaker?: SubtitleSpeaker | null;
+11 -15
View File
@@ -1,18 +1,12 @@
import { RotateCcw, StepBack, StepForward } from "lucide-react"; import { RotateCcw, StepBack, StepForward } from "lucide-react";
import { import { useGameStore } from "@/managers/stores/useGameStore";
type MainGameState,
useGameStore,
} from "@/managers/stores/useGameStore";
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission"; import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
import { GAME_STEPS, type GameStep } from "@/types/game"; import {
GAME_STEPS,
const MAIN_STATES: MainGameState[] = [ isGameStep,
"intro", MAIN_GAME_STATES,
"bike", type MainGameState,
"pylone", } from "@/types/game";
"ferme",
"outro",
];
function toPascalCase(value: string): string { function toPascalCase(value: string): string {
return value return value
@@ -60,7 +54,9 @@ export function GameStateDebugPanel(): React.JSX.Element {
function setSubState(nextSubState: string): void { function setSubState(nextSubState: string): void {
if (mainState === "intro") { if (mainState === "intro") {
setIntroStep(nextSubState as GameStep); if (isGameStep(nextSubState)) {
setIntroStep(nextSubState);
}
return; return;
} }
@@ -124,7 +120,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
aria-label="Main states" aria-label="Main states"
role="group" role="group"
> >
{MAIN_STATES.map((state) => ( {MAIN_GAME_STATES.map((state) => (
<button <button
key={state} key={state}
aria-pressed={state === mainState} aria-pressed={state === mainState}
+1 -1
View File
@@ -30,7 +30,7 @@ export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
) => { ) => {
const { camera: rawCamera } = useThree(); const { camera: rawCamera } = useThree();
const cameraRef = useRef(rawCamera); const cameraRef = useRef(rawCamera);
const keys = useRef<{ [key: string]: boolean }>({}); const keys = useRef<Partial<Record<string, boolean>>>({});
const controlsRef = useRef<OrbitControlsRef | null>(null); const controlsRef = useRef<OrbitControlsRef | null>(null);
const lastPosition = useRef(new THREE.Vector3()); const lastPosition = useRef(new THREE.Vector3());
+3 -709
View File
@@ -1,459 +1,3 @@
export const readmeFr = `# La-Fabrik
Une expérience web 3D interactive pour La Fabrik Durable, un service low-tech de réparation et de transformation situé à Altera, une ville post-capitaliste reconstruite en 2039. Les joueurs incarnent un technicien fraîchement intégré et vivent une journée de service : réparer un vélo électrique, remettre en état un réseau d'énergie et améliorer le système d'irrigation d'une ferme verticale.
Construit avec React, Three.js et Vite. Fonctionne dans le navigateur, sans installation côté utilisateur.
## Stack technique
### Build et langage
| Package |
| -------------------------------------------------- |
| [TypeScript](https://www.typescriptlang.org/docs/) |
| [React](https://react.dev/learn) |
| [Vite](https://vite.dev/guide/) |
| [ESLint](https://eslint.org/docs/latest/) |
| [Prettier](https://prettier.io/docs/) |
### Moteur 3D
| Package |
| ----------------------------------------------------------------------------------------- |
| [Three.js](https://threejs.org/docs/) |
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
| [@react-three/drei](https://pmndrs.github.io/drei) |
| [@react-three/rapier](https://rapier.rs/docs/) |
| [GSAP](https://gsap.com/docs/v3/Installation/) |
### Performance et effets
| Package |
| --------------------------------------------------------------------------- |
| [r3f-perf](https://github.com/utsuboco/r3f-perf) |
| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) |
## Structure du projet
\`\`\`
la-fabrik/
├── public/
│ ├── models/
│ │ ├── map/ # Carte de base, chargée au démarrage
│ │ ├── workshop/
│ │ ├── powerGrid/
│ │ └── farm/
│ ├── textures/
│ └── sounds/
└── src/
├── world/ # Composition du monde 3D persistant
│ ├── World.tsx # Composition de la scène active
│ ├── GameMap.tsx # Chargement de carte et collision octree
│ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles
│ ├── Environment.tsx # Arrière-plan et modèle de ciel
│ ├── GameMusic.tsx # Cycle de vie de la musique de jeu
│ ├── debug/ # Scène de test debug
│ └── player/ # Contrôleur joueur et caméra
├── components/
│ ├── three/ # Composants R3F par domaine
│ └── ui/ # Overlays HTML hors Canvas
├── managers/ # Logique, état et orchestration
├── hooks/ # Hooks React autour des managers
├── data/ # Configuration statique
├── shaders/ # Shaders GLSL
└── utils/ # Utilitaires partagés et debug
\`\`\`
## Démarrage
\`\`\`bash
git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git
cd La-Fabrik
npm install
npm run dev
\`\`\`
- application : \`http://localhost:5173\`
- mode debug : \`http://localhost:5173?debug\`
## Licence
Voir le fichier [LICENSE](./LICENSE).
`;
export const architectureFr = `# Architecture actuelle
Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
## Structure runtime
- \`src/App.tsx\` monte le \`RouterProvider\`, qui pilote l'affichage des vues de l'application.
- \`src/pages/page.tsx\` monte le \`Canvas\`, le \`World\` 3D, l'overlay de performance debug et les overlays HTML.
- \`src/world/World.tsx\` compose la scène active avec :
- l'environnement et l'éclairage
- les helpers debug et le mode caméra debug
- soit la carte principale, soit la scène de test physique debug
- le rig joueur quand le mode caméra actif est \`player\`
- \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision.
- \`src/world/GameStageContent.tsx\` est enveloppé dans le contexte Rapier \`Physics\` dans la scène de jeu de production afin que les objets gameplay de stage puissent utiliser la physique sans migrer la carte ou le joueur vers Rapier. Il monte maintenant des instances réutilisables de \`RepairGame\` pour les états de mission \`bike\`, \`pylone\` et \`ferme\`.
- \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique, avec les objets existants de grab, trigger et preview de modèle, plus des zones playground de réparation séparées \`Bike\`, \`Pylone\` et \`Farm\`.
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut, le verrouillage de déplacement pendant les étapes repair et les inputs d'interaction.
## Frontières physiques
Le projet utilise actuellement deux couches de collision avec des responsabilités séparées :
- \`GameMap\` construit une octree utilisée par le contrôleur joueur pour les collisions avec la carte.
- \`GameStageContent\` est enveloppé dans Rapier \`Physics\` pour les objets gameplay comme les triggers de réparation, les mallettes, les objets saisissables et les futurs objets spécifiques aux missions.
- \`TestMap\` possède son propre playground Rapier \`Physics\` afin de peaufiner le gameplay de réparation par state de mission sans dépendre du placement de la carte de production.
Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il n'existe pas de plan de migration volontaire. Cela évite de mélanger les règles de déplacement joueur avec la physique d'objets avant que les systèmes gameplay en aient besoin.
## Modèle d'interaction
- \`src/managers/InteractionManager.ts\` est la source d'état actuelle des interactions.
- \`src/components/three/interaction/InteractableObject.tsx\` gère la détection de focus par distance et raycasting.
- \`src/components/three/interaction/TriggerObject.tsx\` implémente les interactions de type trigger.
- \`src/components/three/interaction/GrabbableObject.tsx\` implémente les interactions saisir / relâcher.
- \`src/hooks/interaction/useInteraction.ts\` expose un snapshot d'interaction à l'UI React.
- \`src/components/ui/InteractPrompt.tsx\` affiche le prompt \`E\` pour les interactions trigger.
## Audio
- \`src/managers/AudioManager.ts\` fournit la lecture de sons one-shot avec pool, la musique en boucle, les volumes par catégorie et un pan stéréo optionnel pour les sons one-shot.
- Les catégories audio supportées sont \`music\`, \`sfx\` et \`dialogue\`.
- Les interactions trigger peuvent lancer directement des SFX via \`AudioManager\`.
## Menu options
- \`src/managers/stores/useSettingsStore.ts\` stocke les réglages de volume musique, volume SFX, volume dialogue, sous-titres, langue des sous-titres, runtime de réparation et visibilité du menu.
- \`src/components/ui/GameSettingsMenu.tsx\` rend le menu options en jeu.
- \`src/components/ui/GameUI.tsx\` monte le menu comme overlay HTML hors canvas.
- \`Esc\` ouvre et ferme le menu, et \`src/world/player/PlayerController.tsx\` ignore les inputs joueur pendant son ouverture.
- Les changements de volume sont transmis à \`AudioManager\` par catégorie.
## Dialogues et sous-titres
- \`public/sounds/dialogue/dialogues.json\` est le manifeste runtime des dialogues.
- Les fichiers audio de dialogue vivent dans \`public/sounds/dialogue/\`.
- Les fichiers de sous-titres vivent dans \`public/sounds/dialogue/subtitles/{fr|en}/\`.
- Le modèle actuel utilise un fichier SRT par voix et par langue.
- \`src/types/dialogues/dialogues.ts\` contient les types du manifeste.
- \`src/utils/dialogues/dialogueManifestValidation.ts\` valide la forme du manifeste au runtime.
- \`src/utils/dialogues/loadDialogueManifest.ts\` charge le manifeste et les cues SRT, avec fallback français si la langue sélectionnée manque.
- \`src/utils/subtitles/parseSrt.ts\` parse les blocs et timecodes SRT.
- \`src/utils/dialogues/playDialogue.ts\` joue l'audio de dialogue et synchronise le sous-titre actif avec le temps de l'élément audio.
- \`src/managers/stores/useSubtitleStore.ts\` stocke la cue de sous-titre affichée.
- \`src/components/ui/Subtitles.tsx\` rend l'overlay de sous-titres.
- \`src/world/GameDialogues.tsx\` déclenche actuellement les dialogues qui définissent un \`timecode\`.
- La lecture de dialogue est mise en file pour éviter les chevauchements.
## Cinématiques
- \`public/cinematics.json\` est le manifeste runtime des cinématiques.
- \`src/types/cinematics/cinematics.ts\` contient les types du manifeste.
- \`src/utils/cinematics/cinematicManifestValidation.ts\` valide la forme du manifeste.
- \`src/utils/cinematics/loadCinematicManifest.ts\` charge \`/cinematics.json\`.
- \`src/world/GameCinematics.tsx\` déclenche les cinématiques qui définissent un \`timecode\` global.
- Les cinématiques utilisent GSAP pour animer la position caméra et sa cible de regard.
- Les \`dialogueCues\` d'une cinématique déclenchent des dialogues à des temps relatifs au début de la cinématique.
- \`useGameStore.isCinematicPlaying\` sert à bloquer les inputs joueur pendant une cinématique.
## Système debug
- Le mode debug est activé avec \`?debug\`.
- \`src/utils/debug/Debug.ts\` possède l'instance \`lil-gui\` et les contrôles debug.
- \`src/hooks/debug/useCameraMode.ts\` et \`src/hooks/debug/useSceneMode.ts\` s'abonnent à l'état debug.
- \`src/components/debug/DebugPerf.tsx\` monte \`r3f-perf\` en lazy uniquement en mode debug.
- \`src/components/ui/debug/DebugOverlayLayout.tsx\` monte l'overlay HTML debug compact quand il est activé depuis \`lil-gui\`.
- \`src/components/ui/debug/GameStateDebugPanel.tsx\` expose l'état de jeu courant, le changement de main/sub-state, les contrôles previous/next step et le reset.
- \`src/components/ui/debug/HandTrackingDebugPanel.tsx\` affiche le statut hand tracking, l'usage, le modèle de gant chargé, le nombre de mains et l'état fist pendant l'activation du hand tracking.
- \`src/components/three/handTracking/HandTrackingGlove.tsx\` place les modèles riggés \`gant_l\` et \`gant_r\` sur les mains détectées dans la scène physics debug.
- \`src/components/debug/scene/DebugHelpers.tsx\` monte les helpers debug.
- \`src/components/debug/scene/DebugCameraControls.tsx\` monte la caméra libre debug.
- Les contrôles globaux \`lil-gui\` incluent camera mode, scene mode, \`R3F Perf\` et \`Debug Overlay\`; les contrôles d'interaction vivent dans le dossier \`Interaction\`.
## Domaines de composants 3D
- \`src/components/three/models/\` contient les helpers de modèles réutilisables comme \`ExplodableModel\`.
- \`src/components/three/interaction/\` contient les wrappers d'interaction réutilisables comme \`InteractableObject\`, \`TriggerObject\` et \`GrabbableObject\`.
- \`src/components/three/handTracking/\` contient les modèles debug R3F liés au hand tracking, comme les gants.
- \`src/components/three/gameplay/\` contient les composants de gameplay de réparation : le flow de production réutilisable \`RepairGame\`, la mallette, les étapes de réparation et les prompts.
- \`src/components/three/world/\` contient les objets world/environnement réutilisables comme \`SkyModel\`.
## Limites actuelles
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
- L'état de mission existe dans Zustand et le flow de réparation est implémenté comme prototype pour les missions de réparation actuelles.
- Les cinématiques et dialogues existent comme systèmes prototype pilotés par timecode; les branches de dialogue et l'orchestration gameplay globale restent limitées.
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
`;
export const targetArchitectureFr = `# Architecture cible
Ce document décrit l'architecture visée à moyen terme pour le projet.
## Relation avec le code actuel
- \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant.
- Ce document décrit une direction d'architecture, pas un comportement implémenté.
- Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne.
## Objectifs
- Garder \`App.tsx\` petit et centré sur l'orchestration.
- Séparer le code de production du monde des chemins runtime uniquement debug.
- Garder une source de vérité claire par responsabilité.
- Faire grandir les systèmes gameplay progressivement, sans préconstruire une architecture vide.
## Couches prévues
### Couche App
- \`App.tsx\` monte la scène canvas et les overlays HTML de premier niveau.
- Il doit rester fin et éviter la logique gameplay.
### Couche World
- \`src/world/\` doit contenir la composition de scène de production et les objets de scène de production.
- Responsabilités attendues :
- composition du monde
- carte, environnement, éclairage
- contrôleur joueur
- ancres d'interaction de production
- post-processing de production si nécessaire
### Couche Debug
- Les scènes et outils uniquement debug doivent être isolés du chemin de production.
- Responsabilités attendues :
- \`lil-gui\`
- overlay de performance
- helpers de scène
- caméra libre et contrôles de calibration
- scènes temporaires de test utilisées pendant le développement
### Couche UI
- \`src/components/ui/\` doit contenir les overlays HTML visibles par le joueur.
- Exemples futurs :
- crosshair
- flow de chargement
- HUD de mission
- overlays narratifs
### Couche Gameplay
- À mesure que le projet grandit, l'état gameplay peut évoluer vers une couche d'orchestration plus claire.
- Sujets probables :
- missions
- zones
- cinématiques
- dialogues
- audio
- interactions
## Règles
- Préférer du code direct et fonctionnel plutôt qu'un échafaudage spéculatif.
- Les types partagés doivent rester proches de leur domaine jusqu'à avoir plusieurs vrais consommateurs.
- Éviter de créer de nouveaux managers ou services sans besoin runtime actif.
- Les chemins runtime uniquement debug doivent être clairement marqués et faciles à retirer plus tard.
`;
export const zustandFr = `# État de jeu Zustand
Ce document explique comment Zustand est utilisé dans le projet actuel.
## Pourquoi Zustand existe ici
Le projet a besoin d'une source de vérité partagée pour suivre la progression du joueur dans l'expérience.
La progression actuelle est découpée en main states :
| Main state | Rôle |
| --- | --- |
| \`intro\` | Onboarding et séquence d'ouverture |
| \`bike\` | Séquence de réparation du vélo électrique |
| \`pylone\` | Séquence du réseau électrique |
| \`ferme\` | Séquence de la ferme verticale |
| \`outro\` | Séquence de fin |
Chaque main state peut aussi posséder un sous-état plus fin, comme l'étape de mission courante, l'audio de dialogue ou des flags de complétion.
Zustand est utile parce que les composants React et React Three Fiber peuvent s'abonner uniquement à la partie de state dont ils ont besoin. Quand cette partie change, seuls les composants abonnés se mettent à jour.
## Emplacement du store
Le store de progression du jeu vit ici :
\`\`\`txt
src/managers/stores/useGameStore.ts
\`\`\`
Le store est placé dans \`src/managers/stores/\` parce qu'il appartient à la couche d'orchestration gameplay, pas à un composant visuel précis.
## Managers vs Store
Les managers sont responsables des objets runtime locaux et des comportements impératifs.
Exemples :
- \`AudioManager\` possède les éléments audio et les pools de sons.
- \`InteractionManager\` possède les handles d'interaction transitoires et la logique orientée input.
Un manager peut lire ou mettre à jour le store Zustand quand son comportement local doit impacter la progression globale du jeu.
Le store Zustand est responsable de l'état global durable :
- main state courant
- sous-état de mission
- flags de progression
- références de dialogue/audio
- transitions de state
Règle simple :
- manager = objets runtime, effets de bord et logique impérative locale
- store = état gameplay global auquel l'UI ou le world peuvent s'abonner
## Forme actuelle
Le store expose :
- \`mainState\` : phase active du jeu
- \`missionFlow\` : état prototype de l'intro et de la mission 2
- \`intro\` : état spécifique à l'intro
- \`bike\` : état de la mission vélo
- \`pylone\` : état de la mission réseau électrique
- \`ferme\` : état de la mission ferme
- \`outro\` : état de fin
- des actions de mise à jour directe et des actions de progression
Le slice \`missionFlow\` contient l'étape prototype, le prénom joueur, le lock de déplacement, le flag d'activité de la ville et le message de dialogue temporaire. Il vit dans le store principal parce qu'il s'agit d'un état gameplay global utilisé par l'UI, le world et le controller joueur.
Les étapes de mission utilisent actuellement cette séquence :
\`\`\`ts
"locked" | "waiting" | "inspected" | "fragmented" | "scanning" | "repairing" | "reassembling" | "done"
\`\`\`
## Lire le state dans un composant
Utilise des selectors pour lire uniquement ce dont le composant a besoin.
\`\`\`tsx
import { useGameStore } from "@/managers/stores/useGameStore";
export function Example(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
return <p>State courant : {mainState}</p>;
}
\`\`\`
C'est mieux que de lire tout le store, car le composant se re-render uniquement quand \`mainState\` change.
## Mettre à jour le state
Préfère les actions explicites du store.
\`\`\`ts
const advanceGameState = useGameStore((state) => state.advanceGameState);
advanceGameState();
\`\`\`
Pour le développement et le debug, des setters directs existent aussi :
\`\`\`ts
const setMainState = useGameStore((state) => state.setMainState);
setMainState("bike");
\`\`\`
Les setters directs sont pratiques pour les panneaux debug, mais le gameplay de production devrait préférer les actions métier comme \`advanceGameState\`, \`completeBike\` ou \`completePylone\`.
Le gameplay de mission qui peut cibler \`bike\`, \`pylone\` ou \`ferme\` doit préférer les actions génériques de mission :
\`\`\`ts
const setMissionStep = useGameStore((state) => state.setMissionStep);
const completeMission = useGameStore((state) => state.completeMission);
setMissionStep("bike", "inspected");
completeMission("bike");
\`\`\`
Cela évite aux composants gameplay réutilisables, comme les flows de réparation, de dupliquer des branches spécifiques à chaque mission avec \`setBikeState\`, \`setPyloneState\` et \`setFermeState\`.
## Intégration avec le World
\`src/world/GameStageContent.tsx\` s'abonne à \`mainState\` et monte le contenu spécifique au state courant.
Pour les missions de réparation, il monte le composant réutilisable \`RepairGame\` avec un id de mission :
\`\`\`tsx
<RepairGame mission="bike" position={[8, 0, -6]} />
\`\`\`
\`RepairGame\` lit l'étape de mission active depuis le store et écrit les transitions via des actions génériques comme \`setMissionStep\` et \`completeMission\`. Les ids de mission, étapes de mission et guards partagés vivent dans \`src/types/gameplay/repairMission.ts\`, ce qui évite à la configuration statique des missions de dépendre du store Zustand. Le flow de réparation de production supporte actuellement les transitions \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`.
Le flow prototype intro et mission 2 est documenté séparément dans \`docs/technical/mission-flow.md\`. Il utilise volontairement la même source de vérité \`useGameStore\`, sans \`GameStepManager\` dédié ni second store Zustand.
La scène peut donc évoluer progressivement vers ce pattern :
\`\`\`tsx
switch (mainState) {
case "intro":
return <IntroContent />;
case "bike":
return <BikeContent />;
case "pylone":
return <PyloneContent />;
case "ferme":
return <FarmContent />;
case "outro":
return <OutroContent />;
}
\`\`\`
Dans React Three Fiber, monter ou démonter du JSX contrôle ce qui apparaît dans la scène Three.js. Quand un composant lié à un state disparaît du JSX, React le retire de la scène.
## Intégration UI
\`src/components/ui/GameUI.tsx\` regroupe les overlays HTML utilisés par la route jouable.
Overlays actuels :
- \`DebugOverlayLayout\` : layout compact des panels debug HTML visible avec \`?debug\`
- \`GameStateDebugPanel\` : panneau de progression debug pour consulter/changer le main state, le sub state, avancer/reculer et reset le store
- \`Crosshair\` : aide de visée joueur
- \`InteractPrompt\` : prompt d'interaction
- \`RepairMovementLockIndicator\` : indicateur joueur affiché quand les étapes repair désactivent temporairement le déplacement
- Les overlays du flow mission comme \`IntroUI\`, \`BienvenueDisplay\` et \`DialogMessage\` sont montés par \`src/pages/page.tsx\`, car ce sont des overlays HTML de route plutôt qu'un HUD de jeu persistant.
\`src/pages/page.tsx\` doit rester fin et monter le canvas, le \`GameUI\` persistant et les overlays de route.
## Règles anti-régression
- Ne pas stocker les valeurs mises à jour à chaque frame dans Zustand.
- Utiliser \`useRef\` pour les valeurs mutables fréquentes comme la vélocité joueur, les vecteurs temporaires ou les données de boucle d'animation.
- Utiliser des selectors au lieu de lire tout le store dans les composants.
- Garder les transitions gameplay dans les actions du store quand possible.
- Garder les contrôles debug derrière \`?debug\`.
- Ajouter du state uniquement quand une vraie fonctionnalité runtime en a besoin.
## Prochaines étapes
Déplacer la validation de réparation dans les données de mission lorsque chaque mission aura ses propres nodes de modules cassés, assets de remplacement et événements de complétion.
`;
export const missionFlowFr = `# Flow de mission export const missionFlowFr = `# Flow de mission
Ce document décrit le flow prototype d'intro et de mission 2 après son intégration dans l'architecture actuelle. Ce document décrit le flow prototype d'intro et de mission 2 après son intégration dans l'architecture actuelle.
@@ -470,7 +14,6 @@ Le store possède le slice \`missionFlow\` :
\`\`\`ts \`\`\`ts
missionFlow: { missionFlow: {
step: GameStep;
activityCity: boolean; activityCity: boolean;
playerName: string; playerName: string;
canMove: boolean; canMove: boolean;
@@ -487,14 +30,13 @@ Les managers restent responsables de services runtime locaux :
- \`AudioManager\` possède les éléments audio, les pools audio, la musique, le volume par catégorie et le pan stéréo. - \`AudioManager\` possède les éléments audio, les pools audio, la musique, le volume par catégorie et le pan stéréo.
- \`InteractionManager\` possède les handles d'interaction transitoires, focus, nearby et held. - \`InteractionManager\` possède les handles d'interaction transitoires, focus, nearby et held.
La progression de mission n'est pas possédée par un manager. Les composants mettent à jour le store via des actions explicites comme \`setFlowStep\`, \`setCanMove\`, \`showDialog\` et \`hideDialog\`. La progression de mission n'est pas possédée par un manager. Les composants mettent à jour le store via des actions explicites comme \`setCanMove\`, \`showDialog\` et \`hideDialog\`.
## Composants runtime ## Composants runtime
- \`src/components/game/GameFlow.tsx\` réagit à \`missionFlow.step\` et déclenche les effets ponctuels comme l'audio d'intro et le déblocage du mouvement. - \`src/components/game/GameFlow.tsx\` réagit au store et déclenche les effets ponctuels comme l'audio d'intro et le déblocage du mouvement.
- \`src/components/zone/ZoneDetection.tsx\` lit la position caméra et fait passer le flow à une étape cible quand le joueur entre dans une zone configurée. - \`src/components/zone/ZoneDetection.tsx\` lit la position caméra et fait passer le flow à une étape cible quand le joueur entre dans une zone configurée.
- \`src/components/three/interaction/CentralObject.tsx\` et \`VillageoisHelperObject.tsx\` exposent les objets interactifs temporaires de mission. - \`src/pages/page.tsx\` monte les overlays HTML de mission : \`IntroUI\`, \`DialogMessage\` et \`Subtitles\`.
- \`src/pages/page.tsx\` monte les overlays HTML de mission : \`IntroUI\`, \`BienvenueDisplay\` et \`DialogMessage\`.
- \`src/world/player/PlayerController.tsx\` lit \`missionFlow.canMove\` comme lock de déplacement supplémentaire. - \`src/world/player/PlayerController.tsx\` lit \`missionFlow.canMove\` comme lock de déplacement supplémentaire.
## Séquence d'étapes ## Séquence d'étapes
@@ -525,251 +67,3 @@ Chaque zone possède un id, une position, un rayon, une hauteur et un \`targetSt
- Garder les effets de bord comme l'audio dans les composants ou les managers de service, mais garder la transition d'état dans le store. - Garder les effets de bord comme l'audio dans les composants ou les managers de service, mais garder la transition d'état dans le store.
- Ne pas mettre les valeurs par-frame comme la position caméra ou les distances de zones dans Zustand. - Ne pas mettre les valeurs par-frame comme la position caméra ou les distances de zones dans Zustand.
`; `;
export const featuresFr = `# Fonctionnalités implémentées
Ce document liste les fonctionnalités présentes dans le code actuel.
## Scène
- Scène React Three Fiber plein écran
- Carte principale chargée depuis \`public/models/{name}/model.glb\`, avec fallback vers \`model.gltf\`
- Scène de test physique debug sélectionnable depuis le panneau debug, avec tests grab/trigger, preview de modèle animé et zones playground de réparation séparées pour \`bike\`, \`pylone\` et \`ferme\`
- Contexte physique Rapier disponible pour les objets gameplay de stage en production
- Éclairage ambiant et directionnel
- Configuration de l'environnement de fond
## Joueur
- Mode caméra joueur
- Orientation souris avec pointer lock
- Déplacement avec \`ZQSD\`
- Saut
- Verrouillage du déplacement pendant les étapes repair actives, avec indicateur à l'écran tout en gardant les interactions trigger disponibles
- Collision basée sur une octree contre la carte chargée
## Interactions
- Détection de focus par distance et raycast
- Interactions trigger activées avec \`E\`
- Interactions grab activées avec le bouton principal de la souris
- Les objets gameplay avec physique peuvent être montés dans le contenu de stage sans remplacer la collision octree du joueur
- Prompt d'interaction affiché pour les interactions trigger
## Gameplay de réparation
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
- Le playground physics debug monte le même \`RepairGame\` réutilisable dans des zones \`Bike\`, \`Pylone\` et \`Farm\`, afin de peaufiner chaque state avec un placement isolé avant déplacement vers la carte de production
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`, avec nodes cassés, placeholders cibles, timing de scan et timing de réassemblage propres à chaque mission
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, indicateur de verrouillage de déplacement pendant la réparation active, interaction trigger sur la mallette, traverse des placeholders de mallette, placement avec snap vers placeholder, feedback de dépôt des pièces cassées, touche \`E\`, hold deux poings, transition de modèle explosé, réassemblage inverse avec particules, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, feedback de validation de la bonne pièce et complétion de mission
## Audio
- Volumes par catégorie pour la musique, les SFX et les dialogues
- Lecture de musique en boucle via \`AudioManager\`
- Lecture de sons one-shot pour les SFX et les dialogues, avec pool simple par son
- Pan stéréo optionnel pour les sons one-shot
## Dialogues et sous-titres
- Manifeste de dialogues dans \`public/sounds/dialogue/dialogues.json\`
- Audios de dialogue chargés depuis \`public/sounds/dialogue/\`
- Un fichier SRT par voix et par langue
- Fallback vers les sous-titres français quand le fichier de langue sélectionné manque
- Overlay de sous-titres runtime avec couleurs par speaker
- Déclenchement timecodé pour les dialogues qui définissent \`timecode\`
- File d'attente pour éviter les dialogues superposés
## Cinématiques
- Manifeste de cinématiques dans \`public/cinematics.json\`
- Déclenchement timecodé des cinématiques
- Lecture de keyframes caméra via GSAP
- Dialogue cues optionnelles synchronisées avec les timelines de cinématique
- Blocage des inputs joueur pendant une cinématique
## Menu options
- \`Esc\` ouvre et ferme le menu options en jeu
- Sliders de volume musique, SFX et dialogue
- Toggle d'affichage des sous-titres
- Choix de langue des sous-titres entre français et anglais
- Choix du runtime de réparation entre JavaScript local et serveur Python
- Action quitter qui nettoie les cookies accessibles au navigateur et retourne vers \`/\`
## Outils debug
- Le paramètre \`?debug\` active le panneau debug
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène, \`R3F Perf\`, \`Debug Overlay\` et le tuning d'interaction
- Overlay debug compact pour les contrôles de game state et le statut hand tracking
- Le changement de mission dans le panneau game-state debug déverrouille les missions repair encore \`locked\` à \`waiting\` pour accélérer les tests
- Helpers de scène debug
- Caméra libre debug
- Overlay \`r3f-perf\`
## Éditeur de carte
- Route \`/editor\` pour inspecter et éditer \`public/map.json\`
- Chargement automatique de \`public/map.json\` quand il existe
- Rendu des modèles disponibles depuis \`public/models/{name}/model.glb\` ou \`model.gltf\`
- Cubes de fallback pour les nodes dont le modèle manque
- Sélection d'objet au clic
- Modes de transformation translation, rotation et scale
- Export JSON pour télécharger la carte modifiée
- Endpoint de sauvegarde dev-server pour écrire \`public/map.json\`
- Éditeur SRT pour les sous-titres de dialogue
- Preview audio et outils de timing pour les cues SRT
- Endpoint de sauvegarde dev-server pour les fichiers SRT
- Validation du manifeste de dialogues depuis l'UI de l'éditeur
- Éditeur de manifeste dialogues avec preview et création assistée de cue SRT FR
- Éditeur de manifeste cinématiques avec keyframes caméra, dialogue cues et preview canvas
## Pas encore implémenté
- système de missions complet
- système de zones
- branches de dialogues gameplay au-delà des déclencheurs prototype actuels
- flow de chargement
- minimap et HUD de mission
- séparation complète production / debug pour les scènes gameplay
`;
export const editorFr = `# Éditeur de carte
L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur.
## Ce qui est édité
L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json".
Chaque node décrit un objet de la scène :
- "name" : nom du dossier modèle dans "/public/models/{name}/model.glb", avec fallback vers "model.gltf"
- "type" : catégorie de l'objet
- "position" : "[x, y, z]"
- "rotation" : "[x, y, z]"
- "scale" : "[x, y, z]"
Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable.
## Workflow de base
1. Ouvrir "/editor".
2. Sélectionner un objet dans la vue 3D.
3. Choisir un mode de transformation : translation, rotation ou scale.
4. Déplacer la gizmo de transformation.
5. Utiliser undo ou redo si nécessaire.
6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev.
## Contrôles
| Action | Input |
| --- | --- |
| Sélectionner un objet | Clic sur l'objet |
| Désélectionner | "Esc" ou clic dans le vide |
| Mode translation | "T" |
| Mode rotation | "R" |
| Mode scale | "S" |
| Undo | "Ctrl+Z" |
| Redo | "Ctrl+Y" |
| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches |
| Monter / descendre | "Space", "Shift" |
## Actions fichier
### Export JSON
"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json".
### Save to server
"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite.
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
## Éditer les dialogues et sous-titres
Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres.
### Manifeste dialogues
Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main.
- \`Reload\` recharge le manifeste depuis le disque.
- \`Add\` crée un dialogue local pour la voix courante et assigne le prochain index SRT disponible.
- \`Save\` écrit le manifeste via le serveur Vite local.
- \`Preview dialogue\` joue le dialogue sélectionné avec les sous-titres dans l'éditeur.
- \`Create FR SRT cue\` crée la cue française si elle manque.
- \`Delete dialogue\` supprime localement l'entrée sélectionnée.
Après \`Add\`, il faut cliquer \`Save\` pour conserver le dialogue dans le manifeste. La cue SRT FR est écrite directement, mais le manifeste reste local tant qu'il n'est pas sauvegardé.
Les nouveaux dialogues utilisent un chemin audio placeholder comme \`/sounds/dialogue/new_dialogue_24.mp3\`. Remplace-le par un vrai MP3 avant validation finale.
### Éditeur SRT
1. Choisir une voix : \`narrateur\`, \`fermier\` ou \`electricienne\`.
2. Choisir une langue : \`FR\` ou \`EN\`.
3. Modifier le texte SRT directement dans la textarea.
4. Utiliser la preview audio pour vérifier le dialogue sélectionné.
5. Utiliser \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster le timing de la cue sélectionnée avec l'audio.
6. Utiliser \`Save SRT\` en développement local, ou \`Export SRT\` pour télécharger le fichier manuellement.
Chaque fichier SRT appartient à une voix, pas à un dialogue. Les indexes de cue doivent correspondre aux valeurs \`subtitleCueIndex\` référencées par le manifeste de dialogues.
## Valider les assets de dialogue
Utilise \`Validate\` dans le panneau SRT pour vérifier le manifeste et les assets liés.
La validation vérifie :
- \`public/sounds/dialogue/dialogues.json\`
- les fichiers audio de dialogue référencés
- les fichiers SRT français
- les indexes de cue référencés par le manifeste
Les fichiers SRT anglais manquants sont des warnings parce que le runtime retombe sur les sous-titres français.
## Éditer les cinématiques
Le panneau \`Cinematics\` permet d'éditer \`public/cinematics.json\`.
Chaque cinématique contient :
- un \`id\`
- un \`timecode\` global optionnel
- au moins deux keyframes caméra
- des dialogue cues optionnelles synchronisées avec la timeline
Les keyframes caméra définissent un temps relatif, une position caméra et une cible de regard. Les dialogue cues définissent un temps relatif et un \`dialogueId\` issu de \`dialogues.json\`.
Actions disponibles :
- \`Reload\` recharge le manifeste.
- \`Add\` crée une cinématique locale avec deux keyframes.
- \`Save\` écrit \`public/cinematics.json\` via le serveur Vite local.
- \`Preview cinematic\` joue l'animation caméra dans le canvas éditeur.
- \`Add keyframe\` et \`Remove\` modifient le chemin caméra.
- \`Add dialogue\` et \`Remove\` modifient les dialogues synchronisés.
- \`Delete cinematic\` supprime localement la cinématique sélectionnée.
Les dialogue cues sont la manière recommandée de synchroniser un dialogue avec une cinématique. Évite de donner aussi un \`timecode\` global au même dialogue dans \`dialogues.json\`, sinon il peut être lancé deux fois.
## Inspecteur JSON
Le panneau latéral affiche le JSON brut de la carte :
- sans sélection, il affiche toute la liste des nodes
- avec un objet sélectionné, il met en évidence les lignes du node sélectionné
Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde.
## Limites actuelles
- L'éditeur modifie uniquement les nodes existants.
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
- La sauvegarde production n'est pas implémentée.
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
- La sauvegarde SRT est un helper local du serveur Vite, pas une API backend de production.
- Les sauvegardes dialogues et cinématiques sont aussi des helpers locaux du serveur Vite.
`;
+17 -1
View File
@@ -1,5 +1,21 @@
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
export const EBIKE_REPAIR_POSITION = [ export const BIKE_REPAIR_POSITION = [
42.2399, 4.5484, 34.6468, 42.2399, 4.5484, 34.6468,
] as const satisfies Vector3Tuple; ] as const satisfies Vector3Tuple;
const REPAIR_MISSION_POSITIONS = {
bike: BIKE_REPAIR_POSITION,
pylone: [64, 0, -66],
ferme: [-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 {
mission: RepairMissionId;
position: Vector3Tuple;
}[];
+15 -47
View File
@@ -1,40 +1,8 @@
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { import type {
ModelTransformProps, RepairMissionCaseConfig,
Vector3Scale, RepairMissionConfig,
Vector3Tuple, RepairMissionId,
} from "@/types/three/three"; } from "@/types/gameplay/repairMission";
export interface RepairMissionCaseConfig {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Scale;
}
export interface RepairMissionPartConfig {
id: string;
label: string;
nodeName?: string;
placeholderName?: string;
modelPath?: string;
}
export interface RepairMissionConfig {
id: RepairMissionId;
label: string;
description: string;
modelPath: string;
modelScale?: ModelTransformProps["scale"];
stageUiPath: string;
interactUiPath: string;
brokenUiPath: string;
case: RepairMissionCaseConfig;
reassemblySeconds?: number;
requiredReplacementPartId: string;
scanPartSeconds?: number;
brokenParts: readonly RepairMissionPartConfig[];
replacementParts: readonly RepairMissionPartConfig[];
}
const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm"; const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm";
const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm"; const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm";
@@ -64,7 +32,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
label: "Cooling core", label: "Cooling core",
modelPath: "/models/refroidisseur/model.gltf", modelPath: "/models/refroidisseur/model.gltf",
nodeName: "refroidisseur", nodeName: "refroidisseur",
placeholderName: "placeholder_1", caseSlotName: "placeholder_1",
}, },
], ],
replacementParts: [ replacementParts: [
@@ -74,12 +42,12 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
modelPath: "/models/refroidisseur/model.gltf", modelPath: "/models/refroidisseur/model.gltf",
}, },
{ {
id: "bike-radio-decoy", id: "bike-radio-distractor",
label: "Radio module", label: "Radio module",
modelPath: "/models/talkie/model.gltf", modelPath: "/models/talkie/model.gltf",
}, },
{ {
id: "bike-glove-decoy", id: "bike-glove-distractor",
label: "Insulation glove", label: "Insulation glove",
modelPath: "/models/gant_l/model.gltf", modelPath: "/models/gant_l/model.gltf",
}, },
@@ -103,13 +71,13 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
id: "pylone-grid-relay", id: "pylone-grid-relay",
label: "Grid relay", label: "Grid relay",
nodeName: "lampe", nodeName: "lampe",
placeholderName: "placeholder_1", caseSlotName: "placeholder_1",
}, },
{ {
id: "pylone-damaged-panel", id: "pylone-damaged-panel",
label: "Damaged solar panel", label: "Damaged solar panel",
nodeName: "panneau2", nodeName: "panneau2",
placeholderName: "placeholder_2", caseSlotName: "placeholder_2",
}, },
], ],
replacementParts: [ replacementParts: [
@@ -119,12 +87,12 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
modelPath: "/models/pylone/model.gltf", modelPath: "/models/pylone/model.gltf",
}, },
{ {
id: "pylone-stone-decoy", id: "pylone-stone-distractor",
label: "Stone counterweight", label: "Stone counterweight",
modelPath: "/models/galet/model.gltf", modelPath: "/models/galet/model.gltf",
}, },
{ {
id: "pylone-cooling-decoy", id: "pylone-cooling-distractor",
label: "Cooling core", label: "Cooling core",
modelPath: "/models/refroidisseur/model.gltf", modelPath: "/models/refroidisseur/model.gltf",
}, },
@@ -147,12 +115,12 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
{ {
id: "ferme-irrigation-pump", id: "ferme-irrigation-pump",
label: "Irrigation pump", label: "Irrigation pump",
placeholderName: "placeholder_1", caseSlotName: "placeholder_1",
}, },
{ {
id: "ferme-humidity-sensor", id: "ferme-humidity-sensor",
label: "Humidity sensor", label: "Humidity sensor",
placeholderName: "placeholder_2", caseSlotName: "placeholder_2",
}, },
], ],
replacementParts: [ replacementParts: [
@@ -162,12 +130,12 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
modelPath: "/models/fermeverticale/model.gltf", modelPath: "/models/fermeverticale/model.gltf",
}, },
{ {
id: "ferme-tree-decoy", id: "ferme-tree-distractor",
label: "Tree sensor", label: "Tree sensor",
modelPath: "/models/sapin/model.gltf", modelPath: "/models/sapin/model.gltf",
}, },
{ {
id: "ferme-radio-decoy", id: "ferme-radio-distractor",
label: "Radio module", label: "Radio module",
modelPath: "/models/talkie/model.gltf", modelPath: "/models/talkie/model.gltf",
}, },
+7
View File
@@ -0,0 +1,7 @@
export const CHUNK_CONFIG = {
enabled: true,
chunkSize: 35,
loadRadius: 50,
unloadRadius: 65,
updateInterval: 250,
};
-12
View File
@@ -1,5 +1,3 @@
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
export type FogMode = "linear" | "exp2"; export type FogMode = "linear" | "exp2";
export const FOG_CONFIG = { export const FOG_CONFIG = {
@@ -28,13 +26,3 @@ export interface FogState {
mode: FogMode; mode: FogMode;
near: number; near: number;
} }
export const CHUNK_CONFIG = {
enabled: true,
chunkSize: 35,
loadRadius: 50,
unloadRadius: 65,
updateInterval: 250,
};
export const GROUND_PLANE_COLOR = TERRAIN_COLORS.grass1.hex;
-4
View File
@@ -6,8 +6,4 @@ export const GRAPHICS_DEFAULTS = {
grassDensity: 1.0, grassDensity: 1.0,
}; };
export const GRAPHICS_BOUNDS = {
grassDensity: { min: 0.1, max: 2.0, step: 0.1 },
};
export type GraphicsState = typeof GRAPHICS_DEFAULTS; export type GraphicsState = typeof GRAPHICS_DEFAULTS;
+2 -2
View File
@@ -1,5 +1,5 @@
export const AMBIENT_LIGHT_COLOR = "#dfe7d8"; const AMBIENT_LIGHT_COLOR = "#dfe7d8";
export const SUN_LIGHT_COLOR = "#ffe2bf"; const SUN_LIGHT_COLOR = "#ffe2bf";
export const LIGHTING_DEFAULTS = { export const LIGHTING_DEFAULTS = {
ambientColor: AMBIENT_LIGHT_COLOR, ambientColor: AMBIENT_LIGHT_COLOR,
+101
View File
@@ -0,0 +1,101 @@
export const MAP_INSTANCING_ASSETS = {
boiteauxlettres: {
mapName: "boiteauxlettres",
modelPath: "/models/boiteauxlettres/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
pylone: {
mapName: "pylone",
modelPath: "/models/pylone/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
immeuble1: {
mapName: "immeuble1",
modelPath: "/models/immeuble1/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
maison1: {
mapName: "maison1",
modelPath: "/models/maison1/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
eolienne: {
mapName: "eolienne",
modelPath: "/models/eolienne/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
parcebike: {
mapName: "parcebike",
modelPath: "/models/parcebike/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneauaffichage: {
mapName: "panneauaffichage",
modelPath: "/models/panneauaffichage/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneauclassique: {
mapName: "panneauclassique",
modelPath: "/models/panneauclassique/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneaufleche: {
mapName: "panneaufleche",
modelPath: "/models/panneaufleche/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneausolaire: {
mapName: "panneausolaire",
modelPath: "/models/panneausolaire/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
} as const;
export const MAP_INSTANCING_ASSET_TYPES = [
"boiteauxlettres",
"pylone",
"immeuble1",
"maison1",
"eolienne",
"parcebike",
"panneauaffichage",
"panneauclassique",
"panneaufleche",
"panneausolaire",
] as const satisfies readonly (keyof typeof MAP_INSTANCING_ASSETS)[];
export type MapInstancingAssetType =
(typeof MAP_INSTANCING_ASSET_TYPES)[number];
export type MapInstancingAssetConfig =
(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);
}
+2 -11
View File
@@ -1,16 +1,9 @@
import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface"; import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf"; export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
export const TERRAIN_SURFACE_COLOR_TOLERANCE = 15;
export const TERRAIN_SURFACE_PROJECTION = {
flipX: false,
flipZ: true,
offsetX: 0,
offsetZ: 0,
};
export const TERRAIN_WATER_HEIGHT = 0.8; export const TERRAIN_WATER_HEIGHT = 0.8;
export const TERRAIN_TILE_SIZE = 1;
export const GRASS_BASE_COLOR = "#1a3a1a"; const TERRAIN_TILE_SIZE = 1;
export const TERRAIN_COLORS = { export const TERRAIN_COLORS = {
grass1: { grass1: {
@@ -61,5 +54,3 @@ export const TERRAIN_COLORS = {
kind: "rock", kind: "rock",
}, },
} satisfies Record<string, TerrainSurfaceColorConfig>; } satisfies Record<string, TerrainSurfaceColorConfig>;
export type TerrainColorKey = keyof typeof TERRAIN_COLORS;
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createSceneDataFromFiles } from "@/utils/editor/loadEditorScene"; import { createSceneDataFromFiles } from "@/utils/editor/loadEditorScene";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import type { SceneData } from "@/types/editor/editor"; import type { SceneData } from "@/types/map/mapScene";
interface UseEditorSceneDataResult { interface UseEditorSceneDataResult {
hasMapJson: boolean; hasMapJson: boolean;
@@ -2,8 +2,6 @@ import { useGameStore } from "@/managers/stores/useGameStore";
import type { MissionStep } from "@/types/gameplay/repairMission"; import type { MissionStep } from "@/types/gameplay/repairMission";
export function useRepairMovementLocked(): boolean { export function useRepairMovementLocked(): boolean {
return false;
return useGameStore((state) => { return useGameStore((state) => {
switch (state.mainState) { switch (state.mainState) {
case "bike": case "bike":
-4
View File
@@ -4,7 +4,3 @@ import type { CloudState } from "@/data/world/cloudConfig";
export function useCloudSettings(): CloudState { export function useCloudSettings(): CloudState {
return useWorldSettingsStore((state) => state.clouds); return useWorldSettingsStore((state) => state.clouds);
} }
export function useSetCloudSettings(): (clouds: Partial<CloudState>) => void {
return useWorldSettingsStore((state) => state.setClouds);
}
-4
View File
@@ -4,7 +4,3 @@ import type { FogState } from "@/data/world/fogConfig";
export function useFogSettings(): FogState { export function useFogSettings(): FogState {
return useWorldSettingsStore((state) => state.fog); return useWorldSettingsStore((state) => state.fog);
} }
export function useSetFogSettings(): (fog: Partial<FogState>) => void {
return useWorldSettingsStore((state) => state.setFog);
}
-45
View File
@@ -1,58 +1,13 @@
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore"; import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import type { GraphicsState } from "@/data/world/graphicsConfig";
export function useGraphicsSettings(): GraphicsState {
return useWorldSettingsStore((state) => state.graphics);
}
export function useSetGraphicsSettings(): (
graphics: Partial<GraphicsState>,
) => void {
return useWorldSettingsStore((state) => state.setGraphics);
}
export function useDynamicGrass(): boolean { export function useDynamicGrass(): boolean {
return useWorldSettingsStore((state) => state.graphics.dynamicGrass); return useWorldSettingsStore((state) => state.graphics.dynamicGrass);
} }
export function useDynamicTrees(): boolean {
return useWorldSettingsStore((state) => state.graphics.dynamicTrees);
}
export function useDynamicClouds(): boolean { export function useDynamicClouds(): boolean {
return useWorldSettingsStore((state) => state.graphics.dynamicClouds); return useWorldSettingsStore((state) => state.graphics.dynamicClouds);
} }
export function useShadowsEnabled(): boolean {
return useWorldSettingsStore((state) => state.graphics.shadowsEnabled);
}
export function useGrassDensity(): number { export function useGrassDensity(): number {
return useWorldSettingsStore((state) => state.graphics.grassDensity); return useWorldSettingsStore((state) => state.graphics.grassDensity);
} }
export function useGraphicsSetters() {
const setDynamicGrass = useWorldSettingsStore(
(state) => state.setDynamicGrass,
);
const setDynamicTrees = useWorldSettingsStore(
(state) => state.setDynamicTrees,
);
const setDynamicClouds = useWorldSettingsStore(
(state) => state.setDynamicClouds,
);
const setShadowsEnabled = useWorldSettingsStore(
(state) => state.setShadowsEnabled,
);
const setGrassDensity = useWorldSettingsStore(
(state) => state.setGrassDensity,
);
return {
setDynamicGrass,
setDynamicTrees,
setDynamicClouds,
setShadowsEnabled,
setGrassDensity,
};
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import {
MAP_INSTANCING_ASSETS,
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 { 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 {
const data: MapInstancingData = new Map();
for (const type of MAP_INSTANCING_ASSET_TYPES) {
const config = MAP_INSTANCING_ASSETS[type];
if (!config.enabled) continue;
const instances = mapNodes
.filter(
(node) => node.name === config.mapName && node.type === "Object3D",
)
.map(mapNodeToInstanceTransform);
if (instances.length > 0) {
data.set(type, instances);
}
}
return data;
}
export function useMapInstancingData(): {
data: MapInstancingData | null;
isLoading: boolean;
} {
const [data, setData] = useState<MapInstancingData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function load() {
const cachedNodes = getMapNodes();
if (cachedNodes) {
if (!cancelled) {
setData(extractMapInstancingData(cachedNodes));
setIsLoading(false);
}
return;
}
try {
await loadMapSceneData();
} catch (error) {
logger.error("MapInstancing", "Failed to load map instancing data", {
error: error instanceof Error ? error : String(error),
});
}
const nodes = getMapNodes();
if (!cancelled) {
setData(nodes ? extractMapInstancingData(nodes) : new Map());
setIsLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, []);
return { data, isLoading };
}
+87
View File
@@ -0,0 +1,87 @@
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 { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
export type VegetationInstance = MapNodeInstanceTransform;
interface InstancedMapEntry {
modelPath: string;
instances: VegetationInstance[];
}
export type VegetationData = Map<string, InstancedMapEntry>;
function extractVegetationData(
mapNodes: MapNode[],
models: Map<string, string>,
): VegetationData {
const data: VegetationData = new Map();
for (const node of mapNodes) {
if (node.type !== "Object3D") continue;
if (INSTANCED_MAP_EXCEPTIONS.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)],
});
}
}
return data;
}
export function useVegetationData(): {
data: VegetationData | null;
isLoading: boolean;
} {
const [data, setData] = useState<VegetationData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function load() {
let sceneData: Awaited<ReturnType<typeof loadMapSceneData>> | null = null;
try {
sceneData = await loadMapSceneData();
} catch (error) {
logger.error("Vegetation", "Failed to load vegetation data", {
error: error instanceof Error ? error : String(error),
});
}
if (!cancelled) {
setData(
sceneData
? extractVegetationData(sceneData.mapNodes, sceneData.models)
: new Map(),
);
setIsLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, []);
return { data, isLoading };
}
+74
View File
@@ -0,0 +1,74 @@
import { useCallback, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
export interface WorldChunkLike {
centerX: number;
centerZ: number;
key: string;
}
function areSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
return a.size === b.size && [...a].every((key) => b.has(key));
}
export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
chunks: readonly TChunk[],
streamingEnabled: boolean,
): readonly TChunk[] {
const camera = useThree((state) => state.camera);
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
() => new Set(),
);
const updateActiveChunks = useCallback(() => {
const nextKeys = new Set<string>();
const cameraX = camera.position.x;
const cameraZ = camera.position.z;
for (const chunk of chunks) {
const distance = Math.hypot(
chunk.centerX - cameraX,
chunk.centerZ - cameraZ,
);
const wasActive = activeChunkKeys.has(chunk.key);
const radius = wasActive
? CHUNK_CONFIG.unloadRadius
: CHUNK_CONFIG.loadRadius;
if (distance <= radius) {
nextKeys.add(chunk.key);
}
}
if (areSetsEqual(nextKeys, activeChunkKeys)) return;
setActiveChunkKeys(nextKeys);
}, [activeChunkKeys, camera, chunks]);
useFrame(({ clock }) => {
if (!streamingEnabled) return;
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
updateActiveChunks();
});
if (!streamingEnabled) return chunks;
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
);
});
}
-16
View File
@@ -4,19 +4,3 @@ import type { WindState } from "@/data/world/windConfig";
export function useWind(): WindState { export function useWind(): WindState {
return useWorldSettingsStore((state) => state.wind); return useWorldSettingsStore((state) => state.wind);
} }
export function useSetWind(): (wind: Partial<WindState>) => void {
return useWorldSettingsStore((state) => state.setWind);
}
export function useWindSpeed(): number {
return useWorldSettingsStore((state) => state.wind.speed);
}
export function useWindDirection(): number {
return useWorldSettingsStore((state) => state.wind.direction);
}
export function useWindStrength(): number {
return useWorldSettingsStore((state) => state.wind.strength);
}
+6 -18
View File
@@ -1,5 +1,10 @@
import { create } from "zustand"; import { create } from "zustand";
import { GAME_STEPS, type GameStep } from "@/types/game"; import {
isGameStep,
isMainGameState,
type GameStep,
type MainGameState,
} from "@/types/game";
import { import {
isRepairMissionId, isRepairMissionId,
isMissionStep, isMissionStep,
@@ -15,7 +20,6 @@ import {
} from "@/utils/debug/debugGameStateCookie"; } from "@/utils/debug/debugGameStateCookie";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled"; import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
export type { MissionStep, RepairMissionId }; export type { MissionStep, RepairMissionId };
interface IntroState { interface IntroState {
@@ -86,14 +90,6 @@ interface GameActions {
type GameStore = GameState & GameActions; type GameStore = GameState & GameActions;
type GameStateUpdate = Partial<GameState>; type GameStateUpdate = Partial<GameState>;
const MAIN_GAME_STATES: readonly MainGameState[] = [
"intro",
"bike",
"pylone",
"ferme",
"outro",
];
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
@@ -106,14 +102,6 @@ function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean"; return typeof value === "boolean";
} }
function isMainGameState(value: unknown): value is MainGameState {
return MAIN_GAME_STATES.includes(value as MainGameState);
}
function isGameStep(value: unknown): value is GameStep {
return GAME_STEPS.includes(value as GameStep);
}
function completeIntroState(state: GameState): GameStateUpdate { function completeIntroState(state: GameState): GameStateUpdate {
return { return {
mainState: "bike", mainState: "bike",
+14 -2
View File
@@ -26,6 +26,10 @@ export type MapPerformanceModelName =
| "pylone" | "pylone"
| "boiteauxlettres" | "boiteauxlettres"
| "maison1" | "maison1"
| "panneauaffichage"
| "panneauclassique"
| "panneaufleche"
| "panneausolaire"
| "parcebike" | "parcebike"
| "terrain" | "terrain"
| "sky"; | "sky";
@@ -70,6 +74,10 @@ export const MAP_PERFORMANCE_MODEL_NAMES: readonly MapPerformanceModelName[] = [
"pylone", "pylone",
"boiteauxlettres", "boiteauxlettres",
"maison1", "maison1",
"panneauaffichage",
"panneauclassique",
"panneaufleche",
"panneausolaire",
"parcebike", "parcebike",
"terrain", "terrain",
"sky", "sky",
@@ -94,6 +102,10 @@ const MODEL_GROUPS: Record<
pylone: ["props"], pylone: ["props"],
boiteauxlettres: ["props"], boiteauxlettres: ["props"],
maison1: ["buildings"], maison1: ["buildings"],
panneauaffichage: ["props"],
panneauclassique: ["props"],
panneaufleche: ["props"],
panneausolaire: ["props"],
parcebike: ["props"], parcebike: ["props"],
terrain: ["terrain"], terrain: ["terrain"],
sky: ["sky"], sky: ["sky"],
@@ -115,10 +127,10 @@ function createDefaultVisibility(): MapPerformanceVisibility {
}; };
} }
export function isMapPerformanceModelName( function isMapPerformanceModelName(
name: string, name: string,
): name is MapPerformanceModelName { ): name is MapPerformanceModelName {
return MAP_PERFORMANCE_MODEL_NAMES.includes(name as MapPerformanceModelName); return (MAP_PERFORMANCE_MODEL_NAMES as readonly string[]).includes(name);
} }
export function isMapModelVisible( export function isMapModelVisible(
+1 -7
View File
@@ -1,9 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
import type { AudioCategory } from "@/managers/AudioManager"; import type { AudioCategory } from "@/managers/AudioManager";
import type { SubtitleLanguage } from "@/types/settings/settings";
export type SubtitleLanguage = "fr" | "en";
export type RepairRuntime = "js" | "python";
interface SettingsState { interface SettingsState {
isSettingsMenuOpen: boolean; isSettingsMenuOpen: boolean;
@@ -12,7 +10,6 @@ interface SettingsState {
dialogueVolume: number; dialogueVolume: number;
subtitlesEnabled: boolean; subtitlesEnabled: boolean;
subtitleLanguage: SubtitleLanguage; subtitleLanguage: SubtitleLanguage;
repairRuntime: RepairRuntime;
} }
interface SettingsActions { interface SettingsActions {
@@ -22,7 +19,6 @@ interface SettingsActions {
setDialogueVolume: (volume: number) => void; setDialogueVolume: (volume: number) => void;
setSubtitlesEnabled: (enabled: boolean) => void; setSubtitlesEnabled: (enabled: boolean) => void;
setSubtitleLanguage: (language: SubtitleLanguage) => void; setSubtitleLanguage: (language: SubtitleLanguage) => void;
setRepairRuntime: (runtime: RepairRuntime) => void;
resetSettings: () => void; resetSettings: () => void;
} }
@@ -35,7 +31,6 @@ const DEFAULT_SETTINGS: SettingsState = {
dialogueVolume: 1, dialogueVolume: 1,
subtitlesEnabled: true, subtitlesEnabled: true,
subtitleLanguage: "fr", subtitleLanguage: "fr",
repairRuntime: "js",
}; };
function clampVolume(volume: number): number { function clampVolume(volume: number): number {
@@ -79,7 +74,6 @@ export const useSettingsStore = create<SettingsStore>()((set) => ({
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }), set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }), setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }), setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
setRepairRuntime: (repairRuntime) => set({ repairRuntime }),
resetSettings: () => { resetSettings: () => {
applyDefaultAudioSettings(); applyDefaultAudioSettings();
set(DEFAULT_SETTINGS); set(DEFAULT_SETTINGS);
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore"; import type { SubtitleLanguage } from "@/types/settings/settings";
export type DialogueVoiceId = "narrateur" | "fermier" | "electricienne"; export type DialogueVoiceId = "narrateur" | "fermier" | "electricienne";
export type DialogueSpeaker = "Narrateur" | "Fermier" | "Electricienne"; export type DialogueSpeaker = "Narrateur" | "Fermier" | "Electricienne";
+5 -21
View File
@@ -1,23 +1,7 @@
import type { Vector3Tuple } from "../three/three"; export type {
HierarchicalMapNode,
export interface MapNode { MapNode,
name: string; SceneData,
type: string; } from "@/types/map/mapScene";
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
sourcePath?: number[];
}
export interface HierarchicalMapNode extends MapNode {
role?: "group";
children?: HierarchicalMapNode[];
}
export interface SceneData {
mapNodes: MapNode[];
models: Map<string, string>;
mapTree?: HierarchicalMapNode | HierarchicalMapNode[];
}
export type TransformMode = "translate" | "rotate" | "scale"; export type TransformMode = "translate" | "rotate" | "scale";
+22
View File
@@ -25,6 +25,28 @@ export const GAME_STEPS: readonly GameStep[] = [
"outOfFabrik", "outOfFabrik",
] as const; ] as const;
const GAME_STEP_VALUES: ReadonlySet<string> = new Set(GAME_STEPS);
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
export const MAIN_GAME_STATES: readonly MainGameState[] = [
"intro",
"bike",
"pylone",
"ferme",
"outro",
] as const;
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);
}
export interface Zone { export interface Zone {
id: string; id: string;
position: Vector3Tuple; position: Vector3Tuple;
+51 -3
View File
@@ -1,5 +1,49 @@
import type {
ModelTransformProps,
Vector3Scale,
Vector3Tuple,
} from "@/types/three/three";
export type RepairMissionId = "bike" | "pylone" | "ferme"; export type RepairMissionId = "bike" | "pylone" | "ferme";
export interface RepairMissionCaseConfig {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Scale;
}
export interface RepairMissionPartConfig {
id: string;
label: string;
nodeName?: string;
caseSlotName?: string;
modelPath?: string;
}
export interface RepairScannedBrokenPart {
id: string;
label: string;
modelPath: string;
caseSlotName?: string;
}
export interface RepairMissionConfig {
id: RepairMissionId;
label: string;
description: string;
modelPath: string;
modelScale?: ModelTransformProps["scale"];
stageUiPath: string;
interactUiPath: string;
brokenUiPath: string;
case: RepairMissionCaseConfig;
reassemblySeconds?: number;
requiredReplacementPartId: string;
scanPartSeconds?: number;
brokenParts: readonly RepairMissionPartConfig[];
replacementParts: readonly RepairMissionPartConfig[];
}
export type MissionStep = export type MissionStep =
| "locked" | "locked"
| "waiting" | "waiting"
@@ -10,7 +54,10 @@ export type MissionStep =
| "reassembling" | "reassembling"
| "done"; | "done";
export const REPAIR_MISSION_IDS = ["bike", "pylone", "ferme"] as const; const REPAIR_MISSION_IDS = ["bike", "pylone", "ferme"] as const;
const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
REPAIR_MISSION_IDS,
);
export const MISSION_STEPS = [ export const MISSION_STEPS = [
"locked", "locked",
@@ -22,13 +69,14 @@ export const MISSION_STEPS = [
"reassembling", "reassembling",
"done", "done",
] as const satisfies readonly MissionStep[]; ] as const satisfies readonly MissionStep[];
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
export function isRepairMissionId(value: string): value is RepairMissionId { export function isRepairMissionId(value: string): value is RepairMissionId {
return (REPAIR_MISSION_IDS as readonly string[]).includes(value); return REPAIR_MISSION_ID_VALUES.has(value);
} }
export function isMissionStep(value: string): value is MissionStep { export function isMissionStep(value: string): value is MissionStep {
return (MISSION_STEPS as readonly string[]).includes(value); return MISSION_STEP_VALUES.has(value);
} }
export function getNextMissionStep(step: MissionStep): MissionStep { export function getNextMissionStep(step: MissionStep): MissionStep {
+21
View File
@@ -0,0 +1,21 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface MapNode {
name: string;
type: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
sourcePath?: number[];
}
export interface HierarchicalMapNode extends MapNode {
role?: "group";
children?: HierarchicalMapNode[];
}
export interface SceneData {
mapNodes: MapNode[];
models: Map<string, string>;
mapTree?: HierarchicalMapNode | HierarchicalMapNode[];
}
+1
View File
@@ -0,0 +1 @@
export type SubtitleLanguage = "fr" | "en";
+1 -1
View File
@@ -1,4 +1,4 @@
export type SceneLoadingStatus = "loading" | "ready"; type SceneLoadingStatus = "loading" | "ready";
export interface SceneLoadingState { export interface SceneLoadingState {
currentStep: string; currentStep: string;
+2 -28
View File
@@ -1,6 +1,4 @@
import type * as THREE from "three"; type TerrainSurfaceKind =
export type TerrainSurfaceKind =
| "grass" | "grass"
| "path" | "path"
| "water" | "water"
@@ -8,12 +6,7 @@ export type TerrainSurfaceKind =
| "dirt" | "dirt"
| "rock"; | "rock";
export type TerrainSurfaceRgb = readonly [number, number, number]; type TerrainSurfaceRgb = readonly [number, number, number];
export interface TerrainSurfaceUv {
u: number;
v: number;
}
export interface TerrainSurfaceBounds { export interface TerrainSurfaceBounds {
minX: number; minX: number;
@@ -22,13 +15,6 @@ export interface TerrainSurfaceBounds {
maxZ: number; maxZ: number;
} }
export interface TerrainSurfaceProjectionConfig {
flipX: boolean;
flipZ: boolean;
offsetX: number;
offsetZ: number;
}
export interface TerrainSurfaceColorConfig { export interface TerrainSurfaceColorConfig {
hex: string; hex: string;
rgb: TerrainSurfaceRgb; rgb: TerrainSurfaceRgb;
@@ -37,15 +23,3 @@ export interface TerrainSurfaceColorConfig {
modelPath?: string; modelPath?: string;
tileSize?: number; tileSize?: number;
} }
export interface TerrainSurfaceSample {
rgb: TerrainSurfaceRgb;
key: string | null;
config: TerrainSurfaceColorConfig | null;
}
export interface TerrainSurfaceData {
bounds: TerrainSurfaceBounds;
imageData: ImageData;
raycastTarget: THREE.Object3D;
}
+1 -3
View File
@@ -1,5 +1,3 @@
import type { GameState } from "@/managers/stores/useGameStore";
const DEBUG_GAME_STATE_COOKIE_NAME = "la-fabrik-debug-game-state"; const DEBUG_GAME_STATE_COOKIE_NAME = "la-fabrik-debug-game-state";
const DEBUG_GAME_STATE_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; const DEBUG_GAME_STATE_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
@@ -25,7 +23,7 @@ export function readDebugGameStateCookie(): unknown {
} }
} }
export function writeDebugGameStateCookie(state: GameState): void { export function writeDebugGameStateCookie(state: unknown): void {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const value = encodeURIComponent(JSON.stringify(state)); const value = encodeURIComponent(JSON.stringify(state));
+3 -25
View File
@@ -3,7 +3,7 @@ import type {
DialogueManifest, DialogueManifest,
DialogueVoice, DialogueVoice,
} from "@/types/dialogues/dialogues"; } from "@/types/dialogues/dialogues";
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore"; import type { SubtitleLanguage } from "@/types/settings/settings";
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation"; import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
import { parseSrt } from "@/utils/subtitles/parseSrt"; import { parseSrt } from "@/utils/subtitles/parseSrt";
import type { SubtitleCue } from "@/utils/subtitles/parseSrt"; import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
@@ -27,18 +27,7 @@ export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
return parseDialogueManifest(await response.json()); return parseDialogueManifest(await response.json());
} }
export function resolveDialogueSubtitlePath( function getDialogueVoice(
manifest: DialogueManifest,
dialogue: DialogueDefinition,
language: SubtitleLanguage,
): string | null {
const voice = getDialogueVoice(manifest, dialogue.voice);
if (!voice) return null;
return getVoiceSubtitlePath(voice, language);
}
export function getDialogueVoice(
manifest: DialogueManifest, manifest: DialogueManifest,
voiceId: DialogueDefinition["voice"], voiceId: DialogueDefinition["voice"],
): DialogueVoice | null { ): DialogueVoice | null {
@@ -69,7 +58,7 @@ export async function loadDialogueSubtitleCue(
}; };
} }
export async function loadVoiceSubtitleCues( async function loadVoiceSubtitleCues(
voice: DialogueVoice, voice: DialogueVoice,
language: SubtitleLanguage, language: SubtitleLanguage,
): Promise<{ path: string; cues: SubtitleCue[] } | null> { ): Promise<{ path: string; cues: SubtitleCue[] } | null> {
@@ -103,14 +92,3 @@ function getVoiceSubtitlePaths(
.filter((path): path is string => Boolean(path)) .filter((path): path is string => Boolean(path))
.filter((path, index, paths) => paths.indexOf(path) === index); .filter((path, index, paths) => paths.indexOf(path) === index);
} }
function getVoiceSubtitlePath(
voice: DialogueVoice,
language: SubtitleLanguage,
): string | null {
return (
voice.subtitles[language] ??
voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE] ??
null
);
}
+7 -17
View File
@@ -2,10 +2,8 @@ import { AudioManager } from "@/managers/AudioManager";
import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { DialogueManifest } from "@/types/dialogues/dialogues"; import type { DialogueManifest } from "@/types/dialogues/dialogues";
import { import { logger } from "@/utils/core/Logger";
loadDialogueManifest, import { loadDialogueSubtitleCue } from "@/utils/dialogues/loadDialogueManifest";
loadDialogueSubtitleCue,
} from "@/utils/dialogues/loadDialogueManifest";
interface QueuedDialogueRequest { interface QueuedDialogueRequest {
manifest: DialogueManifest; manifest: DialogueManifest;
@@ -15,8 +13,6 @@ interface QueuedDialogueRequest {
const DIALOGUE_PLAY_START_TIMEOUT_MS = 800; const DIALOGUE_PLAY_START_TIMEOUT_MS = 800;
const dialogueQueue: QueuedDialogueRequest[] = []; const dialogueQueue: QueuedDialogueRequest[] = [];
let gameplayDialogueManifestPromise: Promise<DialogueManifest | null> | null =
null;
let isDialogueQueuePlaying = false; let isDialogueQueuePlaying = false;
export function queueDialogueById( export function queueDialogueById(
@@ -35,16 +31,6 @@ export function clearQueuedDialogues(): void {
} }
} }
export async function playGameplayDialogueById(
dialogueId: string,
): Promise<HTMLAudioElement | null> {
gameplayDialogueManifestPromise ??= loadDialogueManifest();
const manifest = await gameplayDialogueManifestPromise;
if (!manifest) return null;
return queueDialogueById(manifest, dialogueId);
}
export async function playDialogueById( export async function playDialogueById(
manifest: DialogueManifest, manifest: DialogueManifest,
dialogueId: string, dialogueId: string,
@@ -117,7 +103,11 @@ async function playNextQueuedDialogue(): Promise<void> {
); );
request.resolve(audio); request.resolve(audio);
if (audio) await waitForDialogueToFinish(audio); if (audio) await waitForDialogueToFinish(audio);
} catch { } catch (error) {
logger.error("Dialogues", "Failed to play queued dialogue", {
dialogueId: request.dialogueId,
error: error instanceof Error ? error : String(error),
});
request.resolve(null); request.resolve(null);
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SceneData } from "@/types/editor/editor"; import type { SceneData } from "@/types/map/mapScene";
import { createSceneDataFromMapPayload } from "@/utils/map/loadMapSceneData"; import { createSceneDataFromMapPayload } from "@/utils/map/loadMapSceneData";
const MAP_JSON_PATH = "/map.json"; const MAP_JSON_PATH = "/map.json";
+1 -1
View File
@@ -2,7 +2,7 @@ import type {
HierarchicalMapNode, HierarchicalMapNode,
MapNode, MapNode,
SceneData, SceneData,
} from "@/types/editor/editor"; } from "@/types/map/mapScene";
import { parseMapData } from "@/utils/map/mapNodeValidation"; import { parseMapData } from "@/utils/map/mapNodeValidation";
const MAP_JSON_PATH = "/map.json"; const MAP_JSON_PATH = "/map.json";
+18
View File
@@ -0,0 +1,18 @@
import type { MapNode } from "@/types/map/mapScene";
import type { Vector3Tuple } from "@/types/three/three";
export interface MapNodeInstanceTransform {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
export function mapNodeToInstanceTransform(
node: MapNode,
): MapNodeInstanceTransform {
return {
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
}
+2 -18
View File
@@ -1,4 +1,4 @@
import type { HierarchicalMapNode, MapNode } from "../../types/editor/editor"; import type { HierarchicalMapNode, MapNode } from "@/types/map/mapScene";
export interface ParsedMapNodes { export interface ParsedMapNodes {
mapNodes: MapNode[]; mapNodes: MapNode[];
@@ -31,9 +31,7 @@ function isMapNode(value: unknown): value is MapNode {
); );
} }
export function isHierarchicalMapNode( function isHierarchicalMapNode(value: unknown): value is HierarchicalMapNode {
value: unknown,
): value is HierarchicalMapNode {
if (!isMapNode(value)) { if (!isMapNode(value)) {
return false; return false;
} }
@@ -74,20 +72,6 @@ function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] {
return [mapNode, ...childNodes]; return [mapNode, ...childNodes];
} }
export function parseHierarchicalMapPayload(
value: unknown,
): HierarchicalMapNode | HierarchicalMapNode[] {
if (Array.isArray(value) && value.every(isHierarchicalMapNode)) {
return value;
}
if (isHierarchicalMapNode(value)) {
return value;
}
throw new Error("Invalid map node data");
}
export function parseMapNodes(value: unknown): MapNode[] { export function parseMapNodes(value: unknown): MapNode[] {
return parseMapData(value).mapNodes; return parseMapData(value).mapNodes;
} }
+3 -3
View File
@@ -1,5 +1,5 @@
import type { MapNode } from "@/types/editor/editor"; import type { MapNode } from "@/types/map/mapScene";
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig"; import { isInstancedMapNodeName } from "@/data/world/mapInstancingConfig";
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]); const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]);
const RUNTIME_VEGETATION_NODE_NAMES = new Set([ const RUNTIME_VEGETATION_NODE_NAMES = new Set([
@@ -11,7 +11,7 @@ const RUNTIME_VEGETATION_NODE_NAMES = new Set([
"sapin", "sapin",
]); ]);
export function isRuntimeStructureMapNode(name: string): boolean { function isRuntimeStructureMapNode(name: string): boolean {
return MAP_STRUCTURE_NODE_NAMES.has(name); return MAP_STRUCTURE_NODE_NAMES.has(name);
} }
+49 -12
View File
@@ -5,48 +5,85 @@ export interface SubtitleCue {
text: string; text: string;
} }
interface SrtParseDiagnostic {
blockIndex: number;
reason: string;
}
export interface SrtParseResult {
cues: SubtitleCue[];
diagnostics: SrtParseDiagnostic[];
}
const SRT_TIME_SEPARATOR = " --> "; const SRT_TIME_SEPARATOR = " --> ";
const SRT_TIME_PATTERN = /^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/; const SRT_TIME_PATTERN = /^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/;
export function parseSrt(srtContent: string): SubtitleCue[] { export function parseSrt(srtContent: string): SubtitleCue[] {
return srtContent return parseSrtWithDiagnostics(srtContent).cues;
}
export function parseSrtWithDiagnostics(srtContent: string): SrtParseResult {
const diagnostics: SrtParseDiagnostic[] = [];
const cues = srtContent
.replace(/^\uFEFF/, "") .replace(/^\uFEFF/, "")
.replace(/\r/g, "") .replace(/\r/g, "")
.trim() .trim()
.split(/\n{2,}/) .split(/\n{2,}/)
.map(parseSrtBlock) .map((block, blockIndex) => {
const result = parseSrtBlock(block);
if (!result.cue) {
diagnostics.push({ blockIndex, reason: result.reason });
}
return result.cue;
})
.filter((cue): cue is SubtitleCue => cue !== null); .filter((cue): cue is SubtitleCue => cue !== null);
return { cues, diagnostics };
} }
function parseSrtBlock(block: string): SubtitleCue | null { function parseSrtBlock(block: string): {
cue: SubtitleCue | null;
reason: string;
} {
const lines = block const lines = block
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean); .filter(Boolean);
if (lines.length < 3) return null; if (lines.length < 3) {
return { cue: null, reason: "missing index, timecode, or text" };
}
const index = Number(lines[0]); const index = Number(lines[0]);
if (!Number.isInteger(index)) return null; if (!Number.isInteger(index)) {
return { cue: null, reason: "invalid cue index" };
}
const [start, end] = lines[1]?.split(SRT_TIME_SEPARATOR) ?? []; const [start, end] = lines[1]?.split(SRT_TIME_SEPARATOR) ?? [];
if (!start || !end) return null; if (!start || !end) {
return { cue: null, reason: "invalid timecode separator" };
}
const startTime = parseSrtTime(start); const startTime = parseSrtTime(start);
const endTime = parseSrtTime(end); const endTime = parseSrtTime(end);
if (startTime === null || endTime === null || endTime <= startTime) { if (startTime === null || endTime === null || endTime <= startTime) {
return null; return { cue: null, reason: "invalid cue duration" };
} }
return { return {
index, cue: {
startTime, index,
endTime, startTime,
text: lines.slice(2).join("\n"), endTime,
text: lines.slice(2).join("\n"),
},
reason: "",
}; };
} }
function parseSrtTime(value: string): number | null { export function parseSrtTime(value: string): number | null {
const match = value.match(SRT_TIME_PATTERN); const match = value.match(SRT_TIME_PATTERN);
if (!match) return null; if (!match) return null;
+40 -16
View File
@@ -1,5 +1,41 @@
import * as THREE from "three"; import * as THREE from "three";
type TextureMaterialKey = Extract<
| keyof THREE.MeshBasicMaterial
| keyof THREE.MeshStandardMaterial
| keyof THREE.MeshPhysicalMaterial
| keyof THREE.MeshToonMaterial,
string
>;
type MaterialWithTextureSlots = THREE.Material &
Partial<Record<TextureMaterialKey, THREE.Texture | null>>;
const MATERIAL_TEXTURE_KEYS = [
"alphaMap",
"aoMap",
"bumpMap",
"clearcoatMap",
"clearcoatNormalMap",
"clearcoatRoughnessMap",
"displacementMap",
"emissiveMap",
"envMap",
"gradientMap",
"lightMap",
"map",
"metalnessMap",
"normalMap",
"roughnessMap",
"sheenColorMap",
"sheenRoughnessMap",
"specularColorMap",
"specularIntensityMap",
"specularMap",
"thicknessMap",
"transmissionMap",
] as const satisfies readonly TextureMaterialKey[];
export function disposeObject3D(object: THREE.Object3D): void { export function disposeObject3D(object: THREE.Object3D): void {
object.traverse((child) => { object.traverse((child) => {
if (child instanceof THREE.Mesh) { if (child instanceof THREE.Mesh) {
@@ -18,25 +54,13 @@ export function disposeObject3D(object: THREE.Object3D): void {
function disposeMaterial(material: THREE.Material): void { function disposeMaterial(material: THREE.Material): void {
material.dispose(); material.dispose();
const materialWithTextures = material as MaterialWithTextureSlots;
for (const key of MATERIAL_TEXTURE_KEYS) {
const value = materialWithTextures[key];
for (const key of Object.keys(material)) {
const value = (material as unknown as Record<string, unknown>)[key];
if (value instanceof THREE.Texture) { if (value instanceof THREE.Texture) {
value.dispose(); value.dispose();
} }
} }
} }
export function disposeInstancedMesh(mesh: THREE.InstancedMesh): void {
mesh.geometry?.dispose();
if (Array.isArray(mesh.material)) {
for (const material of mesh.material) {
disposeMaterial(material);
}
} else if (mesh.material) {
disposeMaterial(mesh.material);
}
mesh.dispose();
}
+1 -1
View File
@@ -35,7 +35,7 @@ import {
isRuntimeSingleMapNode, isRuntimeSingleMapNode,
} from "@/utils/map/mapRuntimeClassification"; } from "@/utils/map/mapRuntimeClassification";
import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import type { MapNode } from "@/types/editor/editor"; import type { MapNode } from "@/types/map/mapScene";
import type { OctreeReadyHandler } from "@/types/three/three"; import type { OctreeReadyHandler } from "@/types/three/three";
interface LoadedMapNode { interface LoadedMapNode {
+2 -2
View File
@@ -18,12 +18,12 @@ import {
useTerrainHeightSampler, useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight"; } from "@/hooks/three/useTerrainHeight";
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision"; import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
import type { MapNode } from "@/types/editor/editor"; import type { MapNode } from "@/types/map/mapScene";
import type { OctreeReadyHandler } from "@/types/three/three"; import type { OctreeReadyHandler } from "@/types/three/three";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { logModelLoadError } from "@/utils/three/modelLoadLogger";
export interface GameMapCollisionNode { interface GameMapCollisionNode {
node: MapNode; node: MapNode;
modelUrl: string | null; modelUrl: string | null;
} }
+8 -30
View File
@@ -1,8 +1,10 @@
import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { RepairGame } from "@/components/three/gameplay/RepairGame"; import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { EBIKE_REPAIR_POSITION } from "@/data/gameplay/repairMissionAnchors"; import {
BIKE_REPAIR_POSITION,
REPAIR_MISSION_POSITION_ENTRIES,
} from "@/data/gameplay/repairMissionAnchors";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
interface StageAnchorProps { interface StageAnchorProps {
@@ -11,26 +13,6 @@ interface StageAnchorProps {
scale?: number; scale?: number;
} }
interface GameRepairZone {
mission: RepairMissionId;
position: Vector3Tuple;
}
const GAME_REPAIR_ZONES = [
{
mission: "bike",
position: EBIKE_REPAIR_POSITION,
},
{
mission: "pylone",
position: [64, 0, -66],
},
{
mission: "ferme",
position: [-24, 0, 42],
},
] as const satisfies readonly GameRepairZone[];
function StageAnchor({ function StageAnchor({
color, color,
position, position,
@@ -58,11 +40,11 @@ function EbikeMissionTrigger(): React.JSX.Element | null {
if (mainState !== "bike" || bikeStep !== "locked") return null; if (mainState !== "bike" || bikeStep !== "locked") return null;
return ( return (
<group position={EBIKE_REPAIR_POSITION}> <group position={BIKE_REPAIR_POSITION}>
<InteractableObject <InteractableObject
kind="trigger" kind="trigger"
label="Réparer l'e-bike" label="Réparer l'e-bike"
position={EBIKE_REPAIR_POSITION} position={BIKE_REPAIR_POSITION}
radius={4} radius={4}
onPress={() => setMissionStep("bike", "waiting")} onPress={() => setMissionStep("bike", "waiting")}
> >
@@ -83,12 +65,8 @@ export function GameStageContent(): React.JSX.Element {
{mainState === "intro" ? ( {mainState === "intro" ? (
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} /> <StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
) : null} ) : null}
{GAME_REPAIR_ZONES.map((zone) => ( {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => (
<RepairGame <RepairGame key={mission} mission={mission} position={position} />
key={zone.mission}
mission={zone.mission}
position={zone.position}
/>
))} ))}
<EbikeMissionTrigger /> <EbikeMissionTrigger />
{mainState === "outro" ? ( {mainState === "outro" ? (
+10 -3
View File
@@ -4,6 +4,7 @@ import * as THREE from "three";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface"; import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import { logger } from "@/utils/core/Logger";
import { getMapNodesByName } from "@/utils/map/loadMapSceneData"; import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
import { GRASS_CONFIG } from "@/world/grass/grassConfig"; import { GRASS_CONFIG } from "@/world/grass/grassConfig";
@@ -13,8 +14,9 @@ const DOWN = new THREE.Vector3(0, -1, 0);
const DEFAULT_TERRAIN_POSITION: Vector3Tuple = [0, 0, 0]; const DEFAULT_TERRAIN_POSITION: Vector3Tuple = [0, 0, 0];
const DEFAULT_TERRAIN_ROTATION: Vector3Tuple = [0, 0, 0]; const DEFAULT_TERRAIN_ROTATION: Vector3Tuple = [0, 0, 0];
const DEFAULT_TERRAIN_SCALE: Vector3Tuple = [1, 1, 1]; const DEFAULT_TERRAIN_SCALE: Vector3Tuple = [1, 1, 1];
let hasWarnedFallbackBounds = false;
export interface TerrainGrassSample { interface TerrainGrassSample {
normal: THREE.Vector3; normal: THREE.Vector3;
position: THREE.Vector3; position: THREE.Vector3;
} }
@@ -27,7 +29,12 @@ export interface TerrainGrassSampler {
sample: (x: number, z: number) => TerrainGrassSample | null; sample: (x: number, z: number) => TerrainGrassSample | null;
} }
function createFallbackBounds(): TerrainSurfaceBounds { function createFallbackTerrainBounds(): TerrainSurfaceBounds {
if (!hasWarnedFallbackBounds) {
hasWarnedFallbackBounds = true;
logger.warn("Grass", "Terrain bounds missing, using fallback grass bounds");
}
return { return {
minX: -120, minX: -120,
maxX: 120, maxX: 120,
@@ -78,7 +85,7 @@ function createTerrainGrassSampler(
} }
const bounds = terrainBounds.isEmpty() const bounds = terrainBounds.isEmpty()
? createFallbackBounds() ? createFallbackTerrainBounds()
: { : {
minX: terrainBounds.min.x, minX: terrainBounds.min.x,
maxX: terrainBounds.max.x, maxX: terrainBounds.max.x,
@@ -6,7 +6,7 @@ import {
normalizeMapScale, normalizeMapScale,
useTerrainSnappedPosition, useTerrainSnappedPosition,
} from "@/hooks/three/useTerrainHeight"; } from "@/hooks/three/useTerrainHeight";
import type { MapNode } from "@/types/editor/editor"; import type { MapNode } from "@/types/map/mapScene";
interface GeneratedMapNodeInstanceProps { interface GeneratedMapNodeInstanceProps {
node: MapNode; node: MapNode;
@@ -7,8 +7,8 @@ import {
normalizeMapScale, normalizeMapScale,
useTerrainHeightSampler, useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight"; } from "@/hooks/three/useTerrainHeight";
import type { MapAssetInstance } from "@/hooks/world/useMapInstancingData";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene"; import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
interface InstancedMapAssetProps { interface InstancedMapAssetProps {
modelPath: string; modelPath: string;
@@ -102,8 +102,11 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
return [...groups.values()] return [...groups.values()]
.map((group) => { .map((group) => {
if (group.geometries.length === 1) { if (group.geometries.length === 1) {
const [geometry] = group.geometries;
if (!geometry) return null;
return { return {
geometry: group.geometries[0] as THREE.BufferGeometry, geometry,
material: group.material, material: group.material,
}; };
} }
@@ -1,8 +1,8 @@
import { Suspense, useCallback, useMemo, useRef, useState } from "react"; import { Suspense, useMemo } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import { CHUNK_CONFIG } from "@/data/world/fogConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import { import {
isMapModelVisible, isMapModelVisible,
useMapPerformanceStore, useMapPerformanceStore,
@@ -10,13 +10,14 @@ import {
import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset"; import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset";
import { import {
MAP_INSTANCING_ASSETS, MAP_INSTANCING_ASSETS,
MAP_INSTANCING_ASSET_TYPES,
type MapInstancingAssetConfig, type MapInstancingAssetConfig,
type MapInstancingAssetType, type MapInstancingAssetType,
} from "@/world/map-instancing/mapInstancingConfig"; } from "@/data/world/mapInstancingConfig";
import { import {
type MapAssetInstance, type MapAssetInstance,
useMapInstancingData, useMapInstancingData,
} from "@/world/map-instancing/useMapInstancingData"; } from "@/hooks/world/useMapInstancingData";
interface MapAssetChunk { interface MapAssetChunk {
key: string; key: string;
@@ -72,23 +73,20 @@ function createMapAssetChunks(
} }
export function MapInstancingSystem(): React.JSX.Element | null { export function MapInstancingSystem(): React.JSX.Element | null {
const camera = useThree((state) => state.camera);
const cameraMode = useCameraMode(); const cameraMode = useCameraMode();
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
const groups = useMapPerformanceStore((state) => state.groups); const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models); const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useMapInstancingData(); const { data, isLoading } = useMapInstancingData();
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
() => new Set(),
);
const streamingEnabled = const streamingEnabled =
CHUNK_CONFIG.enabled && sceneMode === "game" && cameraMode === "player"; CHUNK_CONFIG.enabled && sceneMode === "game" && cameraMode === "player";
const chunks = useMemo(() => { const chunks = useMemo(() => {
if (!data) return []; if (!data) return [];
return Object.entries(MAP_INSTANCING_ASSETS).flatMap(([type, config]) => { return MAP_INSTANCING_ASSET_TYPES.flatMap((type) => {
const config = MAP_INSTANCING_ASSETS[type];
if ( if (
!config.enabled || !config.enabled ||
!isMapModelVisible(config.mapName, { groups, models }) !isMapModelVisible(config.mapName, { groups, models })
@@ -96,71 +94,14 @@ export function MapInstancingSystem(): React.JSX.Element | null {
return []; return [];
} }
const instances = data.get(type as MapInstancingAssetType); const instances = data.get(type);
if (!instances || instances.length === 0) return []; if (!instances || instances.length === 0) return [];
return createMapAssetChunks( return createMapAssetChunks(type, config, instances);
type as MapInstancingAssetType,
config,
instances,
);
}); });
}, [data, groups, models]); }, [data, groups, models]);
const visibleChunks = streamingEnabled const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
? 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
);
})
: chunks;
const updateActiveChunks = useCallback(() => {
const nextKeys = new Set<string>();
const cameraX = camera.position.x;
const cameraZ = camera.position.z;
for (const chunk of chunks) {
const distance = Math.hypot(
chunk.centerX - cameraX,
chunk.centerZ - cameraZ,
);
const wasActive = activeChunkKeys.has(chunk.key);
const radius = wasActive
? CHUNK_CONFIG.unloadRadius
: CHUNK_CONFIG.loadRadius;
if (distance <= radius) {
nextKeys.add(chunk.key);
}
}
if (
nextKeys.size === activeChunkKeys.size &&
[...nextKeys].every((key) => activeChunkKeys.has(key))
) {
return;
}
setActiveChunkKeys(nextKeys);
}, [activeChunkKeys, camera, chunks]);
useFrame(({ clock }) => {
if (!streamingEnabled) return;
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
updateActiveChunks();
});
if (isLoading || !data) { if (isLoading || !data) {
return null; return null;
+1 -1
View File
@@ -4,9 +4,9 @@ import { useGLTF } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js"; import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight"; import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import type { VegetationInstance } from "@/hooks/world/useVegetationData";
import { useWind } from "@/hooks/world/useWind"; import { useWind } from "@/hooks/world/useWind";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene"; import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
import type { VegetationInstance } from "@/world/vegetation/useVegetationData";
interface InstancedVegetationProps { interface InstancedVegetationProps {
modelPath: string; modelPath: string;
+10 -65
View File
@@ -1,8 +1,8 @@
import { Suspense, useCallback, useMemo, useRef, useState } from "react"; import { Suspense, useMemo } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import { CHUNK_CONFIG } from "@/data/world/fogConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import { import {
isMapModelVisible, isMapModelVisible,
useMapPerformanceStore, useMapPerformanceStore,
@@ -11,8 +11,9 @@ import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation";
import { import {
type VegetationInstance, type VegetationInstance,
useVegetationData, useVegetationData,
} from "@/world/vegetation/useVegetationData"; } from "@/hooks/world/useVegetationData";
import { import {
VEGETATION_TYPE_KEYS,
VEGETATION_TYPES, VEGETATION_TYPES,
type VegetationType, type VegetationType,
} from "@/world/vegetation/vegetationConfig"; } from "@/world/vegetation/vegetationConfig";
@@ -80,87 +81,31 @@ function createVegetationChunks(
} }
export function VegetationSystem(): React.JSX.Element | null { export function VegetationSystem(): React.JSX.Element | null {
const camera = useThree((state) => state.camera);
const cameraMode = useCameraMode(); const cameraMode = useCameraMode();
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
const groups = useMapPerformanceStore((state) => state.groups); const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models); const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useVegetationData(); const { data, isLoading } = useVegetationData();
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
() => new Set(),
);
const streamingEnabled = const streamingEnabled =
CHUNK_CONFIG.enabled && sceneMode === "game" && cameraMode === "player"; CHUNK_CONFIG.enabled && sceneMode === "game" && cameraMode === "player";
const chunks = useMemo(() => { const chunks = useMemo(() => {
if (!data) return []; if (!data) return [];
return Object.entries(VEGETATION_TYPES).flatMap(([type, config]) => { return VEGETATION_TYPE_KEYS.flatMap((type) => {
const config = VEGETATION_TYPES[type];
if (!config.enabled) return []; if (!config.enabled) return [];
if (!isMapModelVisible(config.mapName, { groups, models })) return []; if (!isMapModelVisible(config.mapName, { groups, models })) return [];
const entry = data.get(config.mapName); const entry = data.get(config.mapName);
if (!entry || entry.instances.length === 0) return []; if (!entry || entry.instances.length === 0) return [];
return createVegetationChunks(type as VegetationType, entry.instances); return createVegetationChunks(type, entry.instances);
}); });
}, [data, groups, models]); }, [data, groups, models]);
const visibleChunks = streamingEnabled const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
? 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
);
})
: chunks;
const updateActiveChunks = useCallback(() => {
const nextKeys = new Set<string>();
const cameraX = camera.position.x;
const cameraZ = camera.position.z;
for (const chunk of chunks) {
const distance = Math.hypot(
chunk.centerX - cameraX,
chunk.centerZ - cameraZ,
);
const wasActive = activeChunkKeys.has(chunk.key);
const radius = wasActive
? CHUNK_CONFIG.unloadRadius
: CHUNK_CONFIG.loadRadius;
if (distance <= radius) {
nextKeys.add(chunk.key);
}
}
if (
nextKeys.size === activeChunkKeys.size &&
[...nextKeys].every((key) => activeChunkKeys.has(key))
) {
return;
}
setActiveChunkKeys(nextKeys);
}, [activeChunkKeys, camera, chunks]);
useFrame(({ clock }) => {
if (!streamingEnabled) return;
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
updateActiveChunks();
});
if (isLoading || !data) { if (isLoading || !data) {
return null; return null;
+10 -15
View File
@@ -1,9 +1,3 @@
export const VEGETATION_LOD = {
windAnimationRadius: 70,
windFadeStart: 50,
windFadeEnd: 70,
};
export const VEGETATION_TYPES = { export const VEGETATION_TYPES = {
buissons: { buissons: {
mapName: "buisson", mapName: "buisson",
@@ -61,18 +55,19 @@ export const VEGETATION_TYPES = {
}, },
} as const; } as const;
export type VegetationType = keyof typeof VEGETATION_TYPES; export const VEGETATION_TYPE_KEYS = [
"buissons",
"sapin",
"arbre",
"champdeble",
"champdesoja",
"champsdetournesol",
] 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 INSTANCED_MAP_EXCEPTIONS = new Set([
"Scene", "Scene",
"blocking", "blocking",
"terrain", "terrain",
]); ]);
export function getVegetationScaleMultiplier(name: string): number | null {
const config = Object.values(VEGETATION_TYPES).find(
(vegetationConfig) => vegetationConfig.mapName === name,
);
return config?.scaleMultiplier ?? null;
}