big clean up

This commit is contained in:
Tom Boullay
2026-05-08 03:02:26 +01:00
parent e4f6ec211c
commit 6d9eac291e
20 changed files with 208 additions and 94 deletions
+2 -1
View File
@@ -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
```
+1
View File
@@ -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
+1 -1
View File
@@ -137,7 +137,7 @@ For repair missions, it mounts the reusable `RepairGame` component with a missio
<RepairGame mission="bike" position={[8, 0, -6]} />
```
`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`.
+1
View File
@@ -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
+1 -1
View File
@@ -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";
@@ -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 <RepairObjectFallback label={this.props.label} />;
return (
<RepairObjectFallback
label={this.props.label}
position={this.props.position}
rotation={this.props.rotation}
scale={this.props.scale}
/>
);
}
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 (
<group>
<group
position={position}
rotation={rotation}
scale={toVector3Scale(scale)}
>
<mesh castShadow receiveShadow>
<boxGeometry args={[1.4, 1.4, 1.4]} />
<meshStandardMaterial color="#facc15" roughness={0.6} wireframe />
@@ -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 (
<group>
<TriggerObject
position={INSTALL_TARGET_POSITION}
colliders="ball"
label={
isReadyToInstall
? `Installer ${requiredReplacementLabel}`
: hasWrongPartPlaced
? `Mauvaise piece`
: hasCorrectPartPlaced
? `Ranger piece cassee`
: `Approcher ${requiredReplacementLabel}`
}
onTrigger={() => {
if (!isReadyToInstall) return;
<RepairInstallTarget
fillColor={installFillColor}
isReadyToInstall={isReadyToInstall}
label={installLabel}
ringColor={installColor}
onRepair={onRepair}
/>
onRepair();
}}
>
<mesh>
<torusGeometry args={[0.95, 0.045, 12, 96]} />
<meshBasicMaterial color={installColor} transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.15, 0.9, 96]} />
<meshBasicMaterial
color={installFillColor}
transparent
opacity={0.35}
/>
</mesh>
</TriggerObject>
{placeholderPositions.map((position, index) => (
<mesh
key={`${position.join(":")}-${index}`}
position={position}
rotation={[Math.PI / 2, 0, 0]}
>
<torusGeometry args={[0.26, 0.018, 8, 48]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.55} />
</mesh>
))}
<RepairPlaceholderMarkers positions={placeholderPositions} />
{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 (
<TriggerObject
position={INSTALL_TARGET_POSITION}
colliders="ball"
label={label}
onTrigger={() => {
if (!isReadyToInstall) return;
onRepair();
}}
>
<mesh>
<torusGeometry args={[0.95, 0.045, 12, 96]} />
<meshBasicMaterial color={ringColor} transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.15, 0.9, 96]} />
<meshBasicMaterial color={fillColor} transparent opacity={0.35} />
</mesh>
</TriggerObject>
);
}
function RepairPlaceholderMarkers({
positions,
}: RepairPlaceholderMarkersProps): React.JSX.Element {
return (
<>
{positions.map((position, index) => (
<mesh
key={`${position.join(":")}-${index}`}
position={position}
rotation={[Math.PI / 2, 0, 0]}
>
<torusGeometry args={[0.26, 0.018, 8, 48]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.55} />
</mesh>
))}
</>
);
}
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: [
@@ -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 <MissingModelFallback position={this.props.position} />;
return (
<MissingModelFallback
position={this.props.position}
rotation={this.props.rotation}
scale={this.props.scale}
/>
);
}
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}
>
<ExplodableModelInner {...props} />
</ModelErrorBoundary>
@@ -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 (
<mesh position={position}>
<mesh position={position} rotation={rotation} scale={toVector3Scale(scale)}>
<boxGeometry args={[0.7, 0.7, 0.7]} />
<meshStandardMaterial color="#7f1d1d" wireframe />
</mesh>
+11 -17
View File
@@ -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 (
+1 -1
View File
@@ -361,7 +361,7 @@ Pour les missions de réparation, il monte le composant réutilisable \`RepairGa
<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\`. 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 :
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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);
+16 -1
View File
@@ -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, {
+6 -16
View File
@@ -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<GameState>;
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":
@@ -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<MissionStep>([
"inspected",
+32
View File
@@ -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);
}
+2 -1
View File
@@ -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<string, string>();
for (const [path, file] of fileMap.entries()) {
+2 -1
View File
@@ -13,7 +13,8 @@ export async function loadMapSceneData(): Promise<SceneData | null> {
return null;
}
const mapNodes = parseMapNodes(await response.json());
const mapPayload: unknown = await response.json();
const mapNodes = parseMapNodes(mapPayload);
return createSceneData(mapNodes);
}
+1 -1
View File
@@ -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 {