diff --git a/README.md b/README.md
index d36be11..718615a 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,8 @@ la-fabrik/
│ ├── editor/ # Editor-only parsing utilities
│ ├── map/ # Map loading and validation
│ └── three/ # Three.js helpers
- ├── App.tsx # Canvas bootstrap
+ ├── types/ # Shared TypeScript domain types
+ ├── App.tsx # App bootstrap and route switch
└── main.tsx
```
diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md
index b72979b..f368e91 100644
--- a/docs/technical/architecture.md
+++ b/docs/technical/architecture.md
@@ -77,6 +77,7 @@ Keep the player and map octree outside the Rapier provider until there is a deli
- `src/utils/editor/loadEditorScene.ts` handles editor-only folder upload parsing.
- `src/utils/map/loadMapSceneData.ts` is shared by the game scene and editor to load `public/map.json` and resolve model URLs.
- `src/types/editor/editor.ts` contains the shared `MapNode`, `SceneData`, and `TransformMode` types.
+- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used across store, config, debug UI, and gameplay components.
## Map Data
diff --git a/docs/technical/zustand.md b/docs/technical/zustand.md
index a36ba07..5cd0ecf 100644
--- a/docs/technical/zustand.md
+++ b/docs/technical/zustand.md
@@ -137,7 +137,7 @@ For repair missions, it mounts the reusable `RepairGame` component with a missio
```
-`RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep` and `completeMission`. This keeps the scene component small and avoids mission-specific branching inside the repair flow. The production repair flow currently supports `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission` state transitions.
+`RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep` and `completeMission`. Shared repair ids, mission steps, and runtime guards live in `src/types/gameplay/repairMission.ts` so static mission config does not depend on the Zustand store. The production repair flow currently supports `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission` state transitions.
Mission-specific behavior stays in `src/data/gameplay/repairMissions.ts`: each mission can define its broken nodes, placeholder targets, scan duration, and reassembly duration without adding mission branches to `RepairGame`.
diff --git a/docs/user/main-feature.md b/docs/user/main-feature.md
index fc3ed0f..b192aac 100644
--- a/docs/user/main-feature.md
+++ b/docs/user/main-feature.md
@@ -63,6 +63,7 @@ The mission config now carries the mission-specific variations. `bike` repairs o
- `src/data/gameplay/repairGameConfig.ts` stores repair flow timing constants.
- `src/data/gameplay/repairMissions.ts` stores reusable repair mission config for `bike`, `pylone`, and `ferme`.
- `src/managers/stores/useGameStore.ts` stores mission progression state and generic mission step helpers.
+- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used by the store, data config, debug UI, and gameplay components.
## Runtime Requirements
diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx
index ea497bf..63dbd67 100644
--- a/src/components/three/gameplay/RepairGame.tsx
+++ b/src/components/three/gameplay/RepairGame.tsx
@@ -14,7 +14,7 @@ import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGam
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
-import type { RepairMissionId } from "@/managers/stores/useGameStore";
+import type { RepairMissionId } from "@/types/gameplay/repairMission";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
diff --git a/src/components/three/gameplay/RepairObjectModel.tsx b/src/components/three/gameplay/RepairObjectModel.tsx
index 671a574..cb678c0 100644
--- a/src/components/three/gameplay/RepairObjectModel.tsx
+++ b/src/components/three/gameplay/RepairObjectModel.tsx
@@ -3,6 +3,7 @@ import { Component } from "react";
import { SimpleModel } from "@/components/three/models/SimpleModel";
import type { ModelTransformProps } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
+import { toVector3Scale } from "@/utils/three/scale";
interface RepairObjectModelProps extends ModelTransformProps {
label: string;
@@ -17,6 +18,13 @@ interface RepairObjectModelBoundaryState {
hasError: boolean;
}
+interface RepairObjectFallbackProps {
+ label: string;
+ position?: ModelTransformProps["position"] | undefined;
+ rotation?: ModelTransformProps["rotation"] | undefined;
+ scale?: ModelTransformProps["scale"] | undefined;
+}
+
class RepairObjectModelBoundary extends Component<
RepairObjectModelBoundaryProps,
RepairObjectModelBoundaryState
@@ -45,7 +53,14 @@ class RepairObjectModelBoundary extends Component<
render(): ReactNode {
if (this.state.hasError) {
- return ;
+ return (
+
+ );
}
return this.props.children;
@@ -77,9 +92,21 @@ export function RepairObjectModel({
);
}
-function RepairObjectFallback({ label }: { label: string }): React.JSX.Element {
+function RepairObjectFallback({
+ label,
+ position = [0, 0, 0],
+ rotation = [0, 0, 0],
+ scale = 1,
+}: Pick<
+ RepairObjectFallbackProps,
+ "label" | "position" | "rotation" | "scale"
+>): React.JSX.Element {
return (
-
+
diff --git a/src/components/three/gameplay/RepairRepairingStep.tsx b/src/components/three/gameplay/RepairRepairingStep.tsx
index 3b40ad3..166da80 100644
--- a/src/components/three/gameplay/RepairRepairingStep.tsx
+++ b/src/components/three/gameplay/RepairRepairingStep.tsx
@@ -19,7 +19,7 @@ import type { Vector3Tuple } from "@/types/three/three";
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
const _placeholderPosition = new THREE.Vector3();
-const REPLACEMENT_START_OFFSETS: Vector3Tuple[] = [
+const FALLBACK_PLACEHOLDER_OFFSETS: Vector3Tuple[] = [
[-1.15, 1, 0.25],
[0, 1.05, 0.45],
[1.15, 1, 0.25],
@@ -38,6 +38,18 @@ interface RepairRepairingStepProps {
onRepair: () => void;
}
+interface RepairInstallTargetProps {
+ fillColor: string;
+ isReadyToInstall: boolean;
+ label: string;
+ ringColor: string;
+ onRepair: () => void;
+}
+
+interface RepairPlaceholderMarkersProps {
+ positions: readonly Vector3Tuple[];
+}
+
export function RepairRepairingStep({
brokenParts,
config,
@@ -82,6 +94,13 @@ export function RepairRepairingStep({
: hasWrongPartPlaced
? "#fecaca"
: "#fed7aa";
+ const installLabel = isReadyToInstall
+ ? `Installer ${requiredReplacementLabel}`
+ : hasWrongPartPlaced
+ ? `Mauvaise piece`
+ : hasCorrectPartPlaced
+ ? `Ranger piece cassee`
+ : `Approcher ${requiredReplacementLabel}`;
function handleReplacementPosition(
partId: string,
@@ -126,48 +145,15 @@ export function RepairRepairingStep({
return (
- {
- if (!isReadyToInstall) return;
+
- onRepair();
- }}
- >
-
-
-
-
-
-
-
-
-
-
- {placeholderPositions.map((position, index) => (
-
-
-
-
- ))}
+
{replacementParts.map((part, index) => {
const placeholderPosition =
@@ -251,6 +237,55 @@ export function RepairRepairingStep({
);
}
+function RepairInstallTarget({
+ fillColor,
+ isReadyToInstall,
+ label,
+ ringColor,
+ onRepair,
+}: RepairInstallTargetProps): React.JSX.Element {
+ return (
+ {
+ if (!isReadyToInstall) return;
+
+ onRepair();
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function RepairPlaceholderMarkers({
+ positions,
+}: RepairPlaceholderMarkersProps): React.JSX.Element {
+ return (
+ <>
+ {positions.map((position, index) => (
+
+
+
+
+ ))}
+ >
+ );
+}
+
function getPlaceholderTargets(
placeholders: readonly RepairCasePlaceholder[],
): readonly RepairCasePlaceholder[] {
@@ -258,7 +293,7 @@ function getPlaceholderTargets(
return placeholders;
}
- return REPLACEMENT_START_OFFSETS.map(
+ return FALLBACK_PLACEHOLDER_OFFSETS.map(
(offset, index): RepairCasePlaceholder => ({
name: `placeholder_${index + 1}`,
position: [
diff --git a/src/components/three/models/ExplodableModel.tsx b/src/components/three/models/ExplodableModel.tsx
index b23b176..711acdc 100644
--- a/src/components/three/models/ExplodableModel.tsx
+++ b/src/components/three/models/ExplodableModel.tsx
@@ -13,6 +13,8 @@ interface ModelErrorBoundaryProps {
children: ReactNode;
modelPath: string;
position?: Vector3Tuple | undefined;
+ rotation?: Vector3Tuple | undefined;
+ scale?: ModelTransformProps["scale"] | undefined;
}
interface ModelErrorBoundaryState {
@@ -38,6 +40,8 @@ class ModelErrorBoundary extends Component<
modelPath: this.props.modelPath,
scope: "ExplodableModel",
position: this.props.position,
+ rotation: this.props.rotation,
+ scale: this.props.scale,
},
error,
);
@@ -45,7 +49,13 @@ class ModelErrorBoundary extends Component<
render(): ReactNode {
if (this.state.hasError) {
- return ;
+ return (
+
+ );
}
return this.props.children;
@@ -67,6 +77,8 @@ export function ExplodableModel(
key={props.modelPath}
modelPath={props.modelPath}
position={props.position}
+ rotation={props.rotation}
+ scale={props.scale}
>
@@ -116,11 +128,15 @@ function ExplodableModelInner({
function MissingModelFallback({
position = [0, 0, 0],
+ rotation = [0, 0, 0],
+ scale = 1,
}: {
position?: Vector3Tuple | undefined;
+ rotation?: Vector3Tuple | undefined;
+ scale?: ModelTransformProps["scale"] | undefined;
}): React.JSX.Element {
return (
-
+
diff --git a/src/components/ui/debug/GameStateDebugPanel.tsx b/src/components/ui/debug/GameStateDebugPanel.tsx
index d9eaf37..27fc801 100644
--- a/src/components/ui/debug/GameStateDebugPanel.tsx
+++ b/src/components/ui/debug/GameStateDebugPanel.tsx
@@ -1,9 +1,9 @@
import { RotateCcw, StepBack, StepForward } from "lucide-react";
import {
type MainGameState,
- type MissionStep,
useGameStore,
} from "@/managers/stores/useGameStore";
+import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
const MAIN_STATES: MainGameState[] = [
"intro",
@@ -13,17 +13,6 @@ const MAIN_STATES: MainGameState[] = [
"outro",
];
-const MISSION_STEPS: MissionStep[] = [
- "locked",
- "waiting",
- "inspected",
- "fragmented",
- "scanning",
- "repairing",
- "reassembling",
- "done",
-];
-
function toPascalCase(value: string): string {
return value
.split(/[-_\s]+/)
@@ -71,22 +60,27 @@ export function GameStateDebugPanel(): React.JSX.Element {
return;
}
+ if (mainState === "outro") {
+ setOutroState({ hasStarted: nextSubState === "started" });
+ return;
+ }
+
+ if (!isMissionStep(nextSubState)) return;
+
if (mainState === "bike") {
- setBikeState({ currentStep: nextSubState as MissionStep });
+ setBikeState({ currentStep: nextSubState });
return;
}
if (mainState === "pylone") {
- setPyloneState({ currentStep: nextSubState as MissionStep });
+ setPyloneState({ currentStep: nextSubState });
return;
}
if (mainState === "ferme") {
- setFermeState({ currentStep: nextSubState as MissionStep });
+ setFermeState({ currentStep: nextSubState });
return;
}
-
- setOutroState({ hasStarted: nextSubState === "started" });
}
return (
diff --git a/src/data/docs/docsTranslations.ts b/src/data/docs/docsTranslations.ts
index 4e1cf3d..889d5a6 100644
--- a/src/data/docs/docsTranslations.ts
+++ b/src/data/docs/docsTranslations.ts
@@ -361,7 +361,7 @@ Pour les missions de réparation, il monte le composant réutilisable \`RepairGa
\`\`\`
-\`RepairGame\` lit l'étape de mission active depuis le store et écrit les transitions via des actions génériques comme \`setMissionStep\` et \`completeMission\`. Cela garde le composant de scène petit et évite les branches spécifiques à chaque mission dans le flow de réparation. Le flow de réparation de production supporte actuellement les transitions \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`.
+\`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\`.
La scène peut donc évoluer progressivement vers ce pattern :
diff --git a/src/data/gameplay/repairMissions.ts b/src/data/gameplay/repairMissions.ts
index ed88846..f2c1636 100644
--- a/src/data/gameplay/repairMissions.ts
+++ b/src/data/gameplay/repairMissions.ts
@@ -1,4 +1,4 @@
-import type { RepairMissionId } from "@/managers/stores/useGameStore";
+import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { Vector3Scale, Vector3Tuple } from "@/types/three/three";
export interface RepairMissionCaseConfig {
diff --git a/src/hooks/gameplay/useRepairMissionStep.ts b/src/hooks/gameplay/useRepairMissionStep.ts
index f1a24d6..c9b1db2 100644
--- a/src/hooks/gameplay/useRepairMissionStep.ts
+++ b/src/hooks/gameplay/useRepairMissionStep.ts
@@ -1,8 +1,8 @@
+import { useGameStore } from "@/managers/stores/useGameStore";
import type {
MissionStep,
RepairMissionId,
-} from "@/managers/stores/useGameStore";
-import { useGameStore } from "@/managers/stores/useGameStore";
+} from "@/types/gameplay/repairMission";
export function useRepairMissionStep(mission: RepairMissionId): MissionStep {
return useGameStore((state) => state[mission].currentStep);
diff --git a/src/lib/handTracking/handTrackingSession.ts b/src/lib/handTracking/handTrackingSession.ts
index de7ed7e..f4f6da5 100644
--- a/src/lib/handTracking/handTrackingSession.ts
+++ b/src/lib/handTracking/handTrackingSession.ts
@@ -20,7 +20,7 @@ export function getCameraStreamWithTimeout(
didTimeout = true;
reject(
new Error(
- "Camera request timed out. Restart Arc or check camera permissions for localhost:5173.",
+ "Camera request timed out. Restart the browser or check camera permissions for localhost:5173.",
),
);
}, HAND_TRACKING_CAMERA_TIMEOUT_MS);
diff --git a/src/managers/AudioManager.ts b/src/managers/AudioManager.ts
index dcac4ee..01d2be5 100644
--- a/src/managers/AudioManager.ts
+++ b/src/managers/AudioManager.ts
@@ -125,7 +125,22 @@ export class AudioManager {
this._musicUnlockHandler = () => {
this._removeMusicUnlockHandler();
- void this._music?.play();
+ const music = this._music;
+ if (!music) return;
+
+ void music.play().catch((error: unknown) => {
+ if (
+ error instanceof DOMException &&
+ AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
+ ) {
+ return;
+ }
+
+ logger.error("AudioManager", "Failed to unlock music playback", {
+ path: this._musicPath,
+ error: AudioManager._toLogValue(error),
+ });
+ });
};
window.addEventListener("pointerdown", this._musicUnlockHandler, {
diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts
index 499cde1..169c7df 100644
--- a/src/managers/stores/useGameStore.ts
+++ b/src/managers/stores/useGameStore.ts
@@ -1,16 +1,12 @@
import { create } from "zustand";
+import {
+ isRepairMissionId,
+ type MissionStep,
+ type RepairMissionId,
+} from "@/types/gameplay/repairMission";
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
-export type RepairMissionId = "bike" | "pylone" | "ferme";
-export type MissionStep =
- | "locked"
- | "waiting"
- | "inspected"
- | "fragmented"
- | "scanning"
- | "repairing"
- | "reassembling"
- | "done";
+export type { MissionStep, RepairMissionId };
interface IntroState {
dialogueAudio: string | null;
@@ -63,12 +59,6 @@ interface GameActions {
type GameStore = GameState & GameActions;
type GameStateUpdate = Partial;
-export const REPAIR_MISSION_IDS = ["bike", "pylone", "ferme"] as const;
-
-function isRepairMissionId(value: MainGameState): value is RepairMissionId {
- return REPAIR_MISSION_IDS.includes(value as RepairMissionId);
-}
-
function getNextMissionStep(step: MissionStep): MissionStep {
switch (step) {
case "locked":
diff --git a/src/providers/gameplay/HandTrackingProvider.tsx b/src/providers/gameplay/HandTrackingProvider.tsx
index b074696..87b72c6 100644
--- a/src/providers/gameplay/HandTrackingProvider.tsx
+++ b/src/providers/gameplay/HandTrackingProvider.tsx
@@ -9,7 +9,7 @@ import {
import { useBrowserHandTracking } from "@/hooks/handTracking/useBrowserHandTracking";
import { useRemoteHandTracking } from "@/hooks/handTracking/useRemoteHandTracking";
import { useGameStore } from "@/managers/stores/useGameStore";
-import type { MissionStep } from "@/managers/stores/useGameStore";
+import type { MissionStep } from "@/types/gameplay/repairMission";
const REPAIR_HAND_TRACKING_STEPS = new Set([
"inspected",
diff --git a/src/types/gameplay/repairMission.ts b/src/types/gameplay/repairMission.ts
new file mode 100644
index 0000000..f8836b0
--- /dev/null
+++ b/src/types/gameplay/repairMission.ts
@@ -0,0 +1,32 @@
+export type RepairMissionId = "bike" | "pylone" | "ferme";
+
+export type MissionStep =
+ | "locked"
+ | "waiting"
+ | "inspected"
+ | "fragmented"
+ | "scanning"
+ | "repairing"
+ | "reassembling"
+ | "done";
+
+export const REPAIR_MISSION_IDS = ["bike", "pylone", "ferme"] as const;
+
+export const MISSION_STEPS = [
+ "locked",
+ "waiting",
+ "inspected",
+ "fragmented",
+ "scanning",
+ "repairing",
+ "reassembling",
+ "done",
+] as const satisfies readonly MissionStep[];
+
+export function isRepairMissionId(value: string): value is RepairMissionId {
+ return (REPAIR_MISSION_IDS as readonly string[]).includes(value);
+}
+
+export function isMissionStep(value: string): value is MissionStep {
+ return (MISSION_STEPS as readonly string[]).includes(value);
+}
diff --git a/src/utils/editor/loadEditorScene.ts b/src/utils/editor/loadEditorScene.ts
index 8be736d..3da9c53 100644
--- a/src/utils/editor/loadEditorScene.ts
+++ b/src/utils/editor/loadEditorScene.ts
@@ -17,7 +17,8 @@ export async function createSceneDataFromFiles(
throw new Error("Fichier map.json manquant à la racine du dossier");
}
- const mapNodes = parseMapNodes(JSON.parse(await mapFile.text()));
+ const mapPayload: unknown = JSON.parse(await mapFile.text());
+ const mapNodes = parseMapNodes(mapPayload);
const models = new Map();
for (const [path, file] of fileMap.entries()) {
diff --git a/src/utils/map/loadMapSceneData.ts b/src/utils/map/loadMapSceneData.ts
index 0040c74..ed5446c 100644
--- a/src/utils/map/loadMapSceneData.ts
+++ b/src/utils/map/loadMapSceneData.ts
@@ -13,7 +13,8 @@ export async function loadMapSceneData(): Promise {
return null;
}
- const mapNodes = parseMapNodes(await response.json());
+ const mapPayload: unknown = await response.json();
+ const mapNodes = parseMapNodes(mapPayload);
return createSceneData(mapNodes);
}
diff --git a/vite.config.ts b/vite.config.ts
index 8b0376c..9f0d111 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -48,7 +48,7 @@ const saveMapPlugin = (): Plugin => ({
}
try {
- const data = JSON.parse(Buffer.concat(chunks).toString());
+ const data: unknown = JSON.parse(Buffer.concat(chunks).toString());
try {
parseMapNodes(data);
} catch {