clean: remove obsolete repair debug code + unused core utilities

This commit is contained in:
Tom Boullay
2026-05-08 02:07:03 +01:00
parent 15c3d1858f
commit eee69825c6
34 changed files with 144 additions and 797 deletions
+2 -3
View File
@@ -37,9 +37,8 @@ Use `useClonedObject` when a GLTF scene is reused by a component instance. It me
src/components/three/ src/components/three/
├── gameplay/ ├── gameplay/
│ ├── RepairCaseModel.tsx │ ├── RepairCaseModel.tsx
│ ├── RepairCaseObject.tsx │ ├── RepairGame.tsx
── RepairGameZone.tsx ── RepairRepairingStep.tsx
│ └── RepairModuleSlot.tsx
├── interaction/ ├── interaction/
│ ├── GrabbableObject.tsx │ ├── GrabbableObject.tsx
│ ├── InteractableObject.tsx │ ├── InteractableObject.tsx
+1 -1
View File
@@ -62,7 +62,7 @@ Keep the player and map octree outside the Rapier provider until there is a deli
- `src/components/three/models/` contains reusable model helpers such as `ExplodableModel`. - `src/components/three/models/` contains reusable model helpers such as `ExplodableModel`.
- `src/components/three/interaction/` contains reusable interaction wrappers such as `InteractableObject`, `TriggerObject`, and `GrabbableObject`. - `src/components/three/interaction/` contains reusable interaction wrappers such as `InteractableObject`, `TriggerObject`, and `GrabbableObject`.
- `src/components/three/handTracking/` contains R3F hand tracking debug models such as the glove overlays. - `src/components/three/handTracking/` contains R3F hand tracking debug models such as the glove overlays.
- `src/components/three/gameplay/` contains the repair gameplay components: the reusable production `RepairGame` flow, the repair case, the debug repair game zone, and module slots. - `src/components/three/gameplay/` contains the reusable production `RepairGame` flow, repair case, repair steps, and repair prompt components.
- `src/components/three/world/` contains reusable world/environment objects such as `SkyModel`. - `src/components/three/world/` contains reusable world/environment objects such as `SkyModel`.
## Editor System ## Editor System
+1 -1
View File
@@ -181,4 +181,4 @@ Current overlays:
## Next Steps ## Next Steps
The next natural step is to move repair validation from this local scene interaction into richer mission data when each mission has distinct broken module nodes, replacement assets, and narrative completion beats. Move repair validation into mission data once each mission has distinct broken module nodes, replacement assets, and completion events.
+3 -12
View File
@@ -21,27 +21,22 @@ The current user flow is:
11. Press `E` on the green install target to move to `done` and show the reassembled object. Wrong parts turn the target red and cannot finish the repair. 11. Press `E` on the green install target to move to `done` and show the reassembled object. Wrong parts turn the target red and cannot finish the repair.
12. Press `E` on the completion target to call `completeMission` and move to the next mission, or to `outro` after `ferme`. 12. Press `E` on the completion target to call `completeMission` and move to the next mission, or to `outro` after `ferme`.
The older debug repair sandbox still exists in the physics test scene, but the production path now starts from the reusable `RepairGame` component.
## Why It Matters ## Why It Matters
This feature validates the core repair fantasy before a full mission system exists. It tests whether repair objects, physical proximity, model selection, audio feedback, and exploded model visualization can work together in the 3D scene. This feature validates the repair loop before a full mission system exists. It tests whether repair objects, physical proximity, model selection, audio feedback, and exploded model visualization can work together in the 3D scene.
## Current Behavior ## Current Behavior
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. When the player is close enough, the existing case model floats upward and rotates gently to signal interactivity. 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 existing case model floats upward and rotates gently to signal interactivity.
In `inspected`, `RepairGame` can also move to `fragmented`. The player can use the interaction key or hold both fists closed for one second. The hand-tracking path is state-based, so it does not depend on being inside a local object interaction radius. In `inspected`, `RepairGame` can also move to `fragmented`. The player can use the interaction key or hold both fists closed for one second. The hand-tracking path 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`, a blue scan visual and the `cassé.webm` prompt are shown before the flow advances to `repairing`. In `repairing`, the case opens, several grabbable replacement parts appear, and the install target only validates the configured correct part for the active mission. In `done`, the repaired object remains visible with a completion target that advances the global mission progression. In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, a blue scan visual and the `cassé.webm` prompt are shown before the flow advances to `repairing`. In `repairing`, the case opens, several grabbable replacement parts appear, and the install target only validates the configured correct part for the active mission. In `done`, the repaired object remains visible with a completion target that advances the global mission progression.
Repair module slots and model-selection behavior still exist in the debug prototype. They can be migrated into the reusable repair flow in later steps if the repair interaction needs more depth.
## Key Files ## Key Files
- `src/world/debug/TestMap.tsx` mounts the repair-game prototype in the debug physics scene.
- `src/world/GameStageContent.tsx` mounts production `RepairGame` instances for `bike`, `pylone`, and `ferme`. - `src/world/GameStageContent.tsx` mounts production `RepairGame` instances for `bike`, `pylone`, and `ferme`.
- `src/components/three/gameplay/RepairCompletionStep.tsx` renders the final repaired object, completion target, and mission UI prompt. - `src/components/three/gameplay/RepairCompletionStep.tsx` renders the final repaired object, completion target, and mission UI prompt.
- `src/components/three/gameplay/RepairGame.tsx` composes the reusable production repair flow. - `src/components/three/gameplay/RepairGame.tsx` composes the reusable production repair flow.
@@ -53,14 +48,10 @@ Repair module slots and model-selection behavior still exist in the debug protot
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` keyboard and hand-tracking input. - `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` keyboard and hand-tracking input.
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store. - `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture. - `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
- `src/components/three/gameplay/RepairGameZone.tsx` composes the repair-game zone.
- `src/components/three/gameplay/RepairCaseObject.tsx` connects the repair case to trigger interaction and audio.
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model. - `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model.
- `src/components/three/gameplay/RepairModuleSlot.tsx` renders repair slots and model selection behavior.
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization. - `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
- `src/data/gameplay/repairCaseConfig.ts` stores repair case model, sound, and animation constants. - `src/data/gameplay/repairCaseConfig.ts` stores repair case model, sound, and animation constants.
- `src/data/gameplay/repairGameConfig.ts` stores repair zone and slot positions. - `src/data/gameplay/repairGameConfig.ts` stores repair flow timing constants.
- `src/data/gameplay/repairGameModelCatalog.ts` stores selectable repair models.
- `src/data/gameplay/repairMissions.ts` stores reusable repair mission config for `bike`, `pylone`, and `ferme`. - `src/data/gameplay/repairMissions.ts` stores reusable repair mission config for `bike`, `pylone`, and `ferme`.
- `src/managers/stores/useGameStore.ts` stores mission progression state and generic mission step helpers. - `src/managers/stores/useGameStore.ts` stores mission progression state and generic mission step helpers.
@@ -11,6 +11,8 @@ import {
REPAIR_CASE_FLOAT_UP_SPEED, REPAIR_CASE_FLOAT_UP_SPEED,
REPAIR_CASE_LID_NODE_NAME, REPAIR_CASE_LID_NODE_NAME,
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES, REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
REPAIR_CASE_POP_DURATION,
REPAIR_CASE_POP_Y_OFFSET,
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES, REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
REPAIR_CASE_ROTATION_RESET_SPEED, REPAIR_CASE_ROTATION_RESET_SPEED,
} from "@/data/gameplay/repairCaseConfig"; } from "@/data/gameplay/repairCaseConfig";
@@ -55,16 +57,30 @@ export function RepairCaseModel({
const floatHeight = useRef(0); const floatHeight = useRef(0);
const animationActiveRef = useRef(false); const animationActiveRef = useRef(false);
const phase = useRef({ x: 0, y: 0, z: 0 }); const phase = useRef({ x: 0, y: 0, z: 0 });
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
const initialOpen = useRef(open); const initialOpen = useRef(open);
const openedRotationZ = useRef(0); const openedRotationZ = useRef(0);
const parsedScale = toVector3Scale(scale); const parsedScale = toVector3Scale(scale);
useEffect(() => { useEffect(() => {
const popAnimation = pop.current;
phase.current = { phase.current = {
x: Math.random() * Math.PI * 2, x: Math.random() * Math.PI * 2,
y: Math.random() * Math.PI * 2, y: Math.random() * Math.PI * 2,
z: Math.random() * Math.PI * 2, z: Math.random() * Math.PI * 2,
}; };
gsap.to(popAnimation, {
scale: 1,
yOffset: 0,
duration: REPAIR_CASE_POP_DURATION,
ease: "back.out(1.7)",
});
return () => {
gsap.killTweensOf(popAnimation);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -119,7 +135,12 @@ export function RepairCaseModel({
floatSpeed, floatSpeed,
delta, delta,
); );
group.position.y = position[1] + floatHeight.current; group.position.y = position[1] + floatHeight.current + pop.current.yOffset;
group.scale.set(
parsedScale[0] * pop.current.scale,
parsedScale[1] * pop.current.scale,
parsedScale[2] * pop.current.scale,
);
animationActiveRef.current = isNear; animationActiveRef.current = isNear;
@@ -158,12 +179,7 @@ export function RepairCaseModel({
}); });
return ( return (
<group <group ref={groupRef} position={position} rotation={rotation} scale={0.001}>
ref={groupRef}
position={position}
rotation={rotation}
scale={parsedScale}
>
<primitive object={model} /> <primitive object={model} />
</group> </group>
); );
@@ -1,102 +0,0 @@
import type { ReactNode } from "react";
import { Component } from "react";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { RepairCaseModel } from "@/components/three/gameplay/RepairCaseModel";
import {
REPAIR_CASE_MODEL_PATH,
REPAIR_CASE_OPEN_SOUND_PATH,
} from "@/data/gameplay/repairCaseConfig";
import { AudioManager } from "@/managers/AudioManager";
import type { Vector3Tuple } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
interface RepairCaseErrorBoundaryProps {
children: ReactNode;
}
interface RepairCaseErrorBoundaryState {
hasError: boolean;
}
class RepairCaseErrorBoundary extends Component<
RepairCaseErrorBoundaryProps,
RepairCaseErrorBoundaryState
> {
constructor(props: RepairCaseErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): RepairCaseErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
logModelLoadError(
{
modelPath: REPAIR_CASE_MODEL_PATH,
scope: "RepairCaseObject",
position: [0, -0.45, 0],
scale: 1.5,
},
error,
);
}
render(): ReactNode {
if (this.state.hasError) {
return <RepairCaseFallback />;
}
return this.props.children;
}
}
interface RepairCaseObjectProps {
position: Vector3Tuple;
open: boolean;
onInspect: () => void;
}
export function RepairCaseObject({
position,
open,
onInspect,
}: RepairCaseObjectProps): React.JSX.Element {
return (
<TriggerObject
position={position}
colliders="cuboid"
label={open ? "Mallette inspectée" : "Inspecter la mallette"}
onTrigger={() => {
if (open) return;
AudioManager.getInstance().playSound(REPAIR_CASE_OPEN_SOUND_PATH);
onInspect();
}}
>
<RepairCaseErrorBoundary>
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
open={open}
position={[0, -0.45, 0]}
scale={1.5}
/>
</RepairCaseErrorBoundary>
</TriggerObject>
);
}
function RepairCaseFallback(): React.JSX.Element {
return (
<group position={[0, -0.25, 0]}>
<mesh castShadow receiveShadow>
<boxGeometry args={[1.5, 0.5, 1]} />
<meshStandardMaterial color="#2563eb" roughness={0.55} />
</mesh>
<mesh position={[0, 0.35, -0.25]} castShadow receiveShadow>
<boxGeometry args={[1.5, 0.12, 0.65]} />
<meshStandardMaterial color="#1d4ed8" roughness={0.55} />
</mesh>
</group>
);
}
@@ -1,121 +0,0 @@
import { Text } from "@react-three/drei";
import { RepairCaseObject } from "@/components/three/gameplay/RepairCaseObject";
import { RepairModuleSlot } from "@/components/three/gameplay/RepairModuleSlot";
import {
REPAIR_GAME_MODULE_SLOTS,
REPAIR_GAME_ZONE_LABEL,
REPAIR_GAME_ZONE_ORIGIN,
REPAIR_GAME_ZONE_RADIUS,
} from "@/data/gameplay/repairGameConfig";
import { useGameStore } from "@/managers/stores/useGameStore";
const CASE_CLOSED_STEPS = new Set(["locked", "waiting"]);
export function RepairGameZone(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
const bikeStep = useGameStore((state) => state.bike.currentStep);
const setMainState = useGameStore((state) => state.setMainState);
const setBikeState = useGameStore((state) => state.setBikeState);
const caseOpen = !CASE_CLOSED_STEPS.has(bikeStep);
const slotsDisabled = !caseOpen;
const inspectRepairCase = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (CASE_CLOSED_STEPS.has(bikeStep)) {
setBikeState({ currentStep: "inspected" });
}
};
const markModelSelected = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (bikeStep === "inspected") {
setBikeState({ currentStep: "fragmented" });
}
};
const markModuleSplit = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (bikeStep === "fragmented") {
setBikeState({ currentStep: "scanning" });
}
};
return (
<group>
<mesh
position={[
REPAIR_GAME_ZONE_ORIGIN[0],
0.025,
REPAIR_GAME_ZONE_ORIGIN[2],
]}
rotation={[-Math.PI / 2, 0, 0]}
>
<ringGeometry
args={[REPAIR_GAME_ZONE_RADIUS - 0.08, REPAIR_GAME_ZONE_RADIUS, 96]}
/>
<meshBasicMaterial color="#38bdf8" transparent opacity={0.72} />
</mesh>
<mesh
position={[
REPAIR_GAME_ZONE_ORIGIN[0],
0.02,
REPAIR_GAME_ZONE_ORIGIN[2],
]}
rotation={[-Math.PI / 2, 0, 0]}
>
<circleGeometry args={[REPAIR_GAME_ZONE_RADIUS, 96]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
</mesh>
<Text
position={[
REPAIR_GAME_ZONE_ORIGIN[0],
3.1,
REPAIR_GAME_ZONE_ORIGIN[2] - 1.8,
]}
rotation={[0, 0, 0]}
fontSize={0.55}
maxWidth={5.5}
textAlign="center"
anchorX="center"
anchorY="middle"
color="#f8fafc"
outlineWidth={0.025}
outlineColor="#0f172a"
>
{REPAIR_GAME_ZONE_LABEL}
</Text>
<RepairCaseObject
position={REPAIR_GAME_ZONE_ORIGIN}
open={caseOpen}
onInspect={inspectRepairCase}
/>
{REPAIR_GAME_MODULE_SLOTS.map((slot) => (
<RepairModuleSlot
key={slot.label}
label={slot.label}
position={[
REPAIR_GAME_ZONE_ORIGIN[0] + slot.offset[0],
REPAIR_GAME_ZONE_ORIGIN[1] + slot.offset[1],
REPAIR_GAME_ZONE_ORIGIN[2] + slot.offset[2],
]}
disabled={slotsDisabled}
onModelSelected={markModelSelected}
onSplit={markModuleSplit}
/>
))}
</group>
);
}
@@ -1,113 +0,0 @@
import { Html } from "@react-three/drei";
import { useCallback, useState } from "react";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_GAME_MODEL_CATALOG } from "@/data/gameplay/repairGameModelCatalog";
import type { ModelCatalogItem } from "@/data/gameplay/repairGameModelCatalog";
import { useModelSelection } from "@/hooks/gameplay/useModelSelection";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairModuleSlotProps {
position: Vector3Tuple;
label: string;
disabled?: boolean;
onModelSelected?: () => void;
onSplit?: () => void;
}
export function RepairModuleSlot({
position,
label,
disabled = false,
onModelSelected,
onSplit,
}: RepairModuleSlotProps): React.JSX.Element {
const [selectedModel, setSelectedModel] = useState<ModelCatalogItem | null>(
null,
);
const [split, setSplit] = useState(false);
const handleSelect = useCallback(
(model: ModelCatalogItem) => {
setSelectedModel(model);
setSplit(false);
onModelSelected?.();
},
[onModelSelected],
);
const selection = useModelSelection(REPAIR_GAME_MODEL_CATALOG, handleSelect);
const triggerLabel = disabled
? "Ouvrir la mallette d'abord"
: selectedModel
? split
? `Réassembler ${label}`
: `Démonter ${label}`
: `Choisir ${label}`;
return (
<group>
<TriggerObject
position={position}
colliders="cuboid"
label={triggerLabel}
onTrigger={() => {
if (disabled) return;
if (selectedModel) {
setSplit((value) => {
const nextSplit = !value;
if (nextSplit) {
onSplit?.();
}
return nextSplit;
});
return;
}
selection.open();
}}
>
{selectedModel ? (
<ExplodableModel
modelPath={selectedModel.path}
split={split}
position={[0, -0.35, 0]}
scale={0.45}
/>
) : (
<mesh castShadow receiveShadow>
<boxGeometry args={[1, 0.18, 1]} />
<meshStandardMaterial
color="#38bdf8"
emissive="#082f49"
roughness={0.55}
/>
</mesh>
)}
</TriggerObject>
{selection.isOpen ? (
<Html position={[position[0], position[1] + 1.2, position[2]]} center>
<div className="model-selector-panel">
<strong>{label}</strong>
<span>Fleches: choisir</span>
<span>E/Enter: valider</span>
<ul>
{REPAIR_GAME_MODEL_CATALOG.map((model, index) => (
<li
key={model.path}
className={
index === selection.selectedIndex
? "is-selected"
: undefined
}
>
{model.name}
</li>
))}
</ul>
</div>
</Html>
) : null}
</group>
);
}
@@ -1,15 +1,12 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Component } from "react"; import { Component } from "react";
import { SimpleModel } from "@/components/three/models/SimpleModel"; import { SimpleModel } from "@/components/three/models/SimpleModel";
import type { Vector3Scale, Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { logModelLoadError } from "@/utils/three/modelLoadLogger";
interface RepairObjectModelProps { interface RepairObjectModelProps extends ModelTransformProps {
label: string; label: string;
modelPath: string; modelPath: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Scale;
} }
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps { interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
@@ -69,7 +69,7 @@ const HAND_HIT_OFFSETS: Array<[number, number]> = [
]; ];
function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark { function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
const landmarks = hand.landmarks ?? []; const landmarks = hand.landmarks;
if (landmarks.length === 0) { if (landmarks.length === 0) {
return { x: hand.x, y: hand.y, z: hand.z }; return { x: hand.x, y: hand.y, z: hand.z };
} }
@@ -7,15 +7,12 @@ import {
type AnimatedModelContextValue, type AnimatedModelContextValue,
} from "@/components/three/models/useAnimatedModel"; } from "@/components/three/models/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
export interface AnimatedModelConfig { export interface AnimatedModelConfig extends ModelTransformProps {
modelPath: string; modelPath: string;
animations?: string[]; animations?: string[];
defaultAnimation?: string; defaultAnimation?: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
fadeDuration?: number; fadeDuration?: number;
speed?: number; speed?: number;
autoPlay?: boolean; autoPlay?: boolean;
+2 -5
View File
@@ -1,12 +1,9 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
export interface SimpleModelConfig { export interface SimpleModelConfig extends ModelTransformProps {
modelPath: string; modelPath: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
castShadow?: boolean; castShadow?: boolean;
receiveShadow?: boolean; receiveShadow?: boolean;
} }
@@ -1,4 +1,4 @@
import { createContext, useContext } from "react"; import { createContext } from "react";
export interface AnimatedModelContextValue { export interface AnimatedModelContextValue {
play: (name: string, fade?: number) => void; play: (name: string, fade?: number) => void;
@@ -12,12 +12,3 @@ export interface AnimatedModelContextValue {
export const AnimatedModelContext = export const AnimatedModelContext =
createContext<AnimatedModelContextValue | null>(null); createContext<AnimatedModelContextValue | null>(null);
export function useAnimatedModel(): AnimatedModelContextValue {
const context = useContext(AnimatedModelContext);
if (!context) {
throw new Error("useAnimatedModel must be used inside AnimatedModel");
}
return context;
}
+1 -1
View File
@@ -47,7 +47,7 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
return ( return (
<svg className="hand-tracking-visualizer" aria-hidden="true"> <svg className="hand-tracking-visualizer" aria-hidden="true">
{hands.map((hand, handIndex) => { {hands.map((hand, handIndex) => {
const landmarks = hand.landmarks ?? []; const landmarks = hand.landmarks;
if (landmarks.length === 0) return null; if (landmarks.length === 0) return null;
const color = hand.isFist ? "#facc15" : "#38bdf8"; const color = hand.isFist ? "#facc15" : "#38bdf8";
-1
View File
@@ -90,7 +90,6 @@ export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
cameraRef.current.position.add(direction); cameraRef.current.position.add(direction);
} }
// Space moves up; Shift moves down.
if (keys.current["Space"]) { if (keys.current["Space"]) {
cameraRef.current.position.y += verticalSpeed * delta; cameraRef.current.position.y += verticalSpeed * delta;
} }
+2 -2
View File
@@ -145,7 +145,7 @@ Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il
- \`src/components/three/models/\` contient les helpers de modèles réutilisables comme \`ExplodableModel\`. - \`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/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/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 de réparation, la zone debug repair et les slots de modules. - \`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\`. - \`src/components/three/world/\` contient les objets world/environnement réutilisables comme \`SkyModel\`.
## Limites actuelles ## Limites actuelles
@@ -406,7 +406,7 @@ Overlays actuels :
## Prochaines étapes ## Prochaines étapes
La prochaine étape naturelle est de déplacer la validation de réparation depuis cette interaction locale vers des données de mission plus riches quand chaque mission aura des nodes de modules cassés, des assets de remplacement dédiés et des beats narratifs de complétion. 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 featuresFr = `# Fonctionnalités implémentées export const featuresFr = `# Fonctionnalités implémentées
+2
View File
@@ -6,6 +6,8 @@ export const REPAIR_CASE_LID_NODE_NAME = "partiesup";
export const REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES = 0; export const REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES = 0;
export const REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES = 115; export const REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES = 115;
export const REPAIR_CASE_ANIMATION_DURATION = 0.8; export const REPAIR_CASE_ANIMATION_DURATION = 0.8;
export const REPAIR_CASE_POP_DURATION = 0.45;
export const REPAIR_CASE_POP_Y_OFFSET = -0.25;
export const REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE = 5; export const REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE = 5;
export const REPAIR_CASE_FLOAT_HEIGHT = 1; export const REPAIR_CASE_FLOAT_HEIGHT = 1;
-11
View File
@@ -1,14 +1,3 @@
import type { Vector3Tuple } from "@/types/three/three";
export const REPAIR_GAME_ZONE_ORIGIN: Vector3Tuple = [10, 0.4, -8];
export const REPAIR_GAME_ZONE_RADIUS = 4.2;
export const REPAIR_GAME_ZONE_LABEL = "Pack de Relance Feature";
export const REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS = 1; export const REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS = 1;
export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4; export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4;
export const REPAIR_SCAN_SEQUENCE_SECONDS = 4; export const REPAIR_SCAN_SEQUENCE_SECONDS = 4;
export const REPAIR_GAME_MODULE_SLOTS = [
{ label: "Module A", offset: [-2.2, 0, 2.2] },
{ label: "Module B", offset: [0, 0, 2.6] },
{ label: "Module C", offset: [2.2, 0, 2.2] },
] satisfies Array<{ label: string; offset: Vector3Tuple }>;
@@ -1,29 +0,0 @@
export interface ModelCatalogItem {
name: string;
path: string;
}
export const REPAIR_GAME_MODEL_CATALOG: ModelCatalogItem[] = [
{ name: "Electricienne", path: "/models/elecsimple/model.gltf" },
{
name: "Electricienne complete",
path: "/models/electricienne_animated/model.gltf",
},
{ name: "Eolienne", path: "/models/eolienne/model.gltf" },
{ name: "Fermier", path: "/models/fermier/model.gltf" },
{ name: "Galet", path: "/models/galet/model.gltf" },
{ name: "Gant", path: "/models/gant/model.gltf" },
{ name: "Gants", path: "/models/gants/model.gltf" },
{ name: "Gerant", path: "/models/gerant/model.gltf" },
{ name: "Immeuble", path: "/models/immeuble1/model.gltf" },
{ name: "Kit de relance", path: "/models/packderelance/model.gltf" },
{ name: "La Fabrik", path: "/models/lafabrik/model.gltf" },
{ name: "Maison", path: "/models/maison1/model.gltf" },
{ name: "Map", path: "/models/map/model.gltf" },
{ name: "Perso principal", path: "/models/persoprincipal/model.gltf" },
{ name: "Pylone", path: "/models/pylone/model.gltf" },
{ name: "Refroidisseur", path: "/models/refroidisseur/model.gltf" },
{ name: "Sapin", path: "/models/sapin/model.gltf" },
{ name: "Talkie", path: "/models/talkie/model.gltf" },
{ name: "Terrain", path: "/models/terrain/model.gltf" },
];
-79
View File
@@ -1,79 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import type { ModelCatalogItem } from "@/data/gameplay/repairGameModelCatalog";
interface UseModelSelectionResult {
isOpen: boolean;
selectedIndex: number;
selectedModel: ModelCatalogItem;
open: () => void;
close: () => void;
}
export function useModelSelection(
models: ModelCatalogItem[],
onSelect: (model: ModelCatalogItem) => void,
): UseModelSelectionResult {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const firstModel = models[0];
if (!firstModel) {
throw new Error("useModelSelection requires at least one model");
}
const selectedModel = models[selectedIndex] ?? firstModel;
const close = useCallback(() => setIsOpen(false), []);
const open = useCallback(() => setIsOpen(true), []);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent): void => {
const key = event.key.toLowerCase();
if (["arrowup", "arrowleft"].includes(key)) {
setSelectedIndex((index) =>
index === 0 ? models.length - 1 : index - 1,
);
event.preventDefault();
event.stopPropagation();
return;
}
if (["arrowdown", "arrowright"].includes(key)) {
setSelectedIndex((index) => (index + 1) % models.length);
event.preventDefault();
event.stopPropagation();
return;
}
if (key === "e" || key === "enter") {
onSelect(selectedModel);
close();
event.preventDefault();
event.stopPropagation();
return;
}
if (key === "escape") {
close();
event.preventDefault();
event.stopPropagation();
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
};
}, [close, isOpen, models, onSelect, selectedModel]);
return {
isOpen,
selectedIndex,
selectedModel,
open,
close,
};
}
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
HAND_TRACKING_CAMERA_TIMEOUT_MS,
HAND_TRACKING_FRAME_HEIGHT, HAND_TRACKING_FRAME_HEIGHT,
HAND_TRACKING_FRAME_WIDTH, HAND_TRACKING_FRAME_WIDTH,
HAND_TRACKING_TARGET_FPS, HAND_TRACKING_TARGET_FPS,
@@ -9,51 +8,22 @@ import {
convertBrowserHandResult, convertBrowserHandResult,
getBrowserHandLandmarker, getBrowserHandLandmarker,
} from "@/lib/handTracking/browserHandTracking"; } from "@/lib/handTracking/browserHandTracking";
import {
INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout,
} from "@/lib/handTracking/handTrackingSession";
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking"; import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
interface UseBrowserHandTrackingOptions { interface UseBrowserHandTrackingOptions {
enabled: boolean; enabled: boolean;
} }
const INITIAL_SNAPSHOT: HandTrackingSnapshot = {
hands: [],
status: "idle",
usageStatus: "inactive",
serverStatus: null,
error: null,
};
function getCameraStreamWithTimeout(
constraints: MediaStreamConstraints,
): Promise<MediaStream> {
let didTimeout = false;
const streamPromise = navigator.mediaDevices.getUserMedia(constraints);
const timeoutPromise = new Promise<never>((_, reject) => {
window.setTimeout(() => {
didTimeout = true;
reject(
new Error(
"Camera request timed out. Restart Arc or check camera permissions for localhost:5173.",
),
);
}, HAND_TRACKING_CAMERA_TIMEOUT_MS);
});
streamPromise.then((stream) => {
if (didTimeout) {
stream.getTracks().forEach((track) => track.stop());
}
});
return Promise.race([streamPromise, timeoutPromise]);
}
export function useBrowserHandTracking({ export function useBrowserHandTracking({
enabled, enabled,
}: UseBrowserHandTrackingOptions): HandTrackingSnapshot { }: UseBrowserHandTrackingOptions): HandTrackingSnapshot {
const [snapshot, setSnapshot] = const [snapshot, setSnapshot] = useState<HandTrackingSnapshot>(
useState<HandTrackingSnapshot>(INITIAL_SNAPSHOT); INITIAL_HAND_TRACKING_SNAPSHOT,
);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const intervalRef = useRef<number | null>(null); const intervalRef = useRef<number | null>(null);
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
HAND_TRACKING_CAMERA_TIMEOUT_MS,
HAND_TRACKING_FRAME_HEIGHT, HAND_TRACKING_FRAME_HEIGHT,
HAND_TRACKING_FRAME_WIDTH, HAND_TRACKING_FRAME_WIDTH,
HAND_TRACKING_JPEG_QUALITY, HAND_TRACKING_JPEG_QUALITY,
@@ -8,6 +7,10 @@ import {
HAND_TRACKING_TARGET_FPS, HAND_TRACKING_TARGET_FPS,
getHandTrackingWsUrl, getHandTrackingWsUrl,
} from "@/data/handTrackingConfig"; } from "@/data/handTrackingConfig";
import {
INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout,
} from "@/lib/handTracking/handTrackingSession";
import type { import type {
HandTrackingFrameMessage, HandTrackingFrameMessage,
HandTrackingHand, HandTrackingHand,
@@ -20,14 +23,6 @@ interface UseRemoteHandTrackingOptions {
websocketUrl?: string; websocketUrl?: string;
} }
const INITIAL_SNAPSHOT: HandTrackingSnapshot = {
hands: [],
status: "idle",
usageStatus: "inactive",
serverStatus: null,
error: null,
};
function getBase64Payload(dataUrl: string): string { function getBase64Payload(dataUrl: string): string {
return dataUrl.slice(dataUrl.indexOf(",") + 1); return dataUrl.slice(dataUrl.indexOf(",") + 1);
} }
@@ -84,38 +79,13 @@ function isHandTrackingServerMessage(
); );
} }
function getCameraStreamWithTimeout(
constraints: MediaStreamConstraints,
): Promise<MediaStream> {
let didTimeout = false;
const streamPromise = navigator.mediaDevices.getUserMedia(constraints);
const timeoutPromise = new Promise<never>((_, reject) => {
window.setTimeout(() => {
didTimeout = true;
reject(
new Error(
"Camera request timed out. Restart Arc or check camera permissions for localhost:5173.",
),
);
}, HAND_TRACKING_CAMERA_TIMEOUT_MS);
});
streamPromise.then((stream) => {
if (didTimeout) {
stream.getTracks().forEach((track) => track.stop());
}
});
return Promise.race([streamPromise, timeoutPromise]);
}
export function useRemoteHandTracking({ export function useRemoteHandTracking({
enabled, enabled,
websocketUrl = getHandTrackingWsUrl(), websocketUrl = getHandTrackingWsUrl(),
}: UseRemoteHandTrackingOptions): HandTrackingSnapshot { }: UseRemoteHandTrackingOptions): HandTrackingSnapshot {
const [snapshot, setSnapshot] = const [snapshot, setSnapshot] = useState<HandTrackingSnapshot>(
useState<HandTrackingSnapshot>(INITIAL_SNAPSHOT); INITIAL_HAND_TRACKING_SNAPSHOT,
);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
-40
View File
@@ -531,46 +531,6 @@ canvas {
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.55)); filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.55));
} }
/* Repair model selector UI */
.model-selector-panel {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 190px;
padding: 12px;
color: rgba(255, 255, 255, 0.92);
background: rgba(4, 7, 13, 0.88);
border: 1px solid rgba(56, 189, 248, 0.5);
border-radius: 8px;
font-size: 12px;
pointer-events: none;
user-select: none;
}
.model-selector-panel strong {
color: white;
font-size: 13px;
}
.model-selector-panel ul {
display: flex;
flex-direction: column;
gap: 3px;
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.model-selector-panel li {
padding: 3px 6px;
border-radius: 4px;
}
.model-selector-panel li.is-selected {
color: #020617;
background: #38bdf8;
}
/* Zustand game state debug UI */ /* Zustand game state debug UI */
.game-state-debug-panel { .game-state-debug-panel {
display: grid; display: grid;
@@ -0,0 +1,36 @@
import { HAND_TRACKING_CAMERA_TIMEOUT_MS } from "@/data/handTrackingConfig";
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
export const INITIAL_HAND_TRACKING_SNAPSHOT: HandTrackingSnapshot = {
hands: [],
status: "idle",
usageStatus: "inactive",
serverStatus: null,
error: null,
};
export function getCameraStreamWithTimeout(
constraints: MediaStreamConstraints,
): Promise<MediaStream> {
let didTimeout = false;
const streamPromise = navigator.mediaDevices.getUserMedia(constraints);
const timeoutPromise = new Promise<never>((_, reject) => {
window.setTimeout(() => {
didTimeout = true;
reject(
new Error(
"Camera request timed out. Restart Arc or check camera permissions for localhost:5173.",
),
);
}, HAND_TRACKING_CAMERA_TIMEOUT_MS);
});
streamPromise.then((stream) => {
if (didTimeout) {
stream.getTracks().forEach((track) => track.stop());
}
});
return Promise.race([streamPromise, timeoutPromise]);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { logger } from "@/utils/core/logger"; import { logger } from "@/utils/core/Logger";
interface PlaySoundOptions { interface PlaySoundOptions {
playbackRate?: number; playbackRate?: number;
+9 -8
View File
@@ -3,6 +3,11 @@ import type {
InteractableHandle, InteractableHandle,
InteractionSnapshot, InteractionSnapshot,
} from "@/types/interaction/interaction"; } from "@/types/interaction/interaction";
import { EventEmitter } from "@/utils/core/EventEmitter";
interface InteractionManagerEvents {
change: void;
}
export class InteractionManager { export class InteractionManager {
private static _instance: InteractionManager | null = null; private static _instance: InteractionManager | null = null;
@@ -18,7 +23,7 @@ export class InteractionManager {
holding: false, holding: false,
handHolding: false, handHolding: false,
}; };
private readonly _listeners = new Set<() => void>(); private readonly _events = new EventEmitter<InteractionManagerEvents>();
static getInstance(): InteractionManager { static getInstance(): InteractionManager {
if (!InteractionManager._instance) { if (!InteractionManager._instance) {
@@ -88,11 +93,7 @@ export class InteractionManager {
} }
subscribe(listener: () => void): () => void { subscribe(listener: () => void): () => void {
this._listeners.add(listener); return this._events.on("change", listener);
return () => {
this._listeners.delete(listener);
};
} }
destroy(): void { destroy(): void {
@@ -107,7 +108,7 @@ export class InteractionManager {
holding: false, holding: false,
handHolding: false, handHolding: false,
}; };
this._listeners.clear(); this._events.clear();
InteractionManager._instance = null; InteractionManager._instance = null;
} }
@@ -118,6 +119,6 @@ export class InteractionManager {
holding: this._holding, holding: this._holding,
handHolding: this._handHolding, handHolding: this._handHolding,
}; };
this._listeners.forEach((cb) => cb()); this._events.emit("change", undefined);
} }
} }
+23 -43
View File
@@ -19,6 +19,14 @@ function withDocsSuspense(
); );
} }
function createDocsRoute(
Component: React.LazyExoticComponent<React.ComponentType>,
): () => React.JSX.Element {
return function DocsRoute(): React.JSX.Element {
return withDocsSuspense(Component);
};
}
const LazyDocsLayout = lazyNamed( const LazyDocsLayout = lazyNamed(
() => import("@/components/docs/DocsLayout"), () => import("@/components/docs/DocsLayout"),
"DocsLayout", "DocsLayout",
@@ -64,46 +72,18 @@ const LazyDocsAnimationPage = lazyNamed(
"DocsAnimationPage", "DocsAnimationPage",
); );
export function DocsLayoutRoute(): React.JSX.Element { export const DocsLayoutRoute = createDocsRoute(LazyDocsLayout);
return withDocsSuspense(LazyDocsLayout); export const DocsReadmeRoute = createDocsRoute(LazyDocsReadmePage);
} export const DocsArchitectureRoute = createDocsRoute(LazyDocsArchitecturePage);
export const DocsTargetArchitectureRoute = createDocsRoute(
export function DocsReadmeRoute(): React.JSX.Element { LazyDocsTargetArchitecturePage,
return withDocsSuspense(LazyDocsReadmePage); );
} export const DocsTechnicalEditorRoute = createDocsRoute(
LazyDocsTechnicalEditorPage,
export function DocsArchitectureRoute(): React.JSX.Element { );
return withDocsSuspense(LazyDocsArchitecturePage); export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage);
} export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage);
export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);
export function DocsTargetArchitectureRoute(): React.JSX.Element { export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage);
return withDocsSuspense(LazyDocsTargetArchitecturePage); export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage);
} export const DocsAnimationRoute = createDocsRoute(LazyDocsAnimationPage);
export function DocsTechnicalEditorRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsTechnicalEditorPage);
}
export function DocsHandTrackingRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsHandTrackingPage);
}
export function DocsZustandRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsZustandPage);
}
export function DocsFeaturesRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsFeaturesPage);
}
export function DocsMainFeatureRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsMainFeaturePage);
}
export function DocsEditorRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsEditorPage);
}
export function DocsAnimationRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsAnimationPage);
}
+6 -16
View File
@@ -1,32 +1,22 @@
type Listener<TPayload> = (payload: TPayload) => void; type Listener<TPayload> = (payload: TPayload) => void;
type ListenerMap<TEvents extends Record<string, unknown>> = { type ListenerMap<TEvents extends object> = {
[TKey in keyof TEvents]?: Set<Listener<TEvents[TKey]>>; [TKey in keyof TEvents]?: Set<Listener<TEvents[TKey]>>;
}; };
function getListeners< export class EventEmitter<TEvents extends object> {
TEvents extends Record<string, unknown>,
TKey extends keyof TEvents,
>(
map: ListenerMap<TEvents>,
key: TKey,
): Set<Listener<TEvents[TKey]>> | undefined {
return map[key] as Set<Listener<TEvents[TKey]>> | undefined;
}
export class EventEmitter<TEvents extends Record<string, unknown>> {
private readonly listeners: ListenerMap<TEvents> = {}; private readonly listeners: ListenerMap<TEvents> = {};
on<TKey extends keyof TEvents>( on<TKey extends keyof TEvents>(
event: TKey, event: TKey,
listener: Listener<TEvents[TKey]>, listener: Listener<TEvents[TKey]>,
): () => void { ): () => void {
const existing = getListeners(this.listeners, event); const existing = this.listeners[event];
if (existing) { if (existing) {
existing.add(listener); existing.add(listener);
} else { } else {
this.listeners[event] = new Set([listener]) as ListenerMap<TEvents>[TKey]; this.listeners[event] = new Set([listener]);
} }
return () => { return () => {
@@ -38,7 +28,7 @@ export class EventEmitter<TEvents extends Record<string, unknown>> {
event: TKey, event: TKey,
listener: Listener<TEvents[TKey]>, listener: Listener<TEvents[TKey]>,
): void { ): void {
const currentListeners = getListeners(this.listeners, event); const currentListeners = this.listeners[event];
if (!currentListeners) { if (!currentListeners) {
return; return;
@@ -52,7 +42,7 @@ export class EventEmitter<TEvents extends Record<string, unknown>> {
} }
emit<TKey extends keyof TEvents>(event: TKey, payload: TEvents[TKey]): void { emit<TKey extends keyof TEvents>(event: TKey, payload: TEvents[TKey]): void {
const currentListeners = getListeners(this.listeners, event); const currentListeners = this.listeners[event];
if (!currentListeners) { if (!currentListeners) {
return; return;
-50
View File
@@ -1,50 +0,0 @@
type SizeSnapshot = {
width: number;
height: number;
pixelRatio: number;
};
type SizeListener = (snapshot: SizeSnapshot) => void;
export class Sizes {
private snapshot: SizeSnapshot;
private readonly listeners = new Set<SizeListener>();
private readonly handleResize = (): void => {
this.snapshot = Sizes.readWindow();
this.emit();
};
constructor() {
this.snapshot = Sizes.readWindow();
window.addEventListener("resize", this.handleResize);
}
subscribe(listener: SizeListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getSnapshot(): SizeSnapshot {
return this.snapshot;
}
destroy(): void {
window.removeEventListener("resize", this.handleResize);
this.listeners.clear();
}
private emit(): void {
this.listeners.forEach((listener) => listener(this.snapshot));
}
private static readWindow(): SizeSnapshot {
return {
width: window.innerWidth,
height: window.innerHeight,
pixelRatio: Math.min(window.devicePixelRatio, 2),
};
}
}
-42
View File
@@ -1,42 +0,0 @@
type TickListener = (delta: number, elapsed: number) => void;
export class Time {
private readonly listeners = new Set<TickListener>();
private animationFrameId = 0;
private lastTick = performance.now();
private elapsed = 0;
constructor() {
this.tick = this.tick.bind(this);
this.animationFrameId = window.requestAnimationFrame(this.tick);
}
subscribe(listener: TickListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getElapsed(): number {
return this.elapsed;
}
destroy(): void {
window.cancelAnimationFrame(this.animationFrameId);
this.listeners.clear();
}
private tick(now: number): void {
const delta = (now - this.lastTick) / 1000;
this.lastTick = now;
this.elapsed += delta;
this.listeners.forEach((listener) => {
listener(delta, this.elapsed);
});
this.animationFrameId = window.requestAnimationFrame(this.tick);
}
}
+8 -7
View File
@@ -1,6 +1,7 @@
import GUI from "lil-gui"; import GUI from "lil-gui";
import type { CameraMode, SceneMode } from "@/types/debug/debug"; import type { CameraMode, SceneMode } from "@/types/debug/debug";
import type { HandTrackingSource } from "@/types/handTracking/handTracking"; import type { HandTrackingSource } from "@/types/handTracking/handTracking";
import { EventEmitter } from "@/utils/core/EventEmitter";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled"; import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls"; const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls";
@@ -10,6 +11,10 @@ interface StoredDebugControls {
sceneMode: SceneMode; sceneMode: SceneMode;
} }
interface DebugEvents {
change: void;
}
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;
} }
@@ -48,9 +53,9 @@ export class Debug {
public readonly active: boolean; public readonly active: boolean;
private readonly gui: GUI | null; private readonly gui: GUI | null;
private readonly events = new EventEmitter<DebugEvents>();
private readonly folders = new Map<string, GUI>(); private readonly folders = new Map<string, GUI>();
private readonly folderRefCounts = new Map<string, number>(); private readonly folderRefCounts = new Map<string, number>();
private readonly listeners = new Set<() => void>();
private readonly controls: { private readonly controls: {
cameraMode: CameraMode; cameraMode: CameraMode;
handTrackingSource: HandTrackingSource; handTrackingSource: HandTrackingSource;
@@ -182,11 +187,7 @@ export class Debug {
} }
subscribe(listener: () => void): () => void { subscribe(listener: () => void): () => void {
this.listeners.add(listener); return this.events.on("change", listener);
return () => {
this.listeners.delete(listener);
};
} }
getCameraMode(): CameraMode { getCameraMode(): CameraMode {
@@ -228,7 +229,7 @@ export class Debug {
} }
private emit(): void { private emit(): void {
this.listeners.forEach((listener) => listener()); this.events.emit("change", undefined);
} }
private saveAndEmit(): void { private saveAndEmit(): void {
+3 -3
View File
@@ -1,12 +1,12 @@
import { logger } from "@/utils/core/logger"; import { logger } from "@/utils/core/Logger";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Scale, Vector3Tuple } from "@/types/three/three";
export interface ModelLoadLogContext { export interface ModelLoadLogContext {
modelPath: string; modelPath: string;
scope: string; scope: string;
position?: Vector3Tuple | undefined; position?: Vector3Tuple | undefined;
rotation?: Vector3Tuple | undefined; rotation?: Vector3Tuple | undefined;
scale?: Vector3Tuple | number | undefined; scale?: Vector3Scale | undefined;
} }
interface LoadedModelInfo { interface LoadedModelInfo {
+1 -1
View File
@@ -4,7 +4,7 @@ import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode"; import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
import { logger } from "@/utils/core/logger"; import { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import type { MapNode } from "@/types/editor/editor"; import type { MapNode } from "@/types/editor/editor";
-3
View File
@@ -2,7 +2,6 @@ import type { ReactNode } from "react";
import { Component, useRef } from "react"; import { Component, useRef } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { RepairGameZone } from "@/components/three/gameplay/RepairGameZone";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { AnimatedModel } from "@/components/three/models/AnimatedModel"; import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
@@ -133,8 +132,6 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
/> />
</mesh> </mesh>
</TriggerObject> </TriggerObject>
<RepairGameZone />
</Physics> </Physics>
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}> <ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>