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
@@ -11,6 +11,8 @@ import {
REPAIR_CASE_FLOAT_UP_SPEED,
REPAIR_CASE_LID_NODE_NAME,
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
REPAIR_CASE_POP_DURATION,
REPAIR_CASE_POP_Y_OFFSET,
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
REPAIR_CASE_ROTATION_RESET_SPEED,
} from "@/data/gameplay/repairCaseConfig";
@@ -55,16 +57,30 @@ export function RepairCaseModel({
const floatHeight = useRef(0);
const animationActiveRef = useRef(false);
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 openedRotationZ = useRef(0);
const parsedScale = toVector3Scale(scale);
useEffect(() => {
const popAnimation = pop.current;
phase.current = {
x: Math.random() * Math.PI * 2,
y: 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(() => {
@@ -119,7 +135,12 @@ export function RepairCaseModel({
floatSpeed,
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;
@@ -158,12 +179,7 @@ export function RepairCaseModel({
});
return (
<group
ref={groupRef}
position={position}
rotation={rotation}
scale={parsedScale}
>
<group ref={groupRef} position={position} rotation={rotation} scale={0.001}>
<primitive object={model} />
</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 { Component } from "react";
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";
interface RepairObjectModelProps {
interface RepairObjectModelProps extends ModelTransformProps {
label: string;
modelPath: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Scale;
}
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
@@ -69,7 +69,7 @@ const HAND_HIT_OFFSETS: Array<[number, number]> = [
];
function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
const landmarks = hand.landmarks ?? [];
const landmarks = hand.landmarks;
if (landmarks.length === 0) {
return { x: hand.x, y: hand.y, z: hand.z };
}
@@ -7,15 +7,12 @@ import {
type AnimatedModelContextValue,
} from "@/components/three/models/useAnimatedModel";
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;
animations?: string[];
defaultAnimation?: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
fadeDuration?: number;
speed?: number;
autoPlay?: boolean;
+2 -5
View File
@@ -1,12 +1,9 @@
import { useMemo } from "react";
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;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
castShadow?: boolean;
receiveShadow?: boolean;
}
@@ -1,4 +1,4 @@
import { createContext, useContext } from "react";
import { createContext } from "react";
export interface AnimatedModelContextValue {
play: (name: string, fade?: number) => void;
@@ -12,12 +12,3 @@ export interface AnimatedModelContextValue {
export const AnimatedModelContext =
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 (
<svg className="hand-tracking-visualizer" aria-hidden="true">
{hands.map((hand, handIndex) => {
const landmarks = hand.landmarks ?? [];
const landmarks = hand.landmarks;
if (landmarks.length === 0) return null;
const color = hand.isFist ? "#facc15" : "#38bdf8";
-1
View File
@@ -90,7 +90,6 @@ export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
cameraRef.current.position.add(direction);
}
// Space moves up; Shift moves down.
if (keys.current["Space"]) {
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/interaction/\` contient les wrappers d'interaction réutilisables comme \`InteractableObject\`, \`TriggerObject\` et \`GrabbableObject\`.
- \`src/components/three/handTracking/\` contient les modèles debug R3F liés au hand tracking, comme les gants.
- \`src/components/three/gameplay/\` contient les composants de gameplay de réparation : le flow de production réutilisable \`RepairGame\`, la mallette 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\`.
## Limites actuelles
@@ -406,7 +406,7 @@ Overlays actuels :
## 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
+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_OPEN_ROTATION_OFFSET_DEGREES = 115;
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_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_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 {
HAND_TRACKING_CAMERA_TIMEOUT_MS,
HAND_TRACKING_FRAME_HEIGHT,
HAND_TRACKING_FRAME_WIDTH,
HAND_TRACKING_TARGET_FPS,
@@ -9,51 +8,22 @@ import {
convertBrowserHandResult,
getBrowserHandLandmarker,
} from "@/lib/handTracking/browserHandTracking";
import {
INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout,
} from "@/lib/handTracking/handTrackingSession";
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
interface UseBrowserHandTrackingOptions {
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({
enabled,
}: UseBrowserHandTrackingOptions): HandTrackingSnapshot {
const [snapshot, setSnapshot] =
useState<HandTrackingSnapshot>(INITIAL_SNAPSHOT);
const [snapshot, setSnapshot] = useState<HandTrackingSnapshot>(
INITIAL_HAND_TRACKING_SNAPSHOT,
);
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const intervalRef = useRef<number | null>(null);
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
import {
HAND_TRACKING_CAMERA_TIMEOUT_MS,
HAND_TRACKING_FRAME_HEIGHT,
HAND_TRACKING_FRAME_WIDTH,
HAND_TRACKING_JPEG_QUALITY,
@@ -8,6 +7,10 @@ import {
HAND_TRACKING_TARGET_FPS,
getHandTrackingWsUrl,
} from "@/data/handTrackingConfig";
import {
INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout,
} from "@/lib/handTracking/handTrackingSession";
import type {
HandTrackingFrameMessage,
HandTrackingHand,
@@ -20,14 +23,6 @@ interface UseRemoteHandTrackingOptions {
websocketUrl?: string;
}
const INITIAL_SNAPSHOT: HandTrackingSnapshot = {
hands: [],
status: "idle",
usageStatus: "inactive",
serverStatus: null,
error: null,
};
function getBase64Payload(dataUrl: string): string {
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({
enabled,
websocketUrl = getHandTrackingWsUrl(),
}: UseRemoteHandTrackingOptions): HandTrackingSnapshot {
const [snapshot, setSnapshot] =
useState<HandTrackingSnapshot>(INITIAL_SNAPSHOT);
const [snapshot, setSnapshot] = useState<HandTrackingSnapshot>(
INITIAL_HAND_TRACKING_SNAPSHOT,
);
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | 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));
}
/* 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 */
.game-state-debug-panel {
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 {
playbackRate?: number;
+9 -8
View File
@@ -3,6 +3,11 @@ import type {
InteractableHandle,
InteractionSnapshot,
} from "@/types/interaction/interaction";
import { EventEmitter } from "@/utils/core/EventEmitter";
interface InteractionManagerEvents {
change: void;
}
export class InteractionManager {
private static _instance: InteractionManager | null = null;
@@ -18,7 +23,7 @@ export class InteractionManager {
holding: false,
handHolding: false,
};
private readonly _listeners = new Set<() => void>();
private readonly _events = new EventEmitter<InteractionManagerEvents>();
static getInstance(): InteractionManager {
if (!InteractionManager._instance) {
@@ -88,11 +93,7 @@ export class InteractionManager {
}
subscribe(listener: () => void): () => void {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
return this._events.on("change", listener);
}
destroy(): void {
@@ -107,7 +108,7 @@ export class InteractionManager {
holding: false,
handHolding: false,
};
this._listeners.clear();
this._events.clear();
InteractionManager._instance = null;
}
@@ -118,6 +119,6 @@ export class InteractionManager {
holding: this._holding,
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(
() => import("@/components/docs/DocsLayout"),
"DocsLayout",
@@ -64,46 +72,18 @@ const LazyDocsAnimationPage = lazyNamed(
"DocsAnimationPage",
);
export function DocsLayoutRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsLayout);
}
export function DocsReadmeRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsReadmePage);
}
export function DocsArchitectureRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsArchitecturePage);
}
export function DocsTargetArchitectureRoute(): React.JSX.Element {
return withDocsSuspense(LazyDocsTargetArchitecturePage);
}
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);
}
export const DocsLayoutRoute = createDocsRoute(LazyDocsLayout);
export const DocsReadmeRoute = createDocsRoute(LazyDocsReadmePage);
export const DocsArchitectureRoute = createDocsRoute(LazyDocsArchitecturePage);
export const DocsTargetArchitectureRoute = createDocsRoute(
LazyDocsTargetArchitecturePage,
);
export const DocsTechnicalEditorRoute = createDocsRoute(
LazyDocsTechnicalEditorPage,
);
export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage);
export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage);
export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);
export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage);
export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage);
export const DocsAnimationRoute = createDocsRoute(LazyDocsAnimationPage);
+6 -16
View File
@@ -1,32 +1,22 @@
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]>>;
};
function getListeners<
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>> {
export class EventEmitter<TEvents extends object> {
private readonly listeners: ListenerMap<TEvents> = {};
on<TKey extends keyof TEvents>(
event: TKey,
listener: Listener<TEvents[TKey]>,
): () => void {
const existing = getListeners(this.listeners, event);
const existing = this.listeners[event];
if (existing) {
existing.add(listener);
} else {
this.listeners[event] = new Set([listener]) as ListenerMap<TEvents>[TKey];
this.listeners[event] = new Set([listener]);
}
return () => {
@@ -38,7 +28,7 @@ export class EventEmitter<TEvents extends Record<string, unknown>> {
event: TKey,
listener: Listener<TEvents[TKey]>,
): void {
const currentListeners = getListeners(this.listeners, event);
const currentListeners = this.listeners[event];
if (!currentListeners) {
return;
@@ -52,7 +42,7 @@ export class EventEmitter<TEvents extends Record<string, unknown>> {
}
emit<TKey extends keyof TEvents>(event: TKey, payload: TEvents[TKey]): void {
const currentListeners = getListeners(this.listeners, event);
const currentListeners = this.listeners[event];
if (!currentListeners) {
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 type { CameraMode, SceneMode } from "@/types/debug/debug";
import type { HandTrackingSource } from "@/types/handTracking/handTracking";
import { EventEmitter } from "@/utils/core/EventEmitter";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls";
@@ -10,6 +11,10 @@ interface StoredDebugControls {
sceneMode: SceneMode;
}
interface DebugEvents {
change: void;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -48,9 +53,9 @@ export class Debug {
public readonly active: boolean;
private readonly gui: GUI | null;
private readonly events = new EventEmitter<DebugEvents>();
private readonly folders = new Map<string, GUI>();
private readonly folderRefCounts = new Map<string, number>();
private readonly listeners = new Set<() => void>();
private readonly controls: {
cameraMode: CameraMode;
handTrackingSource: HandTrackingSource;
@@ -182,11 +187,7 @@ export class Debug {
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
return this.events.on("change", listener);
}
getCameraMode(): CameraMode {
@@ -228,7 +229,7 @@ export class Debug {
}
private emit(): void {
this.listeners.forEach((listener) => listener());
this.events.emit("change", undefined);
}
private saveAndEmit(): void {
+3 -3
View File
@@ -1,12 +1,12 @@
import { logger } from "@/utils/core/logger";
import type { Vector3Tuple } from "@/types/three/three";
import { logger } from "@/utils/core/Logger";
import type { Vector3Scale, Vector3Tuple } from "@/types/three/three";
export interface ModelLoadLogContext {
modelPath: string;
scope: string;
position?: Vector3Tuple | undefined;
rotation?: Vector3Tuple | undefined;
scale?: Vector3Tuple | number | undefined;
scale?: Vector3Scale | undefined;
}
interface LoadedModelInfo {
+1 -1
View File
@@ -4,7 +4,7 @@ import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
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 { logModelLoadError } from "@/utils/three/modelLoadLogger";
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 * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { RepairGameZone } from "@/components/three/gameplay/RepairGameZone";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
@@ -133,8 +132,6 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
/>
</mesh>
</TriggerObject>
<RepairGameZone />
</Physics>
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>