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 {