merge: sync develop into env manager
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
interface RepairBrokenPartHighlightProps {
|
||||
target: THREE.Object3D;
|
||||
}
|
||||
|
||||
const _box = new THREE.Box3();
|
||||
const _sphere = new THREE.Sphere();
|
||||
const _worldPosition = new THREE.Vector3();
|
||||
const _localPosition = new THREE.Vector3();
|
||||
|
||||
export function RepairBrokenPartHighlight({
|
||||
target,
|
||||
}: RepairBrokenPartHighlightProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
_box.setFromObject(target).getBoundingSphere(_sphere);
|
||||
|
||||
_worldPosition.copy(_sphere.center);
|
||||
_localPosition.copy(_worldPosition);
|
||||
group.parent?.worldToLocal(_localPosition);
|
||||
group.position.copy(_localPosition);
|
||||
|
||||
const pulse = 1 + Math.sin(clock.elapsedTime * 5) * 0.08;
|
||||
const radius = Math.max(_sphere.radius, 0.35) * pulse;
|
||||
group.scale.setScalar(radius);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 32, 16]} />
|
||||
<meshBasicMaterial color="#ef4444" transparent opacity={0.14} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1.06, 32, 16]} />
|
||||
<meshBasicMaterial
|
||||
color="#ef4444"
|
||||
wireframe
|
||||
transparent
|
||||
opacity={0.65}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.12, 0.025, 8, 96]} />
|
||||
<meshBasicMaterial color="#dc2626" transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
|
||||
interface RepairBrokenPartPromptProps {
|
||||
src: string;
|
||||
target: THREE.Object3D;
|
||||
}
|
||||
|
||||
const _box = new THREE.Box3();
|
||||
const _sphere = new THREE.Sphere();
|
||||
const _localPosition = new THREE.Vector3();
|
||||
|
||||
export function RepairBrokenPartPrompt({
|
||||
src,
|
||||
target,
|
||||
}: RepairBrokenPartPromptProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
_box.setFromObject(target).getBoundingSphere(_sphere);
|
||||
_localPosition.copy(_sphere.center);
|
||||
group.parent?.worldToLocal(_localPosition);
|
||||
group.position.copy(_localPosition);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<RepairPromptVideo src={src} position={[0, 0, 0]} size={72} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -8,20 +8,39 @@ import {
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE,
|
||||
REPAIR_CASE_FLOAT_DOWN_SPEED,
|
||||
REPAIR_CASE_FLOAT_HEIGHT,
|
||||
REPAIR_CASE_EXIT_DURATION,
|
||||
REPAIR_CASE_EXIT_Y_OFFSET,
|
||||
REPAIR_CASE_FLOAT_UP_SPEED,
|
||||
REPAIR_CASE_LID_NODE_NAME,
|
||||
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
|
||||
REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
REPAIR_CASE_OPEN_SOUND_PATH,
|
||||
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
|
||||
REPAIR_CASE_POP_DURATION,
|
||||
REPAIR_CASE_POP_Y_OFFSET,
|
||||
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { ModelTransformProps } from "@/types/three/three";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
export interface RepairCasePlaceholder {
|
||||
name: string;
|
||||
position: Vector3Tuple;
|
||||
}
|
||||
|
||||
interface RepairCaseModelProps extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
open: boolean;
|
||||
exiting?: boolean;
|
||||
floating?: boolean;
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
|
||||
@@ -37,6 +56,10 @@ const ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(
|
||||
export function RepairCaseModel({
|
||||
modelPath,
|
||||
open,
|
||||
exiting = false,
|
||||
floating = true,
|
||||
onPlaceholdersChange,
|
||||
onExitComplete,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
@@ -55,22 +78,80 @@ 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 onExitCompleteRef = useRef(onExitComplete);
|
||||
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
|
||||
const initialOpen = useRef(open);
|
||||
const previousOpen = useRef(open);
|
||||
const openedRotationZ = useRef(0);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const placeholderNodes = useRef<THREE.Object3D[]>([]);
|
||||
const placeholderSignature = useRef("__initial__");
|
||||
const placeholderPosition = useRef(new THREE.Vector3());
|
||||
const placeholderLocalPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useEffect(() => {
|
||||
onExitCompleteRef.current = onExitComplete;
|
||||
}, [onExitComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
onPlaceholdersChangeRef.current = onPlaceholdersChange;
|
||||
}, [onPlaceholdersChange]);
|
||||
|
||||
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(() => {
|
||||
if (!exiting) return undefined;
|
||||
|
||||
const popAnimation = pop.current;
|
||||
gsap.to(popAnimation, {
|
||||
scale: 0.001,
|
||||
yOffset: REPAIR_CASE_EXIT_Y_OFFSET,
|
||||
duration: REPAIR_CASE_EXIT_DURATION,
|
||||
ease: "back.in(1.4)",
|
||||
overwrite: true,
|
||||
onComplete: () => {
|
||||
onExitCompleteRef.current?.();
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(popAnimation);
|
||||
};
|
||||
}, [exiting]);
|
||||
|
||||
useEffect(() => {
|
||||
const lid = model.getObjectByName(REPAIR_CASE_LID_NODE_NAME);
|
||||
lidRef.current = lid ?? null;
|
||||
openedRotationZ.current = lid?.rotation.z ?? 0;
|
||||
placeholderNodes.current = [];
|
||||
|
||||
model.traverse((child) => {
|
||||
if (
|
||||
child.name.toLowerCase().startsWith(REPAIR_CASE_PLACEHOLDER_NAME_PREFIX)
|
||||
) {
|
||||
placeholderNodes.current.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
if (lid) {
|
||||
lid.rotation.z =
|
||||
@@ -100,14 +181,26 @@ export function RepairCaseModel({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousOpen.current === open) return;
|
||||
|
||||
previousOpen.current = open;
|
||||
AudioManager.getInstance().playSound(
|
||||
open ? REPAIR_CASE_OPEN_SOUND_PATH : REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
0.85,
|
||||
);
|
||||
}, [open]);
|
||||
|
||||
useFrame(({ clock }, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
group.getWorldPosition(worldPosition.current);
|
||||
const isNear =
|
||||
floating &&
|
||||
!exiting &&
|
||||
worldPosition.current.distanceTo(camera.position) <=
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
|
||||
const targetHeight = isNear ? REPAIR_CASE_FLOAT_HEIGHT : 0;
|
||||
const floatSpeed = isNear
|
||||
? REPAIR_CASE_FLOAT_UP_SPEED
|
||||
@@ -119,7 +212,43 @@ 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,
|
||||
);
|
||||
|
||||
if (placeholderNodes.current.length > 0) {
|
||||
const placeholders: RepairCasePlaceholder[] = [];
|
||||
placeholderNodes.current.forEach((child) => {
|
||||
child.getWorldPosition(placeholderPosition.current);
|
||||
placeholderLocalPosition.current.copy(placeholderPosition.current);
|
||||
group.parent?.worldToLocal(placeholderLocalPosition.current);
|
||||
placeholders.push({
|
||||
name: child.name,
|
||||
position: [
|
||||
placeholderLocalPosition.current.x,
|
||||
placeholderLocalPosition.current.y,
|
||||
placeholderLocalPosition.current.z,
|
||||
],
|
||||
});
|
||||
});
|
||||
placeholders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const nextSignature = placeholders
|
||||
.map(
|
||||
(placeholder) =>
|
||||
`${placeholder.name}:${placeholder.position
|
||||
.map((value) => value.toFixed(3))
|
||||
.join(",")}`,
|
||||
)
|
||||
.join("|");
|
||||
if (nextSignature !== placeholderSignature.current) {
|
||||
placeholderSignature.current = nextSignature;
|
||||
onPlaceholdersChangeRef.current?.(placeholders);
|
||||
}
|
||||
}
|
||||
|
||||
animationActiveRef.current = isNear;
|
||||
|
||||
@@ -158,12 +287,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,109 +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";
|
||||
|
||||
const REPAIR_CASE_PAN_RANGE = 20;
|
||||
|
||||
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 {
|
||||
const pan = Math.max(-1, Math.min(1, position[0] / REPAIR_CASE_PAN_RANGE));
|
||||
|
||||
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, 1, {
|
||||
category: "sfx",
|
||||
pan,
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
const PARTICLES = Array.from({ length: 24 }, (_, index) => {
|
||||
const angle = (index / 24) * Math.PI * 2;
|
||||
const ring = index % 3;
|
||||
return {
|
||||
angle,
|
||||
radius: 0.45 + ring * 0.28,
|
||||
y: 0.35 + (index % 5) * 0.16,
|
||||
speed: 0.8 + (index % 4) * 0.18,
|
||||
};
|
||||
});
|
||||
|
||||
export function RepairCompletionParticles(): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
group.rotation.y = clock.elapsedTime * 0.9;
|
||||
group.children.forEach((child, index) => {
|
||||
const particle = PARTICLES[index];
|
||||
if (!particle) return;
|
||||
|
||||
const pulse = 1 + Math.sin(clock.elapsedTime * 5 + index) * 0.35;
|
||||
child.position.y =
|
||||
particle.y + Math.sin(clock.elapsedTime * particle.speed) * 0.08;
|
||||
child.scale.setScalar(pulse);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{PARTICLES.map((particle, index) => (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[
|
||||
Math.cos(particle.angle) * particle.radius,
|
||||
particle.y,
|
||||
Math.sin(particle.angle) * particle.radius,
|
||||
]}
|
||||
>
|
||||
<sphereGeometry args={[0.045, 12, 12]} />
|
||||
<meshBasicMaterial color="#86efac" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
|
||||
interface RepairCompletionStepProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function RepairCompletionStep({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairCompletionStepProps): React.JSX.Element {
|
||||
const [isClosingCase, setIsClosingCase] = useState(false);
|
||||
const [isExitingCase, setIsExitingCase] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClosingCase) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIsExitingCase(true);
|
||||
}, REPAIR_CASE_ANIMATION_DURATION * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isClosingCase]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
exiting={isExitingCase}
|
||||
open={!isClosingCase}
|
||||
onExitComplete={onComplete}
|
||||
/>
|
||||
|
||||
<RepairObjectModel
|
||||
label={config.label}
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
/>
|
||||
|
||||
{!isClosingCase ? (
|
||||
<TriggerObject
|
||||
position={[0, 1.1, 0]}
|
||||
colliders="ball"
|
||||
label={`Valider ${config.label}`}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={() => setIsClosingCase(true)}
|
||||
>
|
||||
<mesh>
|
||||
<torusGeometry args={[1.35, 0.045, 12, 96]} />
|
||||
<meshBasicMaterial color="#22c55e" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.2, 1.25, 96]} />
|
||||
<meshBasicMaterial color="#bbf7d0" transparent opacity={0.3} />
|
||||
</mesh>
|
||||
</TriggerObject>
|
||||
) : null}
|
||||
|
||||
{!isClosingCase ? (
|
||||
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
||||
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
|
||||
import {
|
||||
RepairScanSequence,
|
||||
type RepairScannedBrokenPart,
|
||||
} from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import {
|
||||
REPAIR_MISSIONS,
|
||||
type RepairMissionConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import type {
|
||||
MissionStep,
|
||||
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";
|
||||
|
||||
interface RepairGameProps extends Required<
|
||||
Pick<ModelTransformProps, "position">
|
||||
> {
|
||||
mission: RepairMissionId;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: ModelTransformProps["scale"];
|
||||
}
|
||||
|
||||
interface RepairMissionAssetPreloaderProps {
|
||||
config: RepairMissionConfig;
|
||||
}
|
||||
|
||||
function RepairMissionAssetPreloader({
|
||||
config,
|
||||
}: RepairMissionAssetPreloaderProps): null {
|
||||
const modelPaths = useMemo(
|
||||
() => getRepairMissionModelPaths(config),
|
||||
[config],
|
||||
);
|
||||
|
||||
useGLTF(modelPaths);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RepairGame({
|
||||
mission,
|
||||
position,
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairGameProps): React.JSX.Element | null {
|
||||
const config = REPAIR_MISSIONS[mission];
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const step = useRepairMissionStep(mission);
|
||||
const [casePlaceholders, setCasePlaceholders] = useState<
|
||||
readonly RepairCasePlaceholder[]
|
||||
>([]);
|
||||
const [scannedBrokenParts, setScannedBrokenParts] = useState<
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const readyForFragmentation = step === "inspected";
|
||||
|
||||
useRepairFragmentationInput({
|
||||
enabled: mainState === mission && readyForFragmentation,
|
||||
keyboardEnabled: false,
|
||||
onFragment: () => setMissionStep(mission, "fragmented"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mainState === mission && shouldKeepRepairRuntimeState(step)) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCasePlaceholders([]);
|
||||
setScannedBrokenParts([]);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mainState !== mission) return undefined;
|
||||
|
||||
if (step !== "fragmented") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setMissionStep(mission, "scanning");
|
||||
}, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, setMissionStep, step]);
|
||||
|
||||
if (mainState !== mission) return null;
|
||||
if (step === "locked") return null;
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
<Suspense fallback={null}>
|
||||
<RepairMissionAssetPreloader config={config} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{step === "waiting" ? (
|
||||
<RepairInspectionObject
|
||||
config={config}
|
||||
worldPosition={position}
|
||||
onInspect={() => setMissionStep(mission, "inspected")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "fragmented" ? (
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
/>
|
||||
) : null}
|
||||
{step === "scanning" ? (
|
||||
<RepairScanSequence
|
||||
config={config}
|
||||
onComplete={(brokenParts) => {
|
||||
setScannedBrokenParts(brokenParts);
|
||||
setMissionStep(mission, "repairing");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{step === "repairing" ? (
|
||||
<RepairRepairingStep
|
||||
brokenParts={scannedBrokenParts}
|
||||
config={config}
|
||||
placeholders={casePlaceholders}
|
||||
onRepair={() => setMissionStep(mission, "reassembling")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "reassembling" ? (
|
||||
<RepairReassemblyStep
|
||||
config={config}
|
||||
onComplete={() => setMissionStep(mission, "done")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "done" ? (
|
||||
<RepairCompletionStep
|
||||
config={config}
|
||||
onComplete={() => completeMission(mission)}
|
||||
/>
|
||||
) : null}
|
||||
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
onPlaceholdersChange={setCasePlaceholders}
|
||||
open={step === "repairing"}
|
||||
zoomed={step === "repairing"}
|
||||
showFragmentationPrompt={readyForFragmentation}
|
||||
onInteract={
|
||||
readyForFragmentation
|
||||
? () => setMissionStep(mission, "fragmented")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
|
||||
return step === "repairing" || step === "reassembling" || step === "done";
|
||||
}
|
||||
|
||||
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
config.modelPath,
|
||||
...config.brokenParts.flatMap((part) => part.modelPath ?? []),
|
||||
...config.replacementParts.flatMap((part) => part.modelPath ?? []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -1,124 +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";
|
||||
import { playGameplayDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
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" });
|
||||
void playGameplayDialogueById("narrateur_ebikecasse");
|
||||
}
|
||||
};
|
||||
|
||||
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" });
|
||||
void playGameplayDialogueById("narrateur_galetscan");
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairInspectionObjectProps {
|
||||
config: RepairMissionConfig;
|
||||
worldPosition: Vector3Tuple;
|
||||
onInspect: () => void;
|
||||
}
|
||||
|
||||
export function RepairInspectionObject({
|
||||
config,
|
||||
worldPosition,
|
||||
onInspect,
|
||||
}: RepairInspectionObjectProps): React.JSX.Element {
|
||||
return (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={`Inspecter ${config.label}`}
|
||||
position={worldPosition}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onPress={onInspect}
|
||||
>
|
||||
<RepairObjectModel
|
||||
label={config.label}
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 0.9}
|
||||
/>
|
||||
<RepairPromptVideo src={config.stageUiPath} />
|
||||
</InteractableObject>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
RepairCaseModel,
|
||||
type RepairCasePlaceholder,
|
||||
} from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import {
|
||||
REPAIR_CASE_FOCUS_POSITION,
|
||||
REPAIR_CASE_FOCUS_SCALE,
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairMissionCaseProps {
|
||||
config: RepairMissionConfig;
|
||||
exiting?: boolean;
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
open?: boolean;
|
||||
zoomed?: boolean;
|
||||
showFragmentationPrompt?: boolean;
|
||||
onInteract?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export function RepairMissionCase({
|
||||
config,
|
||||
exiting = false,
|
||||
onPlaceholdersChange,
|
||||
onExitComplete,
|
||||
open = false,
|
||||
zoomed = false,
|
||||
showFragmentationPrompt = false,
|
||||
onInteract,
|
||||
}: RepairMissionCaseProps): React.JSX.Element {
|
||||
const casePosition = zoomed
|
||||
? REPAIR_CASE_FOCUS_POSITION
|
||||
: config.case.position;
|
||||
const caseScale = zoomed ? REPAIR_CASE_FOCUS_SCALE : config.case.scale;
|
||||
const modelPosition: Vector3Tuple = onInteract ? [0, 0, 0] : casePosition;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{onInteract ? (
|
||||
<TriggerObject
|
||||
position={casePosition}
|
||||
colliders="ball"
|
||||
label={`Ouvrir ${config.label}`}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={onInteract}
|
||||
>
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
rotation={config.case.rotation}
|
||||
scale={caseScale}
|
||||
/>
|
||||
</TriggerObject>
|
||||
) : (
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
rotation={config.case.rotation}
|
||||
scale={caseScale}
|
||||
/>
|
||||
)}
|
||||
{showFragmentationPrompt && !exiting ? (
|
||||
<RepairPromptVideo
|
||||
src={config.interactUiPath}
|
||||
position={[casePosition[0], 2.4, casePosition[2]]}
|
||||
size={80}
|
||||
/>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ReactNode } from "react";
|
||||
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;
|
||||
modelPath: string;
|
||||
}
|
||||
|
||||
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
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
|
||||
> {
|
||||
constructor(props: RepairObjectModelBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): RepairObjectModelBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: this.props.modelPath,
|
||||
position: this.props.position,
|
||||
rotation: this.props.rotation,
|
||||
scale: this.props.scale,
|
||||
scope: `RepairObjectModel.${this.props.label}`,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<RepairObjectFallback
|
||||
label={this.props.label}
|
||||
position={this.props.position}
|
||||
rotation={this.props.rotation}
|
||||
scale={this.props.scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function RepairObjectModel({
|
||||
label,
|
||||
modelPath,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairObjectModelProps): React.JSX.Element {
|
||||
return (
|
||||
<RepairObjectModelBoundary
|
||||
label={label}
|
||||
modelPath={modelPath}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
>
|
||||
<SimpleModel
|
||||
modelPath={modelPath}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
/>
|
||||
</RepairObjectModelBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairObjectFallback({
|
||||
label,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: Pick<
|
||||
RepairObjectFallbackProps,
|
||||
"label" | "position" | "rotation" | "scale"
|
||||
>): React.JSX.Element {
|
||||
return (
|
||||
<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 />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.05, 0]}>
|
||||
<sphereGeometry args={[0.08, 16, 16]} />
|
||||
<meshBasicMaterial color={label ? "#f8fafc" : "#facc15"} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { WorldVideoPrompt } from "@/components/three/ui/WorldVideoPrompt";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairPromptVideoProps {
|
||||
src: string;
|
||||
position?: Vector3Tuple;
|
||||
size?: number;
|
||||
billboard?: boolean;
|
||||
}
|
||||
|
||||
export function RepairPromptVideo({
|
||||
src,
|
||||
position = [0, 1.8, 0],
|
||||
size = 96,
|
||||
billboard = true,
|
||||
}: RepairPromptVideoProps): React.JSX.Element {
|
||||
return (
|
||||
<WorldVideoPrompt
|
||||
billboard={billboard}
|
||||
position={position}
|
||||
size={size}
|
||||
src={src}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
|
||||
interface RepairReassemblyStepProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function RepairReassemblyStep({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairReassemblyStepProps): React.JSX.Element {
|
||||
const [split, setSplit] = useState(true);
|
||||
const reassemblySeconds =
|
||||
config.reassemblySeconds ?? REPAIR_REASSEMBLY_SECONDS;
|
||||
|
||||
useEffect(() => {
|
||||
const closeTimeoutId = window.setTimeout(() => {
|
||||
setSplit(false);
|
||||
}, 50);
|
||||
const completeTimeoutId = window.setTimeout(() => {
|
||||
onComplete();
|
||||
}, reassemblySeconds * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(closeTimeoutId);
|
||||
window.clearTimeout(completeTimeoutId);
|
||||
};
|
||||
}, [onComplete, reassemblySeconds]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split={split}
|
||||
splitDistance={1.2}
|
||||
/>
|
||||
<RepairCompletionParticles />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import {
|
||||
REPAIR_CASE_FOCUS_POSITION,
|
||||
REPAIR_CASE_PLACEHOLDER_SNAP_DURATION,
|
||||
REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type {
|
||||
RepairMissionConfig,
|
||||
RepairMissionPartConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
|
||||
const _placeholderPosition = new THREE.Vector3();
|
||||
const FALLBACK_PLACEHOLDER_OFFSETS: Vector3Tuple[] = [
|
||||
[-1.15, 1, 0.25],
|
||||
[0, 1.05, 0.45],
|
||||
[1.15, 1, 0.25],
|
||||
];
|
||||
const BROKEN_PART_START_OFFSETS: Vector3Tuple[] = [
|
||||
[-1.35, 0.55, -0.85],
|
||||
[0, 0.6, -1],
|
||||
[1.35, 0.55, -0.85],
|
||||
];
|
||||
const REPAIR_INSTALL_RADIUS = 1.1;
|
||||
const VALID_PART_COLOR = "#22c55e";
|
||||
const INVALID_PART_COLOR = "#ef4444";
|
||||
const STORED_BROKEN_PART_COLOR = "#38bdf8";
|
||||
|
||||
interface RepairRepairingStepProps {
|
||||
brokenParts: readonly RepairScannedBrokenPart[];
|
||||
config: RepairMissionConfig;
|
||||
placeholders: readonly RepairCasePlaceholder[];
|
||||
onRepair: () => void;
|
||||
}
|
||||
|
||||
interface RepairInstallTargetProps {
|
||||
blockedFeedback: boolean;
|
||||
fillColor: string;
|
||||
isReadyToInstall: boolean;
|
||||
label: string;
|
||||
ringColor: string;
|
||||
onBlocked: () => void;
|
||||
onRepair: () => void;
|
||||
}
|
||||
|
||||
interface RepairPlaceholderMarkersProps {
|
||||
positions: readonly Vector3Tuple[];
|
||||
}
|
||||
|
||||
interface RepairPartPlacementFeedbackProps {
|
||||
state: "valid" | "invalid" | "stored" | null;
|
||||
}
|
||||
|
||||
export function RepairRepairingStep({
|
||||
brokenParts,
|
||||
config,
|
||||
placeholders,
|
||||
onRepair,
|
||||
}: RepairRepairingStepProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const localPosition = useRef(new THREE.Vector3());
|
||||
const [placedPartIds, setPlacedPartIds] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] =
|
||||
useState(false);
|
||||
const replacementParts = getReplacementParts(config);
|
||||
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
|
||||
const requiredReplacementPart = replacementParts.find(
|
||||
(part) => part.id === config.requiredReplacementPartId,
|
||||
);
|
||||
const requiredReplacementLabel =
|
||||
requiredReplacementPart?.label ?? config.label;
|
||||
const placeholderTargets = getPlaceholderTargets(placeholders);
|
||||
const placeholderPositions = placeholderTargets.map(
|
||||
(target) => target.position,
|
||||
);
|
||||
const hasCorrectPartPlaced = Boolean(
|
||||
placedPartIds[config.requiredReplacementPartId],
|
||||
);
|
||||
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
|
||||
(part) => depositedBrokenPartIds[part.id],
|
||||
);
|
||||
const hasWrongPartPlaced = replacementParts.some(
|
||||
(part) =>
|
||||
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
|
||||
);
|
||||
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
|
||||
const installColor = isReadyToInstall
|
||||
? "#22c55e"
|
||||
: hasWrongPartPlaced
|
||||
? "#ef4444"
|
||||
: "#f97316";
|
||||
const installFillColor = isReadyToInstall
|
||||
? "#86efac"
|
||||
: hasWrongPartPlaced
|
||||
? "#fecaca"
|
||||
: "#fed7aa";
|
||||
const installLabel = isReadyToInstall
|
||||
? `Installer ${requiredReplacementLabel}`
|
||||
: hasWrongPartPlaced
|
||||
? `Mauvaise pièce`
|
||||
: hasCorrectPartPlaced
|
||||
? `Ranger pièce cassée`
|
||||
: `Approcher ${requiredReplacementLabel}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBlockedInstallFeedback) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowBlockedInstallFeedback(false);
|
||||
}, 900);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [showBlockedInstallFeedback]);
|
||||
|
||||
function handleReplacementPosition(
|
||||
partId: string,
|
||||
position: THREE.Vector3,
|
||||
): void {
|
||||
const isPlaced = isNearPlaceholder(
|
||||
getStepLocalPosition(position, groupRef.current, localPosition.current),
|
||||
placeholderPositions,
|
||||
);
|
||||
setPlacedPartIds((current) => {
|
||||
if (!current[partId] || isPlaced) return current;
|
||||
|
||||
return { ...current, [partId]: false };
|
||||
});
|
||||
}
|
||||
|
||||
function handleReplacementSnap(partId: string): void {
|
||||
setPlacedPartIds((current) => {
|
||||
if (current[partId]) return current;
|
||||
|
||||
return { ...current, [partId]: true };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrokenPartPosition(
|
||||
partId: string,
|
||||
position: THREE.Vector3,
|
||||
targets: readonly Vector3Tuple[],
|
||||
): void {
|
||||
const isDeposited = isNearPlaceholder(
|
||||
getStepLocalPosition(position, groupRef.current, localPosition.current),
|
||||
targets,
|
||||
);
|
||||
setDepositedBrokenPartIds((current) => {
|
||||
if (!current[partId] || isDeposited) return current;
|
||||
|
||||
return { ...current, [partId]: false };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrokenPartSnap(partId: string): void {
|
||||
setDepositedBrokenPartIds((current) => {
|
||||
if (current[partId]) return current;
|
||||
|
||||
return { ...current, [partId]: true };
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<RepairInstallTarget
|
||||
blockedFeedback={showBlockedInstallFeedback}
|
||||
fillColor={installFillColor}
|
||||
isReadyToInstall={isReadyToInstall}
|
||||
label={installLabel}
|
||||
ringColor={installColor}
|
||||
onBlocked={() => setShowBlockedInstallFeedback(true)}
|
||||
onRepair={onRepair}
|
||||
/>
|
||||
|
||||
<RepairPlaceholderMarkers positions={placeholderPositions} />
|
||||
|
||||
{replacementParts.map((part, index) => {
|
||||
const placeholderPosition =
|
||||
placeholderPositions[index % placeholderPositions.length] ??
|
||||
placeholderPositions[0]!;
|
||||
const isPlaced = Boolean(placedPartIds[part.id]);
|
||||
const feedbackState = getReplacementFeedbackState(
|
||||
part.id,
|
||||
config.requiredReplacementPartId,
|
||||
isPlaced,
|
||||
);
|
||||
|
||||
return (
|
||||
<GrabbableObject
|
||||
key={part.id}
|
||||
position={placeholderPosition}
|
||||
colliders="ball"
|
||||
handControlled
|
||||
label={`Prendre ${part.label}`}
|
||||
onPositionChange={(position) => {
|
||||
handleReplacementPosition(part.id, position);
|
||||
}}
|
||||
onSnap={() => {
|
||||
handleReplacementSnap(part.id);
|
||||
}}
|
||||
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
|
||||
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
|
||||
snapTargets={placeholderPositions}
|
||||
>
|
||||
<group>
|
||||
<RepairObjectModel
|
||||
label={part.label}
|
||||
modelPath={part.modelPath ?? config.modelPath}
|
||||
scale={0.36}
|
||||
/>
|
||||
<RepairPartPlacementFeedback state={feedbackState} />
|
||||
</group>
|
||||
</GrabbableObject>
|
||||
);
|
||||
})}
|
||||
|
||||
{brokenPartsToDeposit.map((part, index) => {
|
||||
const startOffset =
|
||||
BROKEN_PART_START_OFFSETS[index % BROKEN_PART_START_OFFSETS.length] ??
|
||||
BROKEN_PART_START_OFFSETS[0]!;
|
||||
const startPosition: Vector3Tuple = [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + startOffset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + startOffset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + startOffset[2],
|
||||
];
|
||||
const targetPositions = getBrokenPartTargetPositions(
|
||||
part,
|
||||
placeholderTargets,
|
||||
);
|
||||
const isDeposited = Boolean(depositedBrokenPartIds[part.id]);
|
||||
|
||||
return (
|
||||
<GrabbableObject
|
||||
key={part.id}
|
||||
position={startPosition}
|
||||
colliders="ball"
|
||||
handControlled
|
||||
label={`Ranger ${part.label}`}
|
||||
onPositionChange={(position) => {
|
||||
handleBrokenPartPosition(part.id, position, targetPositions);
|
||||
}}
|
||||
onSnap={() => {
|
||||
handleBrokenPartSnap(part.id);
|
||||
}}
|
||||
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
|
||||
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
|
||||
snapTargets={targetPositions}
|
||||
>
|
||||
<group>
|
||||
<RepairObjectModel
|
||||
label={part.label}
|
||||
modelPath={part.modelPath}
|
||||
scale={0.24}
|
||||
/>
|
||||
<mesh position={[0, 0.42, 0]}>
|
||||
<sphereGeometry args={[0.11, 16, 16]} />
|
||||
<meshBasicMaterial color="#ef4444" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<RepairPartPlacementFeedback
|
||||
state={isDeposited ? "stored" : null}
|
||||
/>
|
||||
</group>
|
||||
</GrabbableObject>
|
||||
);
|
||||
})}
|
||||
|
||||
{isReadyToInstall ? (
|
||||
<RepairPromptVideo src={config.interactUiPath} position={[0, 2.3, 0]} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairInstallTarget({
|
||||
blockedFeedback,
|
||||
fillColor,
|
||||
isReadyToInstall,
|
||||
label,
|
||||
ringColor,
|
||||
onBlocked,
|
||||
onRepair,
|
||||
}: RepairInstallTargetProps): React.JSX.Element {
|
||||
return (
|
||||
<TriggerObject
|
||||
position={INSTALL_TARGET_POSITION}
|
||||
colliders="ball"
|
||||
label={label}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={() => {
|
||||
if (!isReadyToInstall) {
|
||||
onBlocked();
|
||||
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>
|
||||
{blockedFeedback ? (
|
||||
<group position={[0, 0.28, 0]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.08, 0.035, 12, 96]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.12, 16, 16]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
) : null}
|
||||
</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 RepairPartPlacementFeedback({
|
||||
state,
|
||||
}: RepairPartPlacementFeedbackProps): React.JSX.Element | null {
|
||||
if (!state) return null;
|
||||
|
||||
const color = getPlacementFeedbackColor(state);
|
||||
|
||||
return (
|
||||
<group position={[0, 0.72, 0]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.48, 0.035, 12, 64]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.08, 0]}>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function getPlacementFeedbackColor(
|
||||
state: NonNullable<RepairPartPlacementFeedbackProps["state"]>,
|
||||
): string {
|
||||
if (state === "valid") return VALID_PART_COLOR;
|
||||
if (state === "stored") return STORED_BROKEN_PART_COLOR;
|
||||
|
||||
return INVALID_PART_COLOR;
|
||||
}
|
||||
|
||||
function getReplacementFeedbackState(
|
||||
partId: string,
|
||||
requiredPartId: string,
|
||||
isPlaced: boolean,
|
||||
): RepairPartPlacementFeedbackProps["state"] {
|
||||
if (!isPlaced) return null;
|
||||
|
||||
return partId === requiredPartId ? "valid" : "invalid";
|
||||
}
|
||||
|
||||
function getPlaceholderTargets(
|
||||
placeholders: readonly RepairCasePlaceholder[],
|
||||
): readonly RepairCasePlaceholder[] {
|
||||
if (placeholders.length > 0) {
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
return FALLBACK_PLACEHOLDER_OFFSETS.map(
|
||||
(offset, index): RepairCasePlaceholder => ({
|
||||
name: `placeholder_${index + 1}`,
|
||||
position: [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + offset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + offset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + offset[2],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getBrokenPartTargetPositions(
|
||||
part: RepairScannedBrokenPart,
|
||||
placeholderTargets: readonly RepairCasePlaceholder[],
|
||||
): readonly Vector3Tuple[] {
|
||||
if (!part.placeholderName) {
|
||||
return placeholderTargets.map((placeholder) => placeholder.position);
|
||||
}
|
||||
|
||||
const matchingPlaceholder = placeholderTargets.find(
|
||||
(placeholder) => placeholder.name === part.placeholderName,
|
||||
);
|
||||
|
||||
return matchingPlaceholder
|
||||
? [matchingPlaceholder.position]
|
||||
: placeholderTargets.map((placeholder) => placeholder.position);
|
||||
}
|
||||
|
||||
function isNearPlaceholder(
|
||||
position: THREE.Vector3,
|
||||
placeholderPositions: readonly Vector3Tuple[],
|
||||
): boolean {
|
||||
return placeholderPositions.some(
|
||||
(placeholderPosition) =>
|
||||
position.distanceTo(_placeholderPosition.set(...placeholderPosition)) <=
|
||||
REPAIR_INSTALL_RADIUS,
|
||||
);
|
||||
}
|
||||
|
||||
function getStepLocalPosition(
|
||||
worldPosition: THREE.Vector3,
|
||||
group: THREE.Group | null,
|
||||
target: THREE.Vector3,
|
||||
): THREE.Vector3 {
|
||||
target.copy(worldPosition);
|
||||
group?.worldToLocal(target);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function getReplacementParts(
|
||||
config: RepairMissionConfig,
|
||||
): readonly RepairMissionPartConfig[] {
|
||||
if (config.replacementParts.length > 0) return config.replacementParts;
|
||||
|
||||
return [
|
||||
{
|
||||
id: config.requiredReplacementPartId,
|
||||
label: config.label,
|
||||
modelPath: config.modelPath,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getBrokenPartsToDeposit(
|
||||
config: RepairMissionConfig,
|
||||
brokenParts: readonly RepairScannedBrokenPart[],
|
||||
): readonly RepairScannedBrokenPart[] {
|
||||
if (brokenParts.length > 0) return brokenParts;
|
||||
|
||||
return config.brokenParts.map((part) => ({
|
||||
id: part.id,
|
||||
label: part.label,
|
||||
modelPath: part.modelPath ?? config.modelPath,
|
||||
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { RepairBrokenPartHighlight } from "@/components/three/gameplay/RepairBrokenPartHighlight";
|
||||
import { RepairBrokenPartPrompt } from "@/components/three/gameplay/RepairBrokenPartPrompt";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual";
|
||||
import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import type {
|
||||
RepairMissionConfig,
|
||||
RepairMissionPartConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||
|
||||
interface RepairScanSequenceProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
|
||||
}
|
||||
|
||||
export interface RepairScannedBrokenPart {
|
||||
id: string;
|
||||
label: string;
|
||||
modelPath: string;
|
||||
placeholderName?: string;
|
||||
}
|
||||
|
||||
export function RepairScanSequence({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairScanSequenceProps): React.JSX.Element {
|
||||
const [parts, setParts] = useState<readonly ExplodedPart[]>([]);
|
||||
const [activePartIndex, setActivePartIndex] = useState(0);
|
||||
const activePart = parts[activePartIndex];
|
||||
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
|
||||
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
||||
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
|
||||
(partIndex) => partIndex <= activePartIndex,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (parts.length === 0) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setActivePartIndex((currentIndex) => {
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= parts.length) {
|
||||
onComplete(getScannedBrokenParts(parts, config));
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
});
|
||||
}, scanPartSeconds * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [activePartIndex, config, onComplete, parts, scanPartSeconds]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
onPartsReady={setParts}
|
||||
/>
|
||||
<RepairScanVisual target={activePart?.object} />
|
||||
{visibleBrokenPartIndexes.map((partIndex) => {
|
||||
const part = parts[partIndex];
|
||||
if (!part) return null;
|
||||
|
||||
return (
|
||||
<group key={part.object.uuid}>
|
||||
<RepairBrokenPartHighlight target={part.object} />
|
||||
<RepairBrokenPartPrompt
|
||||
src={config.brokenUiPath}
|
||||
target={part.object}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function getScannedBrokenParts(
|
||||
parts: readonly ExplodedPart[],
|
||||
config: RepairMissionConfig,
|
||||
): readonly RepairScannedBrokenPart[] {
|
||||
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
||||
|
||||
return brokenPartIndexes.map((_, index) => {
|
||||
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
|
||||
|
||||
return {
|
||||
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
|
||||
label: configuredPart?.label ?? `${config.label} broken part`,
|
||||
modelPath: configuredPart?.modelPath ?? config.modelPath,
|
||||
...(configuredPart?.placeholderName
|
||||
? { placeholderName: configuredPart.placeholderName }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getBrokenPartIndexes(
|
||||
parts: readonly ExplodedPart[],
|
||||
brokenParts: readonly RepairMissionPartConfig[],
|
||||
): number[] {
|
||||
if (parts.length === 0 || brokenParts.length === 0) return [];
|
||||
|
||||
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
|
||||
const { nodeName } = brokenPart;
|
||||
if (!nodeName) return [];
|
||||
|
||||
const index = parts.findIndex((part) =>
|
||||
objectContainsNodeName(part.object, nodeName),
|
||||
);
|
||||
|
||||
return index >= 0 ? [index] : [];
|
||||
});
|
||||
|
||||
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
|
||||
|
||||
return parts.slice(0, brokenParts.length).map((_, index) => index);
|
||||
}
|
||||
|
||||
function objectContainsNodeName(
|
||||
object: THREE.Object3D,
|
||||
nodeName: string,
|
||||
): boolean {
|
||||
if (object.name === nodeName) return true;
|
||||
|
||||
let found = false;
|
||||
object.traverse((child) => {
|
||||
if (child.name === nodeName) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
interface RepairScanVisualProps {
|
||||
target?: THREE.Object3D | null | undefined;
|
||||
}
|
||||
|
||||
export function RepairScanVisual({
|
||||
target = null,
|
||||
}: RepairScanVisualProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const scanLineRef = useRef<THREE.Mesh>(null);
|
||||
const worldPosition = useRef(new THREE.Vector3());
|
||||
const localPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
const scanLine = scanLineRef.current;
|
||||
if (!group || !scanLine) return;
|
||||
|
||||
if (target) {
|
||||
target.getWorldPosition(worldPosition.current);
|
||||
localPosition.current.copy(worldPosition.current);
|
||||
group.parent?.worldToLocal(localPosition.current);
|
||||
group.position.copy(localPosition.current);
|
||||
}
|
||||
|
||||
scanLine.position.y = 0.35 + Math.sin(clock.elapsedTime * 4) * 0.7;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.35, 0.035, 12, 96]} />
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.75} />
|
||||
</mesh>
|
||||
<mesh ref={scanLineRef} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.15, 1.25, 96]} />
|
||||
<meshBasicMaterial
|
||||
color="#7dd3fc"
|
||||
side={THREE.DoubleSide}
|
||||
transparent
|
||||
opacity={0.45}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, 0.85, 0]}>
|
||||
<sphereGeometry args={[1.25, 32, 16]} />
|
||||
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ const GLOVE_CONFIGS: Record<
|
||||
|
||||
const GLOVE_MODEL_SCALE = 0.33;
|
||||
const HAND_SPACE_DISTANCE = 0.5;
|
||||
const HAND_DEPTH_SCALE = 0.5;
|
||||
const HAND_TRACKING_HIDE_DELAY_MS = 250;
|
||||
|
||||
const FINGER_LANDMARK_CHAINS = [
|
||||
@@ -126,12 +125,7 @@ function landmarkToWorldPoint(
|
||||
target.unproject(camera);
|
||||
|
||||
_direction.copy(target).sub(_cameraPosition).normalize();
|
||||
target
|
||||
.copy(_cameraPosition)
|
||||
.addScaledVector(
|
||||
_direction,
|
||||
HAND_SPACE_DISTANCE - landmark.z * HAND_DEPTH_SCALE,
|
||||
);
|
||||
target.copy(_cameraPosition).addScaledVector(_direction, HAND_SPACE_DISTANCE);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { RigidBody } from "@react-three/rapier";
|
||||
import type { RapierRigidBody } from "@react-three/rapier";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import {
|
||||
@@ -24,10 +25,7 @@ import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import type {
|
||||
HandTrackingHand,
|
||||
HandTrackingLandmark,
|
||||
} from "@/types/handTracking/handTracking";
|
||||
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
|
||||
import type { ColliderShape, Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface GrabbableObjectProps {
|
||||
@@ -36,6 +34,16 @@ interface GrabbableObjectProps {
|
||||
colliders?: ColliderShape;
|
||||
label?: string;
|
||||
handControlled?: boolean;
|
||||
onPositionChange?: (position: THREE.Vector3) => void;
|
||||
onSnap?: (position: THREE.Vector3) => void;
|
||||
snapDuration?: number;
|
||||
snapRadius?: number;
|
||||
snapTargets?: readonly Vector3Tuple[];
|
||||
}
|
||||
|
||||
interface HandScreenPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const grabDebugParams = {
|
||||
@@ -55,10 +63,11 @@ const _handDirection = new THREE.Vector3();
|
||||
const _handHitDirection = new THREE.Vector3();
|
||||
const _cameraPos = new THREE.Vector3();
|
||||
const _objectPos = new THREE.Vector3();
|
||||
const _snapPosition = new THREE.Vector3();
|
||||
const _snapTargetWorldPosition = new THREE.Vector3();
|
||||
const _handRaycaster = new THREE.Raycaster();
|
||||
|
||||
const HAND_GRAB_SCREEN_RADIUS = 0.04;
|
||||
const HAND_DEPTH_SENSITIVITY = 4;
|
||||
const HAND_HIT_OFFSETS: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[HAND_GRAB_SCREEN_RADIUS, 0],
|
||||
@@ -67,10 +76,10 @@ const HAND_HIT_OFFSETS: Array<[number, number]> = [
|
||||
[0, -HAND_GRAB_SCREEN_RADIUS],
|
||||
];
|
||||
|
||||
function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
|
||||
const landmarks = hand.landmarks ?? [];
|
||||
function getHandCenterPoint(hand: HandTrackingHand): HandScreenPoint {
|
||||
const landmarks = hand.landmarks;
|
||||
if (landmarks.length === 0) {
|
||||
return { x: hand.x, y: hand.y, z: hand.z };
|
||||
return { x: hand.x, y: hand.y };
|
||||
}
|
||||
|
||||
let minX = landmarks[0]!.x;
|
||||
@@ -88,7 +97,6 @@ function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
|
||||
return {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
z: hand.z,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +104,7 @@ function getHandHit(
|
||||
group: THREE.Group | null,
|
||||
camera: THREE.Camera,
|
||||
cameraPos: THREE.Vector3,
|
||||
handCenter: HandTrackingLandmark,
|
||||
handCenter: HandScreenPoint,
|
||||
): THREE.Intersection | null {
|
||||
if (!group) return null;
|
||||
|
||||
@@ -123,15 +131,83 @@ export function GrabbableObject({
|
||||
colliders = GRAB_DEFAULT_COLLIDERS,
|
||||
label = GRAB_DEFAULT_LABEL,
|
||||
handControlled = false,
|
||||
onPositionChange,
|
||||
onSnap,
|
||||
snapDuration = 0.25,
|
||||
snapRadius = 0,
|
||||
snapTargets = [],
|
||||
}: GrabbableObjectProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const { hands } = useHandTrackingSnapshot();
|
||||
const spaceRef = useRef<THREE.Group>(null);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const rbRef = useRef<RapierRigidBody>(null);
|
||||
const isHolding = useRef(false);
|
||||
const isHandHolding = useRef(false);
|
||||
const handHoldDistance = useRef<number | null>(null);
|
||||
const handHoldStartZ = useRef<number | null>(null);
|
||||
const snapTween = useRef<gsap.core.Tween | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
snapTween.current?.kill();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function snapToNearestTarget(): void {
|
||||
const body = rbRef.current;
|
||||
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
|
||||
|
||||
const translation = body.translation();
|
||||
_currentPos.set(translation.x, translation.y, translation.z);
|
||||
|
||||
let nearestTarget: Vector3Tuple | null = null;
|
||||
let nearestTargetWorld: Vector3Tuple | null = null;
|
||||
let nearestDistance = snapRadius;
|
||||
snapTargets.forEach((target) => {
|
||||
_snapTargetWorldPosition.set(target[0], target[1], target[2]);
|
||||
spaceRef.current?.localToWorld(_snapTargetWorldPosition);
|
||||
const distance = _currentPos.distanceTo(_snapTargetWorldPosition);
|
||||
if (distance <= nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestTarget = target;
|
||||
nearestTargetWorld = [
|
||||
_snapTargetWorldPosition.x,
|
||||
_snapTargetWorldPosition.y,
|
||||
_snapTargetWorldPosition.z,
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
if (!nearestTarget || !nearestTargetWorld) return;
|
||||
|
||||
snapTween.current?.kill();
|
||||
const animatedPosition = {
|
||||
x: _currentPos.x,
|
||||
y: _currentPos.y,
|
||||
z: _currentPos.z,
|
||||
};
|
||||
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
body.setAngvel(ZERO_ANGULAR_VELOCITY, true);
|
||||
snapTween.current = gsap.to(animatedPosition, {
|
||||
x: nearestTargetWorld[0],
|
||||
y: nearestTargetWorld[1],
|
||||
z: nearestTargetWorld[2],
|
||||
duration: snapDuration,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => {
|
||||
body.setTranslation(animatedPosition, true);
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
},
|
||||
onComplete: () => {
|
||||
_snapPosition.set(
|
||||
animatedPosition.x,
|
||||
animatedPosition.y,
|
||||
animatedPosition.z,
|
||||
);
|
||||
onSnap?.(_snapPosition);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useDebugFolder("GrabbableObject", (folder) => {
|
||||
folder
|
||||
@@ -172,6 +248,7 @@ export function GrabbableObject({
|
||||
|
||||
const t = rbRef.current.translation();
|
||||
_currentPos.set(t.x, t.y, t.z);
|
||||
onPositionChange?.(_currentPos);
|
||||
|
||||
if (fistHand) {
|
||||
const handCenter = getHandCenterPoint(fistHand);
|
||||
@@ -191,34 +268,22 @@ export function GrabbableObject({
|
||||
: null;
|
||||
|
||||
isHandHolding.current = Boolean(hit);
|
||||
handHoldDistance.current = hit ? GRAB_HOLD_DISTANCE_DEFAULT : null;
|
||||
handHoldStartZ.current = hit ? fistHand.z : null;
|
||||
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
|
||||
}
|
||||
} else {
|
||||
if (isHandHolding.current) {
|
||||
snapToNearestTarget();
|
||||
}
|
||||
isHandHolding.current = false;
|
||||
handHoldDistance.current = null;
|
||||
handHoldStartZ.current = null;
|
||||
InteractionManager.getInstance().setHandHolding(false);
|
||||
}
|
||||
|
||||
if (!isHolding.current && !isHandHolding.current) return;
|
||||
|
||||
if (fistHand && isHandHolding.current) {
|
||||
const depthOffset =
|
||||
handHoldStartZ.current === null
|
||||
? 0
|
||||
: (fistHand.z - handHoldStartZ.current) * HAND_DEPTH_SENSITIVITY;
|
||||
const holdDistance = THREE.MathUtils.clamp(
|
||||
(handHoldDistance.current ?? grabDebugParams.holdDistance) +
|
||||
depthOffset,
|
||||
GRAB_HOLD_DISTANCE_MIN,
|
||||
GRAB_HOLD_DISTANCE_MAX,
|
||||
);
|
||||
|
||||
_holdTarget
|
||||
.copy(_cameraPos)
|
||||
.addScaledVector(_handDirection, holdDistance);
|
||||
.addScaledVector(_handDirection, grabDebugParams.holdDistance);
|
||||
} else {
|
||||
camera.getWorldDirection(_holdTarget);
|
||||
_holdTarget
|
||||
@@ -238,42 +303,45 @@ export function GrabbableObject({
|
||||
});
|
||||
|
||||
return (
|
||||
<RigidBody
|
||||
ref={rbRef}
|
||||
type="dynamic"
|
||||
colliders={colliders}
|
||||
position={position}
|
||||
>
|
||||
<group ref={groupRef}>
|
||||
<InteractableObject
|
||||
kind="grab"
|
||||
label={label}
|
||||
position={position}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
isHolding.current = true;
|
||||
}}
|
||||
onRelease={() => {
|
||||
isHolding.current = false;
|
||||
if (
|
||||
!rbRef.current ||
|
||||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
|
||||
)
|
||||
return;
|
||||
const v = rbRef.current.linvel();
|
||||
rbRef.current.setLinvel(
|
||||
{
|
||||
x: v.x * grabDebugParams.throwBoost,
|
||||
y: v.y * grabDebugParams.throwBoost,
|
||||
z: v.z * grabDebugParams.throwBoost,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InteractableObject>
|
||||
</group>
|
||||
</RigidBody>
|
||||
<group ref={spaceRef}>
|
||||
<RigidBody
|
||||
ref={rbRef}
|
||||
type="dynamic"
|
||||
colliders={colliders}
|
||||
position={position}
|
||||
>
|
||||
<group ref={groupRef}>
|
||||
<InteractableObject
|
||||
kind="grab"
|
||||
label={label}
|
||||
position={position}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
isHolding.current = true;
|
||||
}}
|
||||
onRelease={() => {
|
||||
isHolding.current = false;
|
||||
snapToNearestTarget();
|
||||
if (
|
||||
!rbRef.current ||
|
||||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
|
||||
)
|
||||
return;
|
||||
const v = rbRef.current.linvel();
|
||||
rbRef.current.setLinvel(
|
||||
{
|
||||
x: v.x * grabDebugParams.throwBoost,
|
||||
y: v.y * grabDebugParams.throwBoost,
|
||||
z: v.z * grabDebugParams.throwBoost,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InteractableObject>
|
||||
</group>
|
||||
</RigidBody>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Vector3Tuple } from "@/types/three/three";
|
||||
interface InteractableObjectBaseProps {
|
||||
label: string;
|
||||
position: Vector3Tuple;
|
||||
radius?: number;
|
||||
bodyRef?: RefObject<RapierRigidBody | null>;
|
||||
onPress: () => void;
|
||||
children: React.ReactNode;
|
||||
@@ -64,7 +65,15 @@ function createInteractableHandle(
|
||||
export function InteractableObject(
|
||||
props: InteractableObjectProps,
|
||||
): React.JSX.Element {
|
||||
const { kind, label, position, bodyRef, onPress, children } = props;
|
||||
const {
|
||||
kind,
|
||||
label,
|
||||
position,
|
||||
radius = INTERACTION_RADIUS,
|
||||
bodyRef,
|
||||
onPress,
|
||||
children,
|
||||
} = props;
|
||||
const onRelease = props.kind === "grab" ? props.onRelease : null;
|
||||
const camera = useThree((state) => state.camera);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
@@ -148,13 +157,15 @@ export function InteractableObject(
|
||||
if (bodyRef?.current) {
|
||||
const t = bodyRef.current.translation();
|
||||
_objectPos.set(t.x, t.y, t.z);
|
||||
} else if (group) {
|
||||
group.getWorldPosition(_objectPos);
|
||||
} else {
|
||||
_objectPos.set(...position);
|
||||
}
|
||||
|
||||
camera.getWorldPosition(_cameraPos);
|
||||
const dist = _cameraPos.distanceTo(_objectPos);
|
||||
const isNearby = dist <= INTERACTION_RADIUS;
|
||||
const isNearby = dist <= radius;
|
||||
|
||||
manager.setNearby(handle.current, isNearby);
|
||||
|
||||
@@ -167,7 +178,7 @@ export function InteractableObject(
|
||||
|
||||
camera.getWorldDirection(_cameraDir);
|
||||
_raycaster.set(_cameraPos, _cameraDir);
|
||||
_raycaster.far = INTERACTION_RADIUS;
|
||||
_raycaster.far = radius;
|
||||
|
||||
const hits = group ? _raycaster.intersectObject(group, true) : [];
|
||||
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
|
||||
@@ -185,7 +196,7 @@ export function InteractableObject(
|
||||
<mesh ref={debugSphereRef} visible={false}>
|
||||
<sphereGeometry
|
||||
args={[
|
||||
INTERACTION_RADIUS,
|
||||
radius,
|
||||
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||
]}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { RigidBody } from "@react-three/rapier";
|
||||
import type { RapierRigidBody } from "@react-three/rapier";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
|
||||
import {
|
||||
TRIGGER_DEFAULT_COLLIDERS,
|
||||
TRIGGER_DEFAULT_LABEL,
|
||||
@@ -22,6 +24,7 @@ interface TriggerObjectProps {
|
||||
children: React.ReactNode;
|
||||
colliders?: ColliderShape;
|
||||
label?: string;
|
||||
radius?: number;
|
||||
soundPath?: string;
|
||||
soundVolume?: number;
|
||||
spawnModel?: string;
|
||||
@@ -52,6 +55,7 @@ export function TriggerObject({
|
||||
children,
|
||||
colliders = TRIGGER_DEFAULT_COLLIDERS,
|
||||
label = TRIGGER_DEFAULT_LABEL,
|
||||
radius = INTERACTION_RADIUS,
|
||||
soundPath,
|
||||
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
|
||||
spawnModel,
|
||||
@@ -59,14 +63,22 @@ export function TriggerObject({
|
||||
onTrigger,
|
||||
}: TriggerObjectProps): React.JSX.Element {
|
||||
const [spawned, setSpawned] = useState<SpawnedModel[]>([]);
|
||||
const rbRef = useRef<RapierRigidBody>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RigidBody type="fixed" colliders={colliders} position={position}>
|
||||
<RigidBody
|
||||
ref={rbRef}
|
||||
type="fixed"
|
||||
colliders={colliders}
|
||||
position={position}
|
||||
>
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={label}
|
||||
position={position}
|
||||
radius={radius}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
if (soundPath) {
|
||||
AudioManager.getInstance().playSound(soundPath, soundVolume, {
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAnimations } from "@react-three/drei";
|
||||
import type { AnimationAction } from "three";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
AnimatedModelContext,
|
||||
type AnimatedModelContextValue,
|
||||
} from "@/components/three/models/useAnimatedModel";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import type { ModelTransformProps } 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;
|
||||
@@ -40,15 +36,13 @@ export function AnimatedModel({
|
||||
onAnimationEnd,
|
||||
children,
|
||||
}: AnimatedModelProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene, animations } = useLoggedGLTF(modelPath, {
|
||||
scope: "AnimatedModel",
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
});
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
const { actions, names, mixer } = useAnimations(animations, groupRef);
|
||||
const { actions, names, mixer } = useAnimations(animations, scene);
|
||||
|
||||
const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
|
||||
const isReady = names.length > 0;
|
||||
@@ -149,19 +143,22 @@ export function AnimatedModel({
|
||||
names,
|
||||
};
|
||||
|
||||
const parsedScale =
|
||||
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
||||
useEffect(() => {
|
||||
scene.position.set(...position);
|
||||
scene.rotation.set(rotation[0], rotation[1], rotation[2]);
|
||||
|
||||
const parsedScale =
|
||||
typeof scale === "number" ? [scale, scale, scale] : (scale ?? [1, 1, 1]);
|
||||
scene.scale.set(
|
||||
parsedScale[0] ?? 1,
|
||||
parsedScale[1] ?? 1,
|
||||
parsedScale[2] ?? 1,
|
||||
);
|
||||
}, [scene, position, rotation, scale]);
|
||||
|
||||
return (
|
||||
<AnimatedModelContext.Provider value={contextValue}>
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={parsedScale}
|
||||
>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
<primitive object={scene} />
|
||||
{children}
|
||||
</AnimatedModelContext.Provider>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useFrame } from "@react-three/fiber";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { ExplodedModel } from "@/utils/three/ExplodedModel";
|
||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
@@ -12,6 +13,8 @@ interface ModelErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
modelPath: string;
|
||||
position?: Vector3Tuple | undefined;
|
||||
rotation?: Vector3Tuple | undefined;
|
||||
scale?: ModelTransformProps["scale"] | undefined;
|
||||
}
|
||||
|
||||
interface ModelErrorBoundaryState {
|
||||
@@ -37,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,
|
||||
);
|
||||
@@ -44,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;
|
||||
@@ -55,6 +66,7 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
split: boolean;
|
||||
splitDistance?: number;
|
||||
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
|
||||
}
|
||||
|
||||
export function ExplodableModel(
|
||||
@@ -65,6 +77,8 @@ export function ExplodableModel(
|
||||
key={props.modelPath}
|
||||
modelPath={props.modelPath}
|
||||
position={props.position}
|
||||
rotation={props.rotation}
|
||||
scale={props.scale}
|
||||
>
|
||||
<ExplodableModelInner {...props} />
|
||||
</ModelErrorBoundary>
|
||||
@@ -78,6 +92,7 @@ function ExplodableModelInner({
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
splitDistance = 1.2,
|
||||
onPartsReady,
|
||||
}: ExplodableModelInnerProps): React.JSX.Element {
|
||||
const { scene } = useLoggedGLTF(modelPath, {
|
||||
scope: "ExplodableModel",
|
||||
@@ -96,6 +111,10 @@ function ExplodableModelInner({
|
||||
explodedModel.setSplit(split);
|
||||
}, [explodedModel, split]);
|
||||
|
||||
useEffect(() => {
|
||||
onPartsReady?.(explodedModel.getParts());
|
||||
}, [explodedModel, onPartsReady]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
explodedModel.update(delta);
|
||||
});
|
||||
@@ -109,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface WorldVideoPromptProps {
|
||||
src: string;
|
||||
position?: Vector3Tuple;
|
||||
size?: number;
|
||||
billboard?: boolean;
|
||||
}
|
||||
|
||||
export function WorldVideoPrompt({
|
||||
src,
|
||||
position = [0, 0, 0],
|
||||
size = 96,
|
||||
billboard = true,
|
||||
}: WorldVideoPromptProps): React.JSX.Element {
|
||||
return (
|
||||
<Html
|
||||
position={position}
|
||||
center
|
||||
transform
|
||||
sprite={billboard}
|
||||
occlude={false}
|
||||
>
|
||||
<video
|
||||
aria-hidden="true"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
src={src}
|
||||
style={{
|
||||
display: "block",
|
||||
height: size,
|
||||
objectFit: "contain",
|
||||
pointerEvents: "none",
|
||||
width: size,
|
||||
}}
|
||||
/>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
|
||||
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
|
||||
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
|
||||
export function GameUI(): React.JSX.Element {
|
||||
@@ -10,6 +11,7 @@ export function GameUI(): React.JSX.Element {
|
||||
<>
|
||||
<DebugOverlayLayout />
|
||||
<Crosshair />
|
||||
<RepairMovementLockIndicator />
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<Subtitles />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
|
||||
export function RepairMovementLockIndicator(): React.JSX.Element | null {
|
||||
const cameraMode = useCameraMode();
|
||||
const movementLocked = useRepairMovementLocked();
|
||||
|
||||
if (cameraMode !== "player") return null;
|
||||
if (!movementLocked) return null;
|
||||
|
||||
return (
|
||||
<div className="repair-movement-lock-indicator" aria-live="polite">
|
||||
<span
|
||||
className="repair-movement-lock-indicator__dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Déplacement verrouillé pendant la réparation</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||
|
||||
interface SceneLoadingOverlayProps {
|
||||
state: SceneLoadingState;
|
||||
}
|
||||
|
||||
export function SceneLoadingOverlay({
|
||||
state,
|
||||
}: SceneLoadingOverlayProps): React.JSX.Element | null {
|
||||
const isReady = state.status === "ready";
|
||||
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="scene-loading-overlay__content">
|
||||
<strong>{state.currentStep}</strong>
|
||||
<div className="scene-loading-overlay__track">
|
||||
<span style={{ width: `${progress}%` }} />
|
||||
<em>{progress}%</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,16 +13,6 @@ const MAIN_STATES: MainGameState[] = [
|
||||
"outro",
|
||||
];
|
||||
|
||||
const MISSION_STEPS: MissionStep[] = [
|
||||
"locked",
|
||||
"waiting",
|
||||
"inspected",
|
||||
"fragmented",
|
||||
"scanning",
|
||||
"repairing",
|
||||
"done",
|
||||
];
|
||||
|
||||
function toPascalCase(value: string): string {
|
||||
return value
|
||||
.split(/[-_\s]+/)
|
||||
@@ -33,6 +23,9 @@ function toPascalCase(value: string): string {
|
||||
|
||||
export function GameStateDebugPanel(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
|
||||
const fermeStep = useGameStore((state) => state.ferme.currentStep);
|
||||
const detail = useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "intro":
|
||||
@@ -70,22 +63,45 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
function setDebugMainState(nextMainState: MainGameState): void {
|
||||
setMainState(nextMainState);
|
||||
|
||||
if (nextMainState === "bike" && bikeStep === "locked") {
|
||||
setBikeState({ currentStep: "waiting" });
|
||||
return;
|
||||
}
|
||||
|
||||
setOutroState({ hasStarted: nextSubState === "started" });
|
||||
if (nextMainState === "pylone" && pyloneStep === "locked") {
|
||||
setPyloneState({ currentStep: "waiting" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextMainState === "ferme" && fermeStep === "locked") {
|
||||
setFermeState({ currentStep: "waiting" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -113,7 +129,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
aria-pressed={state === mainState}
|
||||
className={state === mainState ? "is-active" : undefined}
|
||||
type="button"
|
||||
onClick={() => setMainState(state)}
|
||||
onClick={() => setDebugMainState(state)}
|
||||
>
|
||||
{toPascalCase(state)}
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -19,3 +19,32 @@ export const TEST_SCENE_TRIGGER_SEGMENTS = 32;
|
||||
export const TEST_SCENE_TRIGGER_COLOR = "#3b82f6";
|
||||
export const TEST_SCENE_TRIGGER_ROUGHNESS = 0.3;
|
||||
export const TEST_SCENE_TRIGGER_METALNESS = 0.5;
|
||||
|
||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65;
|
||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
|
||||
|
||||
export const TEST_SCENE_REPAIR_ZONES = [
|
||||
{
|
||||
mission: "bike",
|
||||
label: "Bike",
|
||||
color: "#38bdf8",
|
||||
position: [-12, 0, -12],
|
||||
},
|
||||
{
|
||||
mission: "pylone",
|
||||
label: "Pylone",
|
||||
color: "#facc15",
|
||||
position: [0, 0, -12],
|
||||
},
|
||||
{
|
||||
mission: "ferme",
|
||||
label: "Farm",
|
||||
color: "#86efac",
|
||||
position: [12, 0, -12],
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
mission: "bike" | "pylone" | "ferme";
|
||||
label: string;
|
||||
color: string;
|
||||
position: Vector3Tuple;
|
||||
}[];
|
||||
|
||||
@@ -38,17 +38,23 @@ export const docGroups: DocGroup[] = [
|
||||
subtitle: "Implementation details",
|
||||
meta: "04",
|
||||
},
|
||||
{
|
||||
path: "/docs/audio",
|
||||
title: "Audio Technical Notes",
|
||||
subtitle: "Music, dialogue, SRT, and SFX",
|
||||
meta: "05",
|
||||
},
|
||||
{
|
||||
path: "/docs/hand-tracking",
|
||||
title: "Hand Tracking Technical Notes",
|
||||
subtitle: "Webcam interaction pipeline",
|
||||
meta: "05",
|
||||
meta: "06",
|
||||
},
|
||||
{
|
||||
path: "/docs/zustand",
|
||||
title: "Zustand Game State",
|
||||
subtitle: "Progression store",
|
||||
meta: "06",
|
||||
meta: "07",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -59,25 +65,25 @@ export const docGroups: DocGroup[] = [
|
||||
path: "/docs/features",
|
||||
title: "Features",
|
||||
subtitle: "Implemented scope",
|
||||
meta: "07",
|
||||
meta: "08",
|
||||
},
|
||||
{
|
||||
path: "/docs/main-feature",
|
||||
title: "Main Feature",
|
||||
subtitle: "Repair-game prototype",
|
||||
meta: "08",
|
||||
meta: "09",
|
||||
},
|
||||
{
|
||||
path: "/docs/editor",
|
||||
title: "Editor User Guide",
|
||||
subtitle: "Editing workflow",
|
||||
meta: "09",
|
||||
meta: "10",
|
||||
},
|
||||
{
|
||||
path: "/docs/animation",
|
||||
title: "Animation & 3D Model System",
|
||||
subtitle: "Components and usage",
|
||||
meta: "010",
|
||||
meta: "11",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+162
-108
@@ -98,9 +98,20 @@ Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
|
||||
- soit la carte principale, soit la scène de test physique debug
|
||||
- le rig joueur quand le mode caméra actif est \`player\`
|
||||
- \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision.
|
||||
- \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique.
|
||||
- \`src/world/GameStageContent.tsx\` est enveloppé dans le contexte Rapier \`Physics\` dans la scène de jeu de production afin que les objets gameplay de stage puissent utiliser la physique sans migrer la carte ou le joueur vers Rapier. Il monte maintenant des instances réutilisables de \`RepairGame\` pour les états de mission \`bike\`, \`pylone\` et \`ferme\`.
|
||||
- \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique, avec les objets existants de grab, trigger et preview de modèle, plus des zones playground de réparation séparées \`Bike\`, \`Pylone\` et \`Farm\`.
|
||||
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
|
||||
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut et les inputs d'interaction.
|
||||
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut, le verrouillage de déplacement pendant les étapes repair et les inputs d'interaction.
|
||||
|
||||
## Frontières physiques
|
||||
|
||||
Le projet utilise actuellement deux couches de collision avec des responsabilités séparées :
|
||||
|
||||
- \`GameMap\` construit une octree utilisée par le contrôleur joueur pour les collisions avec la carte.
|
||||
- \`GameStageContent\` est enveloppé dans Rapier \`Physics\` pour les objets gameplay comme les triggers de réparation, les mallettes, les objets saisissables et les futurs objets spécifiques aux missions.
|
||||
- \`TestMap\` possède son propre playground Rapier \`Physics\` afin de peaufiner le gameplay de réparation par state de mission sans dépendre du placement de la carte de production.
|
||||
|
||||
Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il n'existe pas de plan de migration volontaire. Cela évite de mélanger les règles de déplacement joueur avec la physique d'objets avant que les systèmes gameplay en aient besoin.
|
||||
|
||||
## Modèle d'interaction
|
||||
|
||||
@@ -166,13 +177,21 @@ Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
|
||||
- \`src/components/debug/scene/DebugCameraControls.tsx\` monte la caméra libre debug.
|
||||
- Les contrôles globaux \`lil-gui\` incluent camera mode, scene mode, \`R3F Perf\` et \`Debug Overlay\`; les contrôles d'interaction vivent dans le dossier \`Interaction\`.
|
||||
|
||||
## Domaines de composants 3D
|
||||
|
||||
- \`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, les étapes de réparation et les prompts.
|
||||
- \`src/components/three/world/\` contient les objets world/environnement réutilisables comme \`SkyModel\`.
|
||||
|
||||
## Limites actuelles
|
||||
|
||||
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
|
||||
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
|
||||
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
|
||||
- Les systèmes de missions et zones ne sont pas implémentés.
|
||||
- Les branches de dialogue et l'orchestration gameplay restent limitées.
|
||||
- L'état de mission existe dans Zustand et le flow de réparation est implémenté comme prototype pour les missions de réparation actuelles.
|
||||
- Les cinématiques et dialogues existent comme systèmes prototype pilotés par timecode; les branches de dialogue et l'orchestration gameplay globale restent limitées.
|
||||
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
|
||||
`;
|
||||
|
||||
@@ -319,7 +338,7 @@ Le store expose :
|
||||
Les étapes de mission utilisent actuellement cette séquence :
|
||||
|
||||
\`\`\`ts
|
||||
"locked" | "waiting" | "inspected" | "fragmented" | "scanning" | "repairing" | "done"
|
||||
"locked" | "waiting" | "inspected" | "fragmented" | "scanning" | "repairing" | "reassembling" | "done"
|
||||
\`\`\`
|
||||
|
||||
## Lire le state dans un composant
|
||||
@@ -358,10 +377,30 @@ setMainState("bike");
|
||||
|
||||
Les setters directs sont pratiques pour les panneaux debug, mais le gameplay de production devrait préférer les actions métier comme \`advanceGameState\`, \`completeBike\` ou \`completePylone\`.
|
||||
|
||||
Le gameplay de mission qui peut cibler \`bike\`, \`pylone\` ou \`ferme\` doit préférer les actions génériques de mission :
|
||||
|
||||
\`\`\`ts
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
|
||||
setMissionStep("bike", "inspected");
|
||||
completeMission("bike");
|
||||
\`\`\`
|
||||
|
||||
Cela évite aux composants gameplay réutilisables, comme les flows de réparation, de dupliquer des branches spécifiques à chaque mission avec \`setBikeState\`, \`setPyloneState\` et \`setFermeState\`.
|
||||
|
||||
## Intégration avec le World
|
||||
|
||||
\`src/world/GameStageContent.tsx\` s'abonne à \`mainState\` et monte le contenu spécifique au state courant.
|
||||
|
||||
Pour les missions de réparation, il monte le composant réutilisable \`RepairGame\` avec un id de mission :
|
||||
|
||||
\`\`\`tsx
|
||||
<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\`. 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 :
|
||||
|
||||
\`\`\`tsx
|
||||
@@ -391,6 +430,7 @@ Overlays actuels :
|
||||
- \`GameStateDebugPanel\` : panneau de progression debug pour consulter/changer le main state, le sub state, avancer/reculer et reset le store
|
||||
- \`Crosshair\` : aide de visée joueur
|
||||
- \`InteractPrompt\` : prompt d'interaction
|
||||
- \`RepairMovementLockIndicator\` : indicateur joueur affiché quand les étapes repair désactivent temporairement le déplacement
|
||||
|
||||
\`src/pages/page.tsx\` doit rester fin et monter seulement le canvas et \`GameUI\`.
|
||||
|
||||
@@ -405,7 +445,7 @@ Overlays actuels :
|
||||
|
||||
## Prochaines étapes
|
||||
|
||||
La prochaine étape naturelle est de remplacer les ancres temporaires de \`GameStageContent\` par de vrais composants de phase, par exemple \`IntroContent\`, \`BikeContent\`, \`PyloneContent\`, \`FermeContent\` et \`OutroContent\`.
|
||||
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
|
||||
@@ -416,7 +456,8 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
|
||||
- Scène React Three Fiber plein écran
|
||||
- Carte principale chargée depuis \`public/models/{name}/model.glb\`, avec fallback vers \`model.gltf\`
|
||||
- Scène de test physique debug sélectionnable depuis le panneau debug
|
||||
- Scène de test physique debug sélectionnable depuis le panneau debug, avec tests grab/trigger, preview de modèle animé et zones playground de réparation séparées pour \`bike\`, \`pylone\` et \`ferme\`
|
||||
- Contexte physique Rapier disponible pour les objets gameplay de stage en production
|
||||
- Éclairage ambiant et directionnel
|
||||
- Configuration de l'environnement de fond
|
||||
|
||||
@@ -426,6 +467,7 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
- Orientation souris avec pointer lock
|
||||
- Déplacement avec \`ZQSD\`
|
||||
- Saut
|
||||
- Verrouillage du déplacement pendant les étapes repair actives, avec indicateur à l'écran tout en gardant les interactions trigger disponibles
|
||||
- Collision basée sur une octree contre la carte chargée
|
||||
|
||||
## Interactions
|
||||
@@ -433,8 +475,16 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
- Détection de focus par distance et raycast
|
||||
- Interactions trigger activées avec \`E\`
|
||||
- Interactions grab activées avec le bouton principal de la souris
|
||||
- Les objets gameplay avec physique peuvent être montés dans le contenu de stage sans remplacer la collision octree du joueur
|
||||
- Prompt d'interaction affiché pour les interactions trigger
|
||||
|
||||
## Gameplay de réparation
|
||||
|
||||
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
|
||||
- Le playground physics debug monte le même \`RepairGame\` réutilisable dans des zones \`Bike\`, \`Pylone\` et \`Farm\`, afin de peaufiner chaque state avec un placement isolé avant déplacement vers la carte de production
|
||||
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`, avec nodes cassés, placeholders cibles, timing de scan et timing de réassemblage propres à chaque mission
|
||||
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, indicateur de verrouillage de déplacement pendant la réparation active, interaction trigger sur la mallette, traverse des placeholders de mallette, placement avec snap vers placeholder, feedback de dépôt des pièces cassées, touche \`E\`, hold deux poings, transition de modèle explosé, réassemblage inverse avec particules, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, feedback de validation de la bonne pièce et complétion de mission
|
||||
|
||||
## Audio
|
||||
|
||||
- Volumes par catégorie pour la musique, les SFX et les dialogues
|
||||
@@ -474,6 +524,7 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
- Le paramètre \`?debug\` active le panneau debug
|
||||
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène, \`R3F Perf\`, \`Debug Overlay\` et le tuning d'interaction
|
||||
- Overlay debug compact pour les contrôles de game state et le statut hand tracking
|
||||
- Le changement de mission dans le panneau game-state debug déverrouille les missions repair encore \`locked\` à \`waiting\` pour accélérer les tests
|
||||
- Helpers de scène debug
|
||||
- Caméra libre debug
|
||||
- Overlay \`r3f-perf\`
|
||||
@@ -497,7 +548,7 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
|
||||
## Pas encore implémenté
|
||||
|
||||
- système de missions
|
||||
- système de missions complet
|
||||
- système de zones
|
||||
- branches de dialogues gameplay au-delà des déclencheurs prototype actuels
|
||||
- flow de chargement
|
||||
@@ -505,142 +556,145 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
- séparation complète production / debug pour les scènes gameplay
|
||||
`;
|
||||
|
||||
export const editorFr = `# Éditeur de carte
|
||||
export const editorFr = `# Guide utilisateur de l'éditeur
|
||||
|
||||
L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur.
|
||||
L'éditeur est disponible sur \`/editor\`. Il sert à modifier la carte runtime, les cinématiques, le manifeste de dialogues et les sous-titres SRT sans éditer tous les fichiers à la main.
|
||||
|
||||
## Ce qui est édité
|
||||
## À quoi il sert
|
||||
|
||||
L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json".
|
||||
Utilise l'éditeur pour :
|
||||
|
||||
Chaque node décrit un objet de la scène :
|
||||
- déplacer, tourner ou scaler les objets de \`public/map.json\`
|
||||
- inspecter le JSON généré avant export ou sauvegarde
|
||||
- prévisualiser et modifier \`public/cinematics.json\`
|
||||
- créer, prévisualiser et valider les dialogues de \`public/sounds/dialogue/dialogues.json\`
|
||||
- modifier les fichiers SRT FR/EN par voix
|
||||
|
||||
- "name" : nom du dossier modèle dans "/public/models/{name}/model.glb", avec fallback vers "model.gltf"
|
||||
- "type" : catégorie de l'objet
|
||||
- "position" : "[x, y, z]"
|
||||
- "rotation" : "[x, y, z]"
|
||||
- "scale" : "[x, y, z]"
|
||||
## Organisation du panneau
|
||||
|
||||
Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable.
|
||||
Le panneau latéral est divisé en groupes repliables :
|
||||
|
||||
## Workflow de base
|
||||
- \`Editor\` : transforms, raccourcis, sélection, vue, JSON et actions fichier.
|
||||
- \`Cinematics\` : cinématiques et keyframes caméra.
|
||||
- \`Dialogues\` : manifeste des dialogues.
|
||||
- \`SRT\` : fichiers de sous-titres par voix et langue.
|
||||
|
||||
1. Ouvrir "/editor".
|
||||
2. Sélectionner un objet dans la vue 3D.
|
||||
3. Choisir un mode de transformation : translation, rotation ou scale.
|
||||
4. Déplacer la gizmo de transformation.
|
||||
5. Utiliser undo ou redo si nécessaire.
|
||||
6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev.
|
||||
## Carte et transforms
|
||||
|
||||
## Contrôles
|
||||
1. Ouvre \`/editor\`.
|
||||
2. Clique un objet pour le sélectionner.
|
||||
3. Choisis \`Translate\`, \`Rotate\` ou \`Scale\`.
|
||||
4. Déplace la gizmo dans la vue 3D.
|
||||
5. Vérifie le bloc \`JSON\` si tu veux contrôler les valeurs exactes.
|
||||
6. Utilise \`Undo\` ou \`Redo\` si besoin.
|
||||
7. Utilise \`Export JSON\` ou \`Save to server\`.
|
||||
|
||||
Contrôles utiles :
|
||||
|
||||
| Action | Input |
|
||||
| --- | --- |
|
||||
| Sélectionner un objet | Clic sur l'objet |
|
||||
| Désélectionner | "Esc" ou clic dans le vide |
|
||||
| Mode translation | "T" |
|
||||
| Mode rotation | "R" |
|
||||
| Mode scale | "S" |
|
||||
| Undo | "Ctrl+Z" |
|
||||
| Redo | "Ctrl+Y" |
|
||||
| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches |
|
||||
| Monter / descendre | "Space", "Shift" |
|
||||
| Sélectionner | Clic objet |
|
||||
| Désélectionner | \`Esc\` ou clic vide |
|
||||
| Verrouiller la sélection | bouton lock |
|
||||
| Vider la sélection | bouton \`X\` |
|
||||
| Translate | \`T\` |
|
||||
| Rotate | \`R\` |
|
||||
| Scale | \`S\` |
|
||||
| Undo / redo | \`Ctrl+Z\` / \`Ctrl+Y\` |
|
||||
| Déplacement vue verrouillée | \`WASD\`, \`ZQSD\`, flèches |
|
||||
|
||||
## Actions fichier
|
||||
Quand la sélection est verrouillée, cliquer un autre objet, cliquer dans le vide ou appuyer sur \`Esc\` ne change pas la sélection. Le bouton \`X\` reste le moyen volontaire de la vider.
|
||||
|
||||
### Export JSON
|
||||
## Inspecteur JSON
|
||||
|
||||
"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json".
|
||||
Le bloc \`JSON\` affiche le JSON qui sera exporté ou sauvegardé :
|
||||
|
||||
### Save to server
|
||||
- sans sélection, il affiche toute la liste de nodes
|
||||
- avec une sélection, il affiche les lignes du node sélectionné
|
||||
|
||||
"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite.
|
||||
Cet inspecteur est en lecture seule. Les valeurs changent via la gizmo de transformation.
|
||||
|
||||
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
|
||||
## Sauvegarde
|
||||
|
||||
## Éditer les dialogues et sous-titres
|
||||
- \`Export JSON\` télécharge un \`map.json\` local.
|
||||
- \`Save to server\` écrit directement \`public/map.json\` via le serveur Vite local.
|
||||
|
||||
Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres.
|
||||
Les sauvegardes serveur sont des helpers de développement, pas des APIs de production.
|
||||
|
||||
### Manifeste dialogues
|
||||
## Cinématiques
|
||||
|
||||
Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main.
|
||||
Le groupe \`Cinematics\` édite \`public/cinematics.json\`.
|
||||
|
||||
- \`Reload\` recharge le manifeste depuis le disque.
|
||||
- \`Add\` crée un dialogue local pour la voix courante et assigne le prochain index SRT disponible.
|
||||
- \`Save\` écrit le manifeste via le serveur Vite local.
|
||||
- \`Preview dialogue\` joue le dialogue sélectionné avec les sous-titres dans l'éditeur.
|
||||
- \`Create FR SRT cue\` crée la cue française si elle manque.
|
||||
- \`Delete dialogue\` supprime localement l'entrée sélectionnée.
|
||||
|
||||
Après \`Add\`, il faut cliquer \`Save\` pour conserver le dialogue dans le manifeste. La cue SRT FR est écrite directement, mais le manifeste reste local tant qu'il n'est pas sauvegardé.
|
||||
|
||||
Les nouveaux dialogues utilisent un chemin audio placeholder comme \`/sounds/dialogue/new_dialogue_24.mp3\`. Remplace-le par un vrai MP3 avant validation finale.
|
||||
|
||||
### Éditeur SRT
|
||||
|
||||
1. Choisir une voix : \`narrateur\`, \`fermier\` ou \`electricienne\`.
|
||||
2. Choisir une langue : \`FR\` ou \`EN\`.
|
||||
3. Modifier le texte SRT directement dans la textarea.
|
||||
4. Utiliser la preview audio pour vérifier le dialogue sélectionné.
|
||||
5. Utiliser \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster le timing de la cue sélectionnée avec l'audio.
|
||||
6. Utiliser \`Save SRT\` en développement local, ou \`Export SRT\` pour télécharger le fichier manuellement.
|
||||
|
||||
Chaque fichier SRT appartient à une voix, pas à un dialogue. Les indexes de cue doivent correspondre aux valeurs \`subtitleCueIndex\` référencées par le manifeste de dialogues.
|
||||
|
||||
## Valider les assets de dialogue
|
||||
|
||||
Utilise \`Validate\` dans le panneau SRT pour vérifier le manifeste et les assets liés.
|
||||
|
||||
La validation vérifie :
|
||||
|
||||
- \`public/sounds/dialogue/dialogues.json\`
|
||||
- les fichiers audio de dialogue référencés
|
||||
- les fichiers SRT français
|
||||
- les indexes de cue référencés par le manifeste
|
||||
|
||||
Les fichiers SRT anglais manquants sont des warnings parce que le runtime retombe sur les sous-titres français.
|
||||
|
||||
## Éditer les cinématiques
|
||||
|
||||
Le panneau \`Cinematics\` permet d'éditer \`public/cinematics.json\`.
|
||||
|
||||
Chaque cinématique contient :
|
||||
Une cinématique contient :
|
||||
|
||||
- un \`id\`
|
||||
- un \`timecode\` global optionnel
|
||||
- au moins deux keyframes caméra
|
||||
- des dialogue cues optionnelles synchronisées avec la timeline
|
||||
- des \`dialogueCues\` optionnelles
|
||||
|
||||
Les keyframes caméra définissent un temps relatif, une position caméra et une cible de regard. Les dialogue cues définissent un temps relatif et un \`dialogueId\` issu de \`dialogues.json\`.
|
||||
Workflow conseillé :
|
||||
|
||||
Actions disponibles :
|
||||
1. Sélectionne une cinématique ou clique \`Add\`.
|
||||
2. Donne un \`id\` stable.
|
||||
3. Ajoute ou ajuste les keyframes caméra.
|
||||
4. Garde les temps de keyframes dans l'ordre croissant.
|
||||
5. Ajoute des \`dialogueCues\` si un dialogue doit démarrer pendant la cinématique.
|
||||
6. Clique \`Preview cinematic\` pour tester la caméra.
|
||||
7. Clique \`Save\`.
|
||||
|
||||
- \`Reload\` recharge le manifeste.
|
||||
- \`Add\` crée une cinématique locale avec deux keyframes.
|
||||
- \`Save\` écrit \`public/cinematics.json\` via le serveur Vite local.
|
||||
- \`Preview cinematic\` joue l'animation caméra dans le canvas éditeur.
|
||||
- \`Add keyframe\` et \`Remove\` modifient le chemin caméra.
|
||||
- \`Add dialogue\` et \`Remove\` modifient les dialogues synchronisés.
|
||||
- \`Delete cinematic\` supprime localement la cinématique sélectionnée.
|
||||
Les temps de keyframes et de dialogue cues sont relatifs au début de la cinématique.
|
||||
|
||||
Les dialogue cues sont la manière recommandée de synchroniser un dialogue avec une cinématique. Évite de donner aussi un \`timecode\` global au même dialogue dans \`dialogues.json\`, sinon il peut être lancé deux fois.
|
||||
## Dialogues
|
||||
|
||||
## Inspecteur JSON
|
||||
Le groupe \`Dialogues\` édite \`public/sounds/dialogue/dialogues.json\`.
|
||||
|
||||
Le panneau latéral affiche le JSON brut de la carte :
|
||||
Chaque dialogue contient :
|
||||
|
||||
- sans sélection, il affiche toute la liste des nodes
|
||||
- avec un objet sélectionné, il met en évidence les lignes du node sélectionné
|
||||
- \`id\` : identifiant stable utilisé par les cinématiques et le runtime
|
||||
- \`voice\` : \`narrateur\`, \`fermier\` ou \`electricienne\`
|
||||
- \`audio\` : chemin MP3 runtime
|
||||
- \`subtitleCueIndex\` : numéro de cue dans le SRT de la voix
|
||||
- \`timecode\` : déclenchement global optionnel
|
||||
|
||||
Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde.
|
||||
Workflow conseillé pour créer un dialogue :
|
||||
|
||||
1. Clique \`Add\`.
|
||||
2. Choisis la bonne voix.
|
||||
3. Remplace l'\`id\` généré par un ID lisible.
|
||||
4. Remplace le chemin audio placeholder par le vrai MP3.
|
||||
5. Vérifie le \`subtitleCueIndex\`.
|
||||
6. Clique \`Create FR SRT cue\` si la cue manque.
|
||||
7. Clique \`Save\`.
|
||||
8. Passe dans \`SRT\` pour écrire le texte et régler les timings.
|
||||
9. Lance \`Validate\`.
|
||||
|
||||
## SRT
|
||||
|
||||
Le groupe \`SRT\` édite un fichier de sous-titres à la fois.
|
||||
|
||||
1. Choisis une voix.
|
||||
2. Choisis \`FR\` ou \`EN\`.
|
||||
3. Écris le texte SRT.
|
||||
4. Prévisualise l'audio.
|
||||
5. Utilise \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster les timings.
|
||||
6. Clique \`Save SRT\` ou \`Export SRT\`.
|
||||
|
||||
Il y a un fichier SRT par voix et par langue, pas un fichier par dialogue. Les timings SRT sont relatifs au fichier audio du dialogue.
|
||||
|
||||
## Validation
|
||||
|
||||
\`Validate\` vérifie :
|
||||
|
||||
- le manifeste \`dialogues.json\`
|
||||
- les fichiers audio référencés
|
||||
- les fichiers SRT français
|
||||
- les indexes de cues référencés
|
||||
- les SRT anglais en warning si manquants
|
||||
|
||||
## Limites actuelles
|
||||
|
||||
- L'éditeur modifie uniquement les nodes existants.
|
||||
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
|
||||
- La sauvegarde production n'est pas implémentée.
|
||||
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
|
||||
- La sauvegarde SRT est un helper local du serveur Vite, pas une API backend de production.
|
||||
- Les sauvegardes dialogues et cinématiques sont aussi des helpers locaux du serveur Vite.
|
||||
- L'éditeur modifie les nodes existants mais ne crée pas encore d'objet de carte.
|
||||
- Les sauvegardes serveur sont limitées au développement local.
|
||||
- L'éditeur SRT reste textuel, sans waveform.
|
||||
- Les modèles manquants sont représentés par des cubes de fallback.
|
||||
`;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const REPAIR_CASE_MODEL_PATH = "/models/packderelance/model.gltf";
|
||||
export const REPAIR_CASE_OPEN_SOUND_PATH = "/sounds/effect/open-malette.mp3";
|
||||
export const REPAIR_CASE_CLOSE_SOUND_PATH = "/sounds/effect/close-malette.mp3";
|
||||
@@ -6,6 +8,10 @@ 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_EXIT_DURATION = 0.5;
|
||||
export const REPAIR_CASE_EXIT_Y_OFFSET = -0.35;
|
||||
|
||||
export const REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE = 5;
|
||||
export const REPAIR_CASE_FLOAT_HEIGHT = 1;
|
||||
@@ -13,3 +19,11 @@ export const REPAIR_CASE_FLOAT_UP_SPEED = 2.4;
|
||||
export const REPAIR_CASE_FLOAT_DOWN_SPEED = 1.8;
|
||||
export const REPAIR_CASE_ROTATION_RESET_SPEED = 3;
|
||||
export const REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES = 5;
|
||||
|
||||
export const REPAIR_CASE_FOCUS_POSITION = [
|
||||
0, 1.05, 2.05,
|
||||
] satisfies Vector3Tuple;
|
||||
export const REPAIR_CASE_FOCUS_SCALE = 2.25;
|
||||
export const REPAIR_CASE_PLACEHOLDER_NAME_PREFIX = "placeholder_";
|
||||
export const REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS = 0.65;
|
||||
export const REPAIR_CASE_PLACEHOLDER_SNAP_DURATION = 0.25;
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
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_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 }>;
|
||||
export const REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS = 1;
|
||||
export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4;
|
||||
export const REPAIR_INTERACTION_RADIUS = 10;
|
||||
export const REPAIR_SCAN_PART_SECONDS = 1.2;
|
||||
export const REPAIR_REASSEMBLY_SECONDS = 1.4;
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type {
|
||||
ModelTransformProps,
|
||||
Vector3Scale,
|
||||
Vector3Tuple,
|
||||
} from "@/types/three/three";
|
||||
|
||||
export interface RepairMissionCaseConfig {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Scale;
|
||||
}
|
||||
|
||||
export interface RepairMissionPartConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeName?: string;
|
||||
placeholderName?: string;
|
||||
modelPath?: string;
|
||||
}
|
||||
|
||||
export interface RepairMissionConfig {
|
||||
id: RepairMissionId;
|
||||
label: string;
|
||||
description: string;
|
||||
modelPath: string;
|
||||
modelScale?: ModelTransformProps["scale"];
|
||||
stageUiPath: string;
|
||||
interactUiPath: string;
|
||||
brokenUiPath: string;
|
||||
case: RepairMissionCaseConfig;
|
||||
reassemblySeconds?: number;
|
||||
requiredReplacementPartId: string;
|
||||
scanPartSeconds?: number;
|
||||
brokenParts: readonly RepairMissionPartConfig[];
|
||||
replacementParts: readonly RepairMissionPartConfig[];
|
||||
}
|
||||
|
||||
const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm";
|
||||
const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm";
|
||||
|
||||
const DEFAULT_REPAIR_CASE = {
|
||||
position: [0, 0.4, 1.8],
|
||||
rotation: [0, 0, 0],
|
||||
scale: 1.5,
|
||||
} satisfies RepairMissionCaseConfig;
|
||||
|
||||
export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
bike: {
|
||||
id: "bike",
|
||||
label: "E-bike",
|
||||
description:
|
||||
"Repair the damaged cooling module before relaunching the bike",
|
||||
modelPath: "/models/ebike/model.gltf",
|
||||
modelScale: 0.0055,
|
||||
stageUiPath: "/assets/UI/ebike.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
requiredReplacementPartId: "bike-cooling-core-replacement",
|
||||
brokenParts: [
|
||||
{
|
||||
id: "bike-cooling-core",
|
||||
label: "Cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
nodeName: "refroidisseur",
|
||||
placeholderName: "placeholder_1",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "bike-cooling-core-replacement",
|
||||
label: "Replacement cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "bike-radio-decoy",
|
||||
label: "Radio module",
|
||||
modelPath: "/models/talkie/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "bike-glove-decoy",
|
||||
label: "Insulation glove",
|
||||
modelPath: "/models/gant_l/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
pylone: {
|
||||
id: "pylone",
|
||||
label: "Power pylon",
|
||||
description:
|
||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
stageUiPath: "/assets/UI/centrale.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
reassemblySeconds: 1.8,
|
||||
requiredReplacementPartId: "pylone-grid-relay-replacement",
|
||||
scanPartSeconds: 1.4,
|
||||
brokenParts: [
|
||||
{
|
||||
id: "pylone-grid-relay",
|
||||
label: "Grid relay",
|
||||
nodeName: "lampe",
|
||||
placeholderName: "placeholder_1",
|
||||
},
|
||||
{
|
||||
id: "pylone-damaged-panel",
|
||||
label: "Damaged solar panel",
|
||||
nodeName: "panneau2",
|
||||
placeholderName: "placeholder_2",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "pylone-grid-relay-replacement",
|
||||
label: "Replacement grid relay",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "pylone-stone-decoy",
|
||||
label: "Stone counterweight",
|
||||
modelPath: "/models/galet/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "pylone-cooling-decoy",
|
||||
label: "Cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
ferme: {
|
||||
id: "ferme",
|
||||
label: "Vertical farm",
|
||||
description:
|
||||
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
stageUiPath: "/assets/UI/laferme.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
reassemblySeconds: 1.2,
|
||||
requiredReplacementPartId: "ferme-irrigation-pump-replacement",
|
||||
scanPartSeconds: 0.9,
|
||||
brokenParts: [
|
||||
{
|
||||
id: "ferme-irrigation-pump",
|
||||
label: "Irrigation pump",
|
||||
placeholderName: "placeholder_1",
|
||||
},
|
||||
{
|
||||
id: "ferme-humidity-sensor",
|
||||
label: "Humidity sensor",
|
||||
placeholderName: "placeholder_2",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "ferme-irrigation-pump-replacement",
|
||||
label: "Replacement irrigation pump",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "ferme-tree-decoy",
|
||||
label: "Tree sensor",
|
||||
modelPath: "/models/sapin/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "ferme-radio-decoy",
|
||||
label: "Radio module",
|
||||
modelPath: "/models/talkie/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -11,5 +11,5 @@ export const PLAYER_MAX_DELTA = 0.05;
|
||||
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
|
||||
export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
||||
|
||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 100, 0];
|
||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
|
||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import { INTERACT_KEY } from "@/data/input/keybindings";
|
||||
import { useBothFistsHold } from "@/hooks/handTracking/useBothFistsHold";
|
||||
|
||||
interface UseRepairFragmentationInputOptions {
|
||||
enabled: boolean;
|
||||
keyboardEnabled?: boolean;
|
||||
onFragment: () => void;
|
||||
}
|
||||
|
||||
export function useRepairFragmentationInput({
|
||||
enabled,
|
||||
keyboardEnabled = true,
|
||||
onFragment,
|
||||
}: UseRepairFragmentationInputOptions): void {
|
||||
const completedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) return;
|
||||
|
||||
completedRef.current = false;
|
||||
}, [enabled]);
|
||||
|
||||
const fragment = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
if (completedRef.current) return;
|
||||
|
||||
completedRef.current = true;
|
||||
onFragment();
|
||||
}, [enabled, onFragment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !keyboardEnabled) return undefined;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key.toLowerCase() !== INTERACT_KEY) return;
|
||||
|
||||
event.preventDefault();
|
||||
fragment();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [enabled, fragment, keyboardEnabled]);
|
||||
|
||||
useBothFistsHold({
|
||||
enabled,
|
||||
holdSeconds: REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS,
|
||||
onComplete: fragment,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type {
|
||||
MissionStep,
|
||||
RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
|
||||
export function useRepairMissionStep(mission: RepairMissionId): MissionStep {
|
||||
return useGameStore((state) => state[mission].currentStep);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||
|
||||
export function useRepairMovementLocked(): boolean {
|
||||
return false;
|
||||
|
||||
return useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "bike":
|
||||
return isRepairMovementLocked(state.bike.currentStep);
|
||||
case "pylone":
|
||||
return isRepairMovementLocked(state.pylone.currentStep);
|
||||
case "ferme":
|
||||
return isRepairMovementLocked(state.ferme.currentStep);
|
||||
case "intro":
|
||||
case "outro":
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isRepairMovementLocked(step: MissionStep): boolean {
|
||||
return (
|
||||
step === "inspected" ||
|
||||
step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
step === "repairing" ||
|
||||
step === "reassembling"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
|
||||
interface UseBothFistsHoldOptions {
|
||||
enabled: boolean;
|
||||
holdSeconds: number;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function useBothFistsHold({
|
||||
enabled,
|
||||
holdSeconds,
|
||||
onComplete,
|
||||
}: UseBothFistsHoldOptions): void {
|
||||
const { hands } = useHandTrackingSnapshot();
|
||||
const elapsedRef = useRef(0);
|
||||
const completedRef = useRef(false);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete;
|
||||
}, [onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) return;
|
||||
|
||||
elapsedRef.current = 0;
|
||||
completedRef.current = false;
|
||||
}, [enabled]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!enabled) return;
|
||||
if (completedRef.current) return;
|
||||
|
||||
const fistCount = hands.filter((hand) => hand.isFist).length;
|
||||
if (fistCount < 2) {
|
||||
elapsedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
elapsedRef.current += delta;
|
||||
if (elapsedRef.current < holdSeconds) return;
|
||||
|
||||
completedRef.current = true;
|
||||
onCompleteRef.current();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useOctreeGraphNode(
|
||||
if (!enabled) return;
|
||||
|
||||
const graphNode = graphNodeRef.current;
|
||||
if (octreeBuilt.current || !graphNode) return;
|
||||
if (!enabled || octreeBuilt.current || !graphNode) return;
|
||||
octreeBuilt.current = true;
|
||||
|
||||
graphNode.updateMatrixWorld(true);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Octree } from "three/addons/math/Octree.js";
|
||||
import type { SceneMode } from "@/types/debug/debug";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
|
||||
interface UseWorldSceneLoadingOptions {
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
sceneMode: SceneMode;
|
||||
}
|
||||
|
||||
interface UseWorldSceneLoadingResult {
|
||||
octree: Octree | null;
|
||||
gameplayReady: boolean;
|
||||
showGameStage: boolean;
|
||||
handleGameStageLoaded: () => void;
|
||||
handleGameMapLoaded: () => void;
|
||||
handleOctreeReady: (octree: Octree) => void;
|
||||
}
|
||||
|
||||
export function useWorldSceneLoading({
|
||||
onLoadingStateChange,
|
||||
sceneMode,
|
||||
}: UseWorldSceneLoadingOptions): UseWorldSceneLoadingResult {
|
||||
const [octree, setOctree] = useState<Octree | null>(null);
|
||||
const [gameMapLoaded, setGameMapLoaded] = useState(false);
|
||||
const [gameStageLoaded, setGameStageLoaded] = useState(false);
|
||||
const showGameStage = sceneMode === "game" && gameMapLoaded;
|
||||
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
|
||||
const sceneReady =
|
||||
(sceneMode === "game" && gameplayReady) ||
|
||||
(sceneMode === "physics" && octree !== null);
|
||||
|
||||
const handleGameMapLoaded = useCallback(() => {
|
||||
setGameMapLoaded(true);
|
||||
}, []);
|
||||
|
||||
const handleGameStageLoaded = useCallback(() => {
|
||||
setGameStageLoaded(true);
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Initialisation gameplay",
|
||||
progress: 0.96,
|
||||
status: "loading",
|
||||
});
|
||||
}, [onLoadingStateChange]);
|
||||
|
||||
const handleOctreeReady = useCallback(
|
||||
(nextOctree: Octree) => {
|
||||
setOctree(nextOctree);
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Collision prête",
|
||||
progress: 0.92,
|
||||
status: "loading",
|
||||
});
|
||||
},
|
||||
[onLoadingStateChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Initialisation du jeu",
|
||||
progress: 0,
|
||||
status: "loading",
|
||||
});
|
||||
}, [onLoadingStateChange, sceneMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sceneReady) return undefined;
|
||||
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Gameplay prêt",
|
||||
progress: 0.96,
|
||||
status: "loading",
|
||||
});
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Gameplay prêt",
|
||||
progress: 1,
|
||||
status: "ready",
|
||||
});
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [onLoadingStateChange, sceneReady]);
|
||||
|
||||
return {
|
||||
octree,
|
||||
gameplayReady,
|
||||
showGameStage,
|
||||
handleGameStageLoaded,
|
||||
handleGameMapLoaded,
|
||||
handleOctreeReady,
|
||||
};
|
||||
}
|
||||
+101
-40
@@ -397,6 +397,107 @@ canvas {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.repair-movement-lock-indicator {
|
||||
position: fixed;
|
||||
top: 22px;
|
||||
left: 50%;
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(5, 9, 16, 0.72);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.repair-movement-lock-indicator__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #38bdf8;
|
||||
box-shadow: 0 0 14px rgba(56, 189, 248, 0.86);
|
||||
}
|
||||
|
||||
.scene-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 640ms ease;
|
||||
}
|
||||
|
||||
.scene-loading-overlay--ready {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scene-loading-overlay__content {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 18px;
|
||||
width: min(360px, calc(100vw - 48px));
|
||||
padding: 28px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.scene-loading-overlay strong {
|
||||
color: #1e293b;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.scene-loading-overlay__track {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.scene-loading-overlay__track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2563eb, #38bdf8);
|
||||
border-radius: inherit;
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.scene-loading-overlay__track em {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
/* Subtitles */
|
||||
.subtitles {
|
||||
position: fixed;
|
||||
@@ -719,46 +820,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 the browser 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,4 +1,4 @@
|
||||
import { logger } from "@/utils/core/logger";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
export type AudioCategory = "music" | "sfx" | "dialogue";
|
||||
export type OneShotAudioCategory = Exclude<AudioCategory, "music">;
|
||||
@@ -195,7 +195,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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +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 MissionStep =
|
||||
| "locked"
|
||||
| "waiting"
|
||||
| "inspected"
|
||||
| "fragmented"
|
||||
| "scanning"
|
||||
| "repairing"
|
||||
| "done";
|
||||
export type { MissionStep, RepairMissionId };
|
||||
|
||||
interface IntroState {
|
||||
dialogueAudio: string | null;
|
||||
@@ -48,10 +46,12 @@ interface GameActions {
|
||||
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
|
||||
setFermeState: (ferme: Partial<GameState["ferme"]>) => void;
|
||||
setOutroState: (outro: Partial<GameState["outro"]>) => void;
|
||||
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
|
||||
completeIntro: () => void;
|
||||
completeBike: () => void;
|
||||
completePylone: () => void;
|
||||
completeFerme: () => void;
|
||||
completeMission: (mission: RepairMissionId) => void;
|
||||
startOutro: () => void;
|
||||
advanceGameState: () => void;
|
||||
rewindGameState: () => void;
|
||||
@@ -74,6 +74,8 @@ function getNextMissionStep(step: MissionStep): MissionStep {
|
||||
case "scanning":
|
||||
return "repairing";
|
||||
case "repairing":
|
||||
return "reassembling";
|
||||
case "reassembling":
|
||||
case "done":
|
||||
return "done";
|
||||
}
|
||||
@@ -92,8 +94,10 @@ function getPreviousMissionStep(step: MissionStep): MissionStep {
|
||||
return "fragmented";
|
||||
case "repairing":
|
||||
return "scanning";
|
||||
case "done":
|
||||
case "reassembling":
|
||||
return "repairing";
|
||||
case "done":
|
||||
return "reassembling";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +161,56 @@ function completeFermeState(state: GameState): GameStateUpdate {
|
||||
};
|
||||
}
|
||||
|
||||
function setMissionStepState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
step: MissionStep,
|
||||
): GameStateUpdate {
|
||||
return {
|
||||
[mission]: {
|
||||
...state[mission],
|
||||
currentStep: step,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
switch (mission) {
|
||||
case "bike":
|
||||
return completeBikeState(state);
|
||||
case "pylone":
|
||||
return completePyloneState(state);
|
||||
case "ferme":
|
||||
return completeFermeState(state);
|
||||
}
|
||||
}
|
||||
|
||||
function advanceRepairMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
const nextStep = getNextMissionStep(state[mission].currentStep);
|
||||
if (nextStep === "done") {
|
||||
return completeMissionState(state, mission);
|
||||
}
|
||||
|
||||
return setMissionStepState(state, mission, nextStep);
|
||||
}
|
||||
|
||||
function rewindRepairMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
return setMissionStepState(
|
||||
state,
|
||||
mission,
|
||||
getPreviousMissionStep(state[mission].currentStep),
|
||||
);
|
||||
}
|
||||
|
||||
function startOutroState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "outro",
|
||||
@@ -177,17 +231,17 @@ function createInitialGameState(): GameState {
|
||||
isBikeUnlocked: false,
|
||||
},
|
||||
bike: {
|
||||
currentStep: "waiting",
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
isRepaired: false,
|
||||
},
|
||||
pylone: {
|
||||
currentStep: "waiting",
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
isPowered: false,
|
||||
},
|
||||
ferme: {
|
||||
currentStep: "waiting",
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
irrigationFixed: false,
|
||||
},
|
||||
@@ -212,10 +266,14 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
set((state) => ({ ferme: { ...state.ferme, ...ferme } })),
|
||||
setOutroState: (outro) =>
|
||||
set((state) => ({ outro: { ...state.outro, ...outro } })),
|
||||
setMissionStep: (mission, step) =>
|
||||
set((state) => setMissionStepState(state, mission, step)),
|
||||
completeIntro: () => set(completeIntroState),
|
||||
completeBike: () => set(completeBikeState),
|
||||
completePylone: () => set(completePyloneState),
|
||||
completeFerme: () => set(completeFermeState),
|
||||
completeBike: () => set((state) => completeMissionState(state, "bike")),
|
||||
completePylone: () => set((state) => completeMissionState(state, "pylone")),
|
||||
completeFerme: () => set((state) => completeMissionState(state, "ferme")),
|
||||
completeMission: (mission) =>
|
||||
set((state) => completeMissionState(state, mission)),
|
||||
startOutro: () => set(startOutroState),
|
||||
advanceGameState: () =>
|
||||
set((state) => {
|
||||
@@ -223,31 +281,8 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
return completeIntroState(state);
|
||||
}
|
||||
|
||||
if (state.mainState === "bike") {
|
||||
const nextStep = getNextMissionStep(state.bike.currentStep);
|
||||
if (nextStep === "done") {
|
||||
return completeBikeState(state);
|
||||
}
|
||||
|
||||
return { bike: { ...state.bike, currentStep: nextStep } };
|
||||
}
|
||||
|
||||
if (state.mainState === "pylone") {
|
||||
const nextStep = getNextMissionStep(state.pylone.currentStep);
|
||||
if (nextStep === "done") {
|
||||
return completePyloneState(state);
|
||||
}
|
||||
|
||||
return { pylone: { ...state.pylone, currentStep: nextStep } };
|
||||
}
|
||||
|
||||
if (state.mainState === "ferme") {
|
||||
const nextStep = getNextMissionStep(state.ferme.currentStep);
|
||||
if (nextStep === "done") {
|
||||
return completeFermeState(state);
|
||||
}
|
||||
|
||||
return { ferme: { ...state.ferme, currentStep: nextStep } };
|
||||
if (isRepairMissionId(state.mainState)) {
|
||||
return advanceRepairMissionState(state, state.mainState);
|
||||
}
|
||||
|
||||
return startOutroState(state);
|
||||
@@ -258,31 +293,8 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
return { intro: { ...state.intro, hasCompleted: false } };
|
||||
}
|
||||
|
||||
if (state.mainState === "bike") {
|
||||
return {
|
||||
bike: {
|
||||
...state.bike,
|
||||
currentStep: getPreviousMissionStep(state.bike.currentStep),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (state.mainState === "pylone") {
|
||||
return {
|
||||
pylone: {
|
||||
...state.pylone,
|
||||
currentStep: getPreviousMissionStep(state.pylone.currentStep),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (state.mainState === "ferme") {
|
||||
return {
|
||||
ferme: {
|
||||
...state.ferme,
|
||||
currentStep: getPreviousMissionStep(state.ferme.currentStep),
|
||||
},
|
||||
};
|
||||
if (isRepairMissionId(state.mainState)) {
|
||||
return rewindRepairMissionState(state, state.mainState);
|
||||
}
|
||||
|
||||
return { outro: { ...state.outro, hasStarted: false } };
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={animation}
|
||||
frContent={animation}
|
||||
meta="08"
|
||||
meta="11"
|
||||
title="Animation & 3D Model System"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import audio from "../../../../docs/technical/audio.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsAudioPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={audio}
|
||||
frContent={audio}
|
||||
meta="05"
|
||||
title="Audio Technical Notes"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function DocsEditorPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={editor}
|
||||
frContent={editorFr}
|
||||
meta="09"
|
||||
meta="10"
|
||||
title="Editor User Guide"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export function DocsFeaturesPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={features}
|
||||
frContent={featuresFr}
|
||||
meta="06"
|
||||
meta="08"
|
||||
title="Features"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsHandTrackingPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={handTracking}
|
||||
frContent={handTracking}
|
||||
meta="05"
|
||||
meta="06"
|
||||
title="Hand Tracking Technical Notes"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsMainFeaturePage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={mainFeature}
|
||||
frContent={mainFeature}
|
||||
meta="07"
|
||||
meta="09"
|
||||
title="Main Feature"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export function DocsZustandPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={zustand}
|
||||
frContent={zustandFr}
|
||||
meta="05"
|
||||
meta="07"
|
||||
title="Zustand Game State"
|
||||
/>
|
||||
);
|
||||
|
||||
+91
-22
@@ -1,20 +1,56 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { useProgress } from "@react-three/drei";
|
||||
import { EditorControls } from "@/components/editor/EditorControls";
|
||||
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
||||
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
|
||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
||||
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
|
||||
import {
|
||||
INITIAL_SCENE_LOADING_STATE,
|
||||
type SceneLoadingChangeHandler,
|
||||
type SceneLoadingState,
|
||||
} from "@/types/world/sceneLoading";
|
||||
|
||||
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
||||
|
||||
interface EditorSceneLoadingTrackerProps {
|
||||
onLoadingStateChange: SceneLoadingChangeHandler;
|
||||
}
|
||||
|
||||
function serializeMapNodes(sceneData: SceneData): string {
|
||||
return JSON.stringify(sceneData.mapNodes, null, 2);
|
||||
}
|
||||
|
||||
function EditorSceneLoadingTracker({
|
||||
onLoadingStateChange,
|
||||
}: EditorSceneLoadingTrackerProps): null {
|
||||
const { active, progress } = useProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
onLoadingStateChange({
|
||||
currentStep: "Importation des models",
|
||||
progress: 0.2 + (progress / 100) * 0.7,
|
||||
status: "loading",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onLoadingStateChange({
|
||||
currentStep: "Gameplay prêt",
|
||||
progress: 1,
|
||||
status: "ready",
|
||||
});
|
||||
}, [active, onLoadingStateChange, progress]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EditorPage(): React.JSX.Element {
|
||||
const {
|
||||
hasMapJson,
|
||||
@@ -32,6 +68,35 @@ export function EditorPage(): React.JSX.Element {
|
||||
useState<TransformMode>("translate");
|
||||
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
||||
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
|
||||
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||
{
|
||||
...INITIAL_SCENE_LOADING_STATE,
|
||||
currentStep: "Montage progressif des models",
|
||||
progress: 0.2,
|
||||
},
|
||||
);
|
||||
const handleSceneLoadingStateChange = useCallback(
|
||||
(nextState: SceneLoadingState) => {
|
||||
setSceneLoadingState((currentState) => {
|
||||
const shouldRestartProgress = currentState.status === "ready";
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
progress: shouldRestartProgress
|
||||
? nextState.progress
|
||||
: Math.max(currentState.progress, nextState.progress),
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const editorLoadingState = isMapLoading
|
||||
? {
|
||||
currentStep: "Récupération blocking",
|
||||
progress: 0.08,
|
||||
status: "loading" as const,
|
||||
}
|
||||
: sceneLoadingState;
|
||||
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
|
||||
useState<EditorCinematicPreviewRequest | null>(null);
|
||||
|
||||
@@ -131,10 +196,7 @@ export function EditorPage(): React.JSX.Element {
|
||||
if (isMapLoading) {
|
||||
return (
|
||||
<div className="editor-container">
|
||||
<div className="editor-loading">
|
||||
<h2>Chargement de l'éditeur...</h2>
|
||||
<p>Vérification de map.json dans public/</p>
|
||||
</div>
|
||||
<SceneLoadingOverlay state={editorLoadingState} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -185,26 +247,33 @@ export function EditorPage(): React.JSX.Element {
|
||||
gl.setClearColor("#050505");
|
||||
}}
|
||||
>
|
||||
<EditorScene
|
||||
sceneData={sceneData!}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
onSelectNode={handleSelectNode}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={handleHoverNode}
|
||||
transformMode={transformMode}
|
||||
onTransformModeChange={handleTransformModeChange}
|
||||
onTransformStart={handleTransformStart}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
onNodeTransform={handleNodeTransform}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
isPlayerMode={isPlayerMode}
|
||||
cinematicPreviewRequest={cinematicPreviewRequest}
|
||||
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
||||
<EditorSceneLoadingTracker
|
||||
onLoadingStateChange={handleSceneLoadingStateChange}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<EditorScene
|
||||
sceneData={sceneData!}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
onSelectNode={handleSelectNode}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={handleHoverNode}
|
||||
transformMode={transformMode}
|
||||
onTransformModeChange={handleTransformModeChange}
|
||||
onTransformStart={handleTransformStart}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
onNodeTransform={handleNodeTransform}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
isPlayerMode={isPlayerMode}
|
||||
cinematicPreviewRequest={cinematicPreviewRequest}
|
||||
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
|
||||
<SceneLoadingOverlay state={editorLoadingState} />
|
||||
|
||||
{sceneData && (
|
||||
<EditorControls
|
||||
transformMode={transformMode}
|
||||
|
||||
+27
-2
@@ -1,12 +1,36 @@
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useCallback, useState } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||
import { GameUI } from "@/components/ui/GameUI";
|
||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||
import {
|
||||
INITIAL_SCENE_LOADING_STATE,
|
||||
type SceneLoadingState,
|
||||
} from "@/types/world/sceneLoading";
|
||||
import { World } from "@/world/World";
|
||||
|
||||
export function HomePage(): React.JSX.Element {
|
||||
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||
INITIAL_SCENE_LOADING_STATE,
|
||||
);
|
||||
const handleSceneLoadingStateChange = useCallback(
|
||||
(nextState: SceneLoadingState) => {
|
||||
setSceneLoadingState((currentState) => {
|
||||
if (currentState.status === "ready" && nextState.status === "loading") {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
progress: Math.max(currentState.progress, nextState.progress),
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<HandTrackingProvider>
|
||||
<Canvas
|
||||
@@ -14,11 +38,12 @@ export function HomePage(): React.JSX.Element {
|
||||
shadows={{ type: THREE.PCFShadowMap }}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<World />
|
||||
<World onLoadingStateChange={handleSceneLoadingStateChange} />
|
||||
<DebugPerf />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
<GameUI />
|
||||
<SceneLoadingOverlay state={sceneLoadingState} />
|
||||
</HandTrackingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ import {
|
||||
} from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { useBrowserHandTracking } from "@/hooks/handTracking/useBrowserHandTracking";
|
||||
import { useRemoteHandTracking } from "@/hooks/handTracking/useRemoteHandTracking";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||
|
||||
const REPAIR_HAND_TRACKING_STEPS = new Set<MissionStep>([
|
||||
"inspected",
|
||||
"repairing",
|
||||
"reassembling",
|
||||
"done",
|
||||
]);
|
||||
|
||||
export function HandTrackingProvider({
|
||||
children,
|
||||
@@ -18,8 +27,23 @@ export function HandTrackingProvider({
|
||||
const handTrackingSource = useDebugStore((debug) =>
|
||||
debug.getHandTrackingSource(),
|
||||
);
|
||||
const repairNeedsHands = useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "bike":
|
||||
return REPAIR_HAND_TRACKING_STEPS.has(state.bike.currentStep);
|
||||
case "pylone":
|
||||
return REPAIR_HAND_TRACKING_STEPS.has(state.pylone.currentStep);
|
||||
case "ferme":
|
||||
return REPAIR_HAND_TRACKING_STEPS.has(state.ferme.currentStep);
|
||||
case "intro":
|
||||
case "outro":
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const { nearby, holding, handHolding } = useInteraction();
|
||||
const enabled = sceneMode === "physics" && (nearby || holding || handHolding);
|
||||
const enabled =
|
||||
repairNeedsHands ||
|
||||
(sceneMode === "physics" && (nearby || holding || handHolding));
|
||||
const backendSnapshot = useRemoteHandTracking({
|
||||
enabled: enabled && handTrackingSource === "backend",
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HomePage } from "@/pages/page";
|
||||
import { EditorPage } from "@/pages/editor/page";
|
||||
import {
|
||||
DocsAnimationRoute,
|
||||
DocsAudioRoute,
|
||||
DocsArchitectureRoute,
|
||||
DocsEditorRoute,
|
||||
DocsFeaturesRoute,
|
||||
@@ -47,6 +48,7 @@ const docsChildRoutes = [
|
||||
{ path: "architecture", component: DocsArchitectureRoute },
|
||||
{ path: "target-architecture", component: DocsTargetArchitectureRoute },
|
||||
{ path: "technical-editor", component: DocsTechnicalEditorRoute },
|
||||
{ path: "audio", component: DocsAudioRoute },
|
||||
{ path: "hand-tracking", component: DocsHandTrackingRoute },
|
||||
{ path: "zustand", component: DocsZustandRoute },
|
||||
{ path: "features", component: DocsFeaturesRoute },
|
||||
|
||||
+28
-43
@@ -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",
|
||||
@@ -39,6 +47,10 @@ const LazyDocsTechnicalEditorPage = lazyNamed(
|
||||
() => import("@/pages/docs/technical-editor/page"),
|
||||
"DocsTechnicalEditorPage",
|
||||
);
|
||||
const LazyDocsAudioPage = lazyNamed(
|
||||
() => import("@/pages/docs/audio/page"),
|
||||
"DocsAudioPage",
|
||||
);
|
||||
const LazyDocsHandTrackingPage = lazyNamed(
|
||||
() => import("@/pages/docs/hand-tracking/page"),
|
||||
"DocsHandTrackingPage",
|
||||
@@ -64,46 +76,19 @@ 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 DocsAudioRoute = createDocsRoute(LazyDocsAudioPage);
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type SceneLoadingStatus = "loading" | "ready";
|
||||
|
||||
export interface SceneLoadingState {
|
||||
currentStep: string;
|
||||
progress: number;
|
||||
status: SceneLoadingStatus;
|
||||
}
|
||||
|
||||
export type SceneLoadingChangeHandler = (state: SceneLoadingState) => void;
|
||||
|
||||
export const INITIAL_SCENE_LOADING_STATE: SceneLoadingState = {
|
||||
currentStep: "Initialisation du jeu",
|
||||
progress: 0,
|
||||
status: "loading",
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
interface ExplodedPart {
|
||||
export interface ExplodedPart {
|
||||
object: THREE.Object3D;
|
||||
originalPosition: THREE.Vector3;
|
||||
targetPosition: THREE.Vector3;
|
||||
@@ -31,6 +31,10 @@ export class ExplodedModel {
|
||||
this.targetProgress = split ? 1 : 0;
|
||||
}
|
||||
|
||||
getParts(): readonly ExplodedPart[] {
|
||||
return this.parts;
|
||||
}
|
||||
|
||||
update(delta: number): void {
|
||||
const diff = this.targetProgress - this.progress;
|
||||
if (Math.abs(diff) < 0.001) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
CinematicManifest,
|
||||
} from "@/types/cinematics/cinematics";
|
||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||
import { logger } from "@/utils/core/logger";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { queueDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
@@ -22,6 +22,7 @@ export function GameCinematics(): null {
|
||||
const playedCinematicsRef = useRef(new Set<string>());
|
||||
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -59,7 +60,9 @@ export function GameCinematics(): null {
|
||||
useFrame(({ clock }) => {
|
||||
if (!manifest) return;
|
||||
|
||||
const elapsedTime = clock.getElapsedTime();
|
||||
startedAtRef.current ??= clock.getElapsedTime();
|
||||
|
||||
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
|
||||
|
||||
manifest.cinematics.forEach((cinematic) => {
|
||||
if (cinematic.timecode === undefined) return;
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
clearQueuedDialogues,
|
||||
queueDialogueById,
|
||||
} from "@/utils/dialogues/playDialogue";
|
||||
import { logger } from "@/utils/core/logger";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
export function GameDialogues(): null {
|
||||
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||
const playedDialoguesRef = useRef(new Set<string>());
|
||||
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -38,7 +39,9 @@ export function GameDialogues(): null {
|
||||
useFrame(({ clock }) => {
|
||||
if (!manifest) return;
|
||||
|
||||
const elapsedTime = clock.getElapsedTime();
|
||||
startedAtRef.current ??= clock.getElapsedTime();
|
||||
|
||||
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
|
||||
|
||||
manifest.dialogues.forEach((dialogue) => {
|
||||
if (dialogue.timecode === undefined) return;
|
||||
|
||||
+160
-29
@@ -1,10 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component, useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
Component,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
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 { GameMapCollision } from "@/world/GameMapCollision";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
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";
|
||||
@@ -12,13 +19,15 @@ import type { OctreeReadyHandler } from "@/types/three/three";
|
||||
|
||||
interface LoadedMapNode {
|
||||
node: MapNode;
|
||||
modelUrl: string;
|
||||
modelUrl: string | null;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
modelUrl: string;
|
||||
fallback: ReactNode;
|
||||
modelUrl: string | null;
|
||||
node: MapNode;
|
||||
onSettled: () => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
@@ -41,7 +50,7 @@ class ModelErrorBoundary extends Component<
|
||||
componentDidCatch(error: Error): void {
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: this.props.modelUrl,
|
||||
modelPath: this.props.modelUrl ?? `missing:${this.props.node.name}`,
|
||||
scope: "GameMap.ModelInstance",
|
||||
position: this.props.node.position,
|
||||
rotation: this.props.node.rotation,
|
||||
@@ -49,11 +58,12 @@ class ModelErrorBoundary extends Component<
|
||||
},
|
||||
error,
|
||||
);
|
||||
this.props.onSettled();
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return null;
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
@@ -61,39 +71,80 @@ class ModelErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
interface GameMapProps {
|
||||
onLoaded?: (() => void) | undefined;
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
onOctreeReady: OctreeReadyHandler;
|
||||
buildOctree?: boolean;
|
||||
}
|
||||
|
||||
export function GameMap({
|
||||
onOctreeReady,
|
||||
buildOctree = true,
|
||||
onLoaded,
|
||||
onLoadingStateChange,
|
||||
onOctreeReady,
|
||||
}: GameMapProps): React.JSX.Element {
|
||||
const settledMapNodesRef = useRef(new Set<number>());
|
||||
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
||||
const mapReady = mapLoaded && settledMapNodeCount >= mapNodes.length;
|
||||
|
||||
useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length, buildOctree);
|
||||
const handleMapNodeSettled = useCallback((index: number) => {
|
||||
if (settledMapNodesRef.current.has(index)) return;
|
||||
|
||||
settledMapNodesRef.current.add(index);
|
||||
setSettledMapNodeCount(settledMapNodesRef.current.size);
|
||||
}, []);
|
||||
|
||||
const showEmptyMap = useCallback(
|
||||
(currentStep: string) => {
|
||||
setMapNodes([]);
|
||||
setMapLoaded(true);
|
||||
settledMapNodesRef.current.clear();
|
||||
setSettledMapNodeCount(0);
|
||||
onLoadingStateChange?.({
|
||||
currentStep,
|
||||
progress: 0.7,
|
||||
status: "loading",
|
||||
});
|
||||
},
|
||||
[onLoadingStateChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Récupération blocking",
|
||||
progress: 0.05,
|
||||
status: "loading",
|
||||
});
|
||||
|
||||
const loadMap = async () => {
|
||||
try {
|
||||
const sceneData = await loadMapSceneData();
|
||||
if (!sceneData) {
|
||||
logger.warn("GameMap", "map.json not found");
|
||||
showEmptyMap("Map introuvable");
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedMapNodes = sceneData.mapNodes.flatMap((node) => {
|
||||
const modelUrl = sceneData.models.get(node.name);
|
||||
return modelUrl ? [{ node, modelUrl }] : [];
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Importation des models",
|
||||
progress: 0.18,
|
||||
status: "loading",
|
||||
});
|
||||
const missingModelCount =
|
||||
sceneData.mapNodes.length - loadedMapNodes.length;
|
||||
|
||||
const loadedMapNodes = sceneData.mapNodes.map((node) => {
|
||||
const modelUrl = sceneData.models.get(node.name);
|
||||
return { node, modelUrl: modelUrl ?? null };
|
||||
});
|
||||
const missingModelCount = loadedMapNodes.filter(
|
||||
(mapNode) => mapNode.modelUrl === null,
|
||||
).length;
|
||||
|
||||
if (missingModelCount > 0) {
|
||||
logger.warn(
|
||||
"GameMap",
|
||||
"Map nodes skipped because model files are missing",
|
||||
"Map nodes rendered as fallback cubes because model files are missing",
|
||||
{
|
||||
missingModelCount,
|
||||
},
|
||||
@@ -101,37 +152,102 @@ export function GameMap({
|
||||
}
|
||||
|
||||
setMapNodes(loadedMapNodes);
|
||||
setMapLoaded(true);
|
||||
settledMapNodesRef.current.clear();
|
||||
setSettledMapNodeCount(0);
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Chargement des modèles de la map",
|
||||
progress: 0.25,
|
||||
status: "loading",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("GameMap", "Error loading map", {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
showEmptyMap("Erreur de chargement de la map");
|
||||
}
|
||||
};
|
||||
|
||||
loadMap();
|
||||
}, []);
|
||||
}, [onLoadingStateChange, showEmptyMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapNodes.length === 0) return;
|
||||
|
||||
const renderProgress =
|
||||
mapNodes.length === 0 ? 1 : settledMapNodeCount / mapNodes.length;
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Chargement des modèles de la map",
|
||||
progress: 0.25 + renderProgress * 0.45,
|
||||
status: "loading",
|
||||
});
|
||||
}, [mapNodes.length, onLoadingStateChange, settledMapNodeCount]);
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{mapNodes.map((mapNode, index) => (
|
||||
<ModelErrorBoundary
|
||||
key={index}
|
||||
modelUrl={mapNode.modelUrl}
|
||||
node={mapNode.node}
|
||||
>
|
||||
<ModelInstance node={mapNode.node} modelUrl={mapNode.modelUrl} />
|
||||
</ModelErrorBoundary>
|
||||
))}
|
||||
</group>
|
||||
<>
|
||||
<group>
|
||||
{mapNodes.map((mapNode, index) => (
|
||||
<ModelErrorBoundary
|
||||
key={index}
|
||||
fallback={<FallbackMapNode node={mapNode.node} />}
|
||||
modelUrl={mapNode.modelUrl}
|
||||
node={mapNode.node}
|
||||
onSettled={() => handleMapNodeSettled(index)}
|
||||
>
|
||||
<MapNodeInstance
|
||||
node={mapNode.node}
|
||||
modelUrl={mapNode.modelUrl}
|
||||
onSettled={() => handleMapNodeSettled(index)}
|
||||
/>
|
||||
</ModelErrorBoundary>
|
||||
))}
|
||||
</group>
|
||||
<GameMapCollision
|
||||
buildOctree={buildOctree}
|
||||
mapReady={mapReady}
|
||||
nodes={mapNodes}
|
||||
onLoaded={onLoaded}
|
||||
onLoadingStateChange={onLoadingStateChange}
|
||||
onOctreeReady={onOctreeReady}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNodeInstance({
|
||||
node,
|
||||
modelUrl,
|
||||
onSettled,
|
||||
}: {
|
||||
node: MapNode;
|
||||
modelUrl: string | null;
|
||||
onSettled: () => void;
|
||||
}): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
if (modelUrl !== null) return;
|
||||
|
||||
onSettled();
|
||||
}, [modelUrl, onSettled]);
|
||||
|
||||
if (!modelUrl) {
|
||||
return <FallbackMapNode node={node} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<FallbackMapNode node={node} />}>
|
||||
<ModelInstance node={node} modelUrl={modelUrl} onLoaded={onSettled} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelInstance({
|
||||
node,
|
||||
modelUrl,
|
||||
onLoaded,
|
||||
}: {
|
||||
node: MapNode;
|
||||
modelUrl: string;
|
||||
onLoaded: () => void;
|
||||
}): React.JSX.Element {
|
||||
const { position, rotation, scale } = node;
|
||||
const { scene } = useLoggedGLTF(modelUrl, {
|
||||
@@ -142,6 +258,10 @@ function ModelInstance({
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
|
||||
useEffect(() => {
|
||||
onLoaded();
|
||||
}, [onLoaded]);
|
||||
|
||||
return (
|
||||
<primitive
|
||||
object={sceneInstance}
|
||||
@@ -151,3 +271,14 @@ function ModelInstance({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FallbackMapNode({ node }: { node: MapNode }): React.JSX.Element {
|
||||
const { position, rotation, scale } = node;
|
||||
|
||||
return (
|
||||
<mesh position={position} rotation={rotation} scale={scale}>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#64748b" wireframe />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Component,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
|
||||
import type { MapNode } from "@/types/editor/editor";
|
||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
|
||||
export interface GameMapCollisionNode {
|
||||
node: MapNode;
|
||||
modelUrl: string | null;
|
||||
}
|
||||
|
||||
interface ResolvedGameMapCollisionNode {
|
||||
node: MapNode;
|
||||
modelUrl: string;
|
||||
}
|
||||
|
||||
interface GameMapCollisionProps {
|
||||
buildOctree?: boolean;
|
||||
mapReady: boolean;
|
||||
nodes: readonly GameMapCollisionNode[];
|
||||
onLoaded?: (() => void) | undefined;
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
onOctreeReady: OctreeReadyHandler;
|
||||
}
|
||||
|
||||
interface CollisionErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
modelUrl: string;
|
||||
node: MapNode;
|
||||
onSettled: () => void;
|
||||
}
|
||||
|
||||
interface CollisionErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
const MAP_COLLISION_NODE_NAMES = new Set(["terrain"]);
|
||||
|
||||
class CollisionErrorBoundary extends Component<
|
||||
CollisionErrorBoundaryProps,
|
||||
CollisionErrorBoundaryState
|
||||
> {
|
||||
constructor(props: CollisionErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): CollisionErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: this.props.modelUrl,
|
||||
scope: "GameMapCollision.ModelInstance",
|
||||
position: this.props.node.position,
|
||||
rotation: this.props.node.rotation,
|
||||
scale: this.props.node.scale,
|
||||
},
|
||||
error,
|
||||
);
|
||||
this.props.onSettled();
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function isCollisionNode(
|
||||
mapNode: GameMapCollisionNode,
|
||||
): mapNode is ResolvedGameMapCollisionNode {
|
||||
return (
|
||||
mapNode.modelUrl !== null && MAP_COLLISION_NODE_NAMES.has(mapNode.node.name)
|
||||
);
|
||||
}
|
||||
|
||||
export function GameMapCollision({
|
||||
buildOctree = true,
|
||||
mapReady,
|
||||
nodes,
|
||||
onLoaded,
|
||||
onLoadingStateChange,
|
||||
onOctreeReady,
|
||||
}: GameMapCollisionProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const settledCollisionNodesRef = useRef(new Set<number>());
|
||||
const loadedNotifiedRef = useRef(false);
|
||||
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
|
||||
const collisionNodes = nodes.filter(isCollisionNode);
|
||||
const collisionReady =
|
||||
mapReady && settledCollisionNodeCount >= collisionNodes.length;
|
||||
|
||||
const notifyLoaded = useCallback(() => {
|
||||
if (loadedNotifiedRef.current) return;
|
||||
|
||||
loadedNotifiedRef.current = true;
|
||||
onLoaded?.();
|
||||
}, [onLoaded]);
|
||||
|
||||
const handleCollisionNodeSettled = useCallback((index: number) => {
|
||||
if (settledCollisionNodesRef.current.has(index)) return;
|
||||
|
||||
settledCollisionNodesRef.current.add(index);
|
||||
setSettledCollisionNodeCount(settledCollisionNodesRef.current.size);
|
||||
}, []);
|
||||
|
||||
const handleOctreeReady = useCallback<OctreeReadyHandler>(
|
||||
(octree) => {
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Collision prête",
|
||||
progress: 0.92,
|
||||
status: "loading",
|
||||
});
|
||||
onOctreeReady(octree);
|
||||
notifyLoaded();
|
||||
},
|
||||
[notifyLoaded, onLoadingStateChange, onOctreeReady],
|
||||
);
|
||||
|
||||
useOctreeGraphNode(
|
||||
groupRef,
|
||||
handleOctreeReady,
|
||||
collisionReady ? collisionNodes.length : 0,
|
||||
buildOctree && collisionReady && collisionNodes.length > 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady) return;
|
||||
|
||||
if (collisionNodes.length === 0) {
|
||||
notifyLoaded();
|
||||
return;
|
||||
}
|
||||
|
||||
if (collisionReady && !buildOctree) {
|
||||
notifyLoaded();
|
||||
return;
|
||||
}
|
||||
|
||||
if (collisionReady) return;
|
||||
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Ajout de la collision",
|
||||
progress: 0.86,
|
||||
status: "loading",
|
||||
});
|
||||
}, [
|
||||
buildOctree,
|
||||
collisionNodes.length,
|
||||
collisionReady,
|
||||
mapReady,
|
||||
notifyLoaded,
|
||||
onLoadingStateChange,
|
||||
]);
|
||||
|
||||
return (
|
||||
<group ref={groupRef} visible={false}>
|
||||
{mapReady
|
||||
? collisionNodes.map((mapNode, index) => (
|
||||
<CollisionErrorBoundary
|
||||
key={`collision-${index}`}
|
||||
node={mapNode.node}
|
||||
modelUrl={mapNode.modelUrl}
|
||||
onSettled={() => handleCollisionNodeSettled(index)}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<CollisionModelInstance
|
||||
node={mapNode.node}
|
||||
modelUrl={mapNode.modelUrl}
|
||||
onLoaded={() => handleCollisionNodeSettled(index)}
|
||||
/>
|
||||
</Suspense>
|
||||
</CollisionErrorBoundary>
|
||||
))
|
||||
: null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function CollisionModelInstance({
|
||||
node,
|
||||
modelUrl,
|
||||
onLoaded,
|
||||
}: {
|
||||
node: MapNode;
|
||||
modelUrl: string;
|
||||
onLoaded: () => void;
|
||||
}): React.JSX.Element {
|
||||
const { position, rotation, scale } = node;
|
||||
const { scene } = useLoggedGLTF(modelUrl, {
|
||||
scope: "GameMapCollision.ModelInstance",
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
|
||||
useEffect(() => {
|
||||
onLoaded();
|
||||
}, [onLoaded]);
|
||||
|
||||
return (
|
||||
<primitive
|
||||
object={sceneInstance}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
|
||||
const GAME_MUSIC_PATH = "/sounds/musique/test.mp3";
|
||||
const GAME_MUSIC_VOLUME = 0.45;
|
||||
const GAME_MUSIC_VOLUME = 0.33;
|
||||
|
||||
export function GameMusic(): null {
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface StageAnchorProps {
|
||||
@@ -7,6 +9,26 @@ interface StageAnchorProps {
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
interface GameRepairZone {
|
||||
mission: RepairMissionId;
|
||||
position: Vector3Tuple;
|
||||
}
|
||||
|
||||
const GAME_REPAIR_ZONES = [
|
||||
{
|
||||
mission: "bike",
|
||||
position: [8, 0, -6],
|
||||
},
|
||||
{
|
||||
mission: "pylone",
|
||||
position: [64, 0, -66],
|
||||
},
|
||||
{
|
||||
mission: "ferme",
|
||||
position: [-24, 0, 42],
|
||||
},
|
||||
] as const satisfies readonly GameRepairZone[];
|
||||
|
||||
function StageAnchor({
|
||||
color,
|
||||
position,
|
||||
@@ -29,16 +51,21 @@ function StageAnchor({
|
||||
export function GameStageContent(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
|
||||
switch (mainState) {
|
||||
case "intro":
|
||||
return <StageAnchor color="#7dd3fc" position={[0, 4, 0]} />;
|
||||
case "bike":
|
||||
return <StageAnchor color="#facc15" position={[8, 3, -6]} />;
|
||||
case "pylone":
|
||||
return <StageAnchor color="#a78bfa" position={[64, 6, -66]} />;
|
||||
case "ferme":
|
||||
return <StageAnchor color="#86efac" position={[-24, 5, 42]} />;
|
||||
case "outro":
|
||||
return <StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{mainState === "intro" ? (
|
||||
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
|
||||
) : null}
|
||||
{GAME_REPAIR_ZONES.map((zone) => (
|
||||
<RepairGame
|
||||
key={zone.mission}
|
||||
mission={zone.mission}
|
||||
position={zone.position}
|
||||
/>
|
||||
))}
|
||||
{mainState === "outro" ? (
|
||||
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+57
-26
@@ -1,11 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import type { Octree } from "three/addons/math/Octree.js";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { Physics } from "@react-three/rapier";
|
||||
import {
|
||||
PLAYER_SPAWN_POSITION_GAME,
|
||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
||||
@@ -18,57 +21,85 @@ import { GameMap } from "@/world/GameMap";
|
||||
import { GameStageContent } from "@/world/GameStageContent";
|
||||
import { Player } from "@/world/player/Player";
|
||||
import { TestMap } from "@/world/debug/TestMap";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
|
||||
function hasBootFlag(name: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return new URLSearchParams(window.location.search).has(name);
|
||||
interface WorldProps {
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
}
|
||||
|
||||
export function World(): React.JSX.Element {
|
||||
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
const cameraMode = useCameraMode();
|
||||
const sceneMode = useSceneMode();
|
||||
const [octree, setOctree] = useState<Octree | null>(null);
|
||||
const noCinematics = hasBootFlag("noCinematics");
|
||||
const noDialogues = hasBootFlag("noDialogues");
|
||||
const noMap = hasBootFlag("noMap");
|
||||
const noMusic = hasBootFlag("noMusic");
|
||||
const noOctree = hasBootFlag("noOctree");
|
||||
const noPlayer = hasBootFlag("noPlayer");
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const { status, usageStatus } = useHandTrackingSnapshot();
|
||||
const {
|
||||
octree,
|
||||
gameplayReady,
|
||||
showGameStage,
|
||||
handleGameStageLoaded,
|
||||
handleGameMapLoaded,
|
||||
handleOctreeReady,
|
||||
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||
const playerSpawnPosition =
|
||||
sceneMode === "game"
|
||||
? PLAYER_SPAWN_POSITION_GAME
|
||||
: PLAYER_SPAWN_POSITION_PHYSICS;
|
||||
const showHandTrackingGloves =
|
||||
sceneMode === "physics" ||
|
||||
(status !== "idle" && usageStatus !== "inactive");
|
||||
const spawnPlayer =
|
||||
cameraMode !== "debug" &&
|
||||
(sceneMode === "game" ? gameplayReady : octree !== null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Environment />
|
||||
<Lighting />
|
||||
<DebugHelpers />
|
||||
{sceneMode === "physics" ? (
|
||||
<>
|
||||
{showHandTrackingGloves ? (
|
||||
<Suspense fallback={null}>
|
||||
<HandTrackingGlove handedness="left" />
|
||||
<HandTrackingGlove handedness="right" />
|
||||
</>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||
|
||||
{sceneMode === "game" ? (
|
||||
<>
|
||||
{noMusic ? null : <GameMusic />}
|
||||
{noCinematics ? null : <GameCinematics />}
|
||||
{noDialogues ? null : <GameDialogues />}
|
||||
{noMap ? null : (
|
||||
<GameMap onOctreeReady={setOctree} buildOctree={!noOctree} />
|
||||
)}
|
||||
<GameStageContent />
|
||||
<GameMap
|
||||
onLoaded={handleGameMapLoaded}
|
||||
onLoadingStateChange={onLoadingStateChange}
|
||||
onOctreeReady={handleOctreeReady}
|
||||
/>
|
||||
{showGameStage ? (
|
||||
<Physics>
|
||||
<GameStageLoaded onLoaded={handleGameStageLoaded} />
|
||||
<GameStageContent />
|
||||
</Physics>
|
||||
) : null}
|
||||
{spawnPlayer ? (
|
||||
<>
|
||||
<GameMusic />
|
||||
{mainState === "outro" ? <GameCinematics /> : null}
|
||||
<GameDialogues />
|
||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<TestMap onOctreeReady={setOctree} />
|
||||
<TestMap onOctreeReady={handleOctreeReady} />
|
||||
)}
|
||||
|
||||
{cameraMode !== "debug" && !noPlayer ? (
|
||||
{sceneMode !== "game" && spawnPlayer ? (
|
||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GameStageLoaded({ onLoaded }: { onLoaded: () => void }): null {
|
||||
useEffect(() => {
|
||||
onLoaded();
|
||||
}, [onLoaded]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
TEST_SCENE_GRABBABLE_METALNESS,
|
||||
TEST_SCENE_GRABBABLE_POSITION,
|
||||
TEST_SCENE_GRABBABLE_ROUGHNESS,
|
||||
TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS,
|
||||
TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS,
|
||||
TEST_SCENE_REPAIR_ZONES,
|
||||
TEST_SCENE_TRIGGER_COLOR,
|
||||
TEST_SCENE_TRIGGER_METALNESS,
|
||||
TEST_SCENE_TRIGGER_POSITION,
|
||||
@@ -43,6 +46,10 @@ interface ModelPreviewErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
interface RepairPlaygroundZoneMarkerProps {
|
||||
color: string;
|
||||
}
|
||||
|
||||
class ModelPreviewErrorBoundary extends Component<
|
||||
ModelPreviewErrorBoundaryProps,
|
||||
ModelPreviewErrorBoundaryState
|
||||
@@ -134,13 +141,20 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
</mesh>
|
||||
</TriggerObject>
|
||||
|
||||
<RepairGameZone />
|
||||
{TEST_SCENE_REPAIR_ZONES.map((zone) => (
|
||||
<group key={zone.mission}>
|
||||
<group position={zone.position}>
|
||||
<RepairPlaygroundZoneMarker color={zone.color} />
|
||||
</group>
|
||||
<RepairGame mission={zone.mission} position={zone.position} />
|
||||
</group>
|
||||
))}
|
||||
</Physics>
|
||||
|
||||
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>
|
||||
<AnimatedModel
|
||||
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
|
||||
defaultAnimation="Idle"
|
||||
defaultAnimation="Dance"
|
||||
position={[0, 0, -5]}
|
||||
scale={1}
|
||||
/>
|
||||
@@ -148,3 +162,31 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairPlaygroundZoneMarker({
|
||||
color,
|
||||
}: RepairPlaygroundZoneMarkerProps): React.JSX.Element {
|
||||
return (
|
||||
<group>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry
|
||||
args={[
|
||||
TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS,
|
||||
TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS,
|
||||
12,
|
||||
96,
|
||||
]}
|
||||
/>
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.2}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, 0.08, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.2, TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS, 96]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.12} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import type { Octree } from "three/addons/math/Octree.js";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
@@ -16,7 +16,7 @@ export function Player({
|
||||
}: PlayerProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
camera.position.set(...spawnPosition);
|
||||
}, [camera, spawnPosition]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { Capsule } from "three/addons/math/Capsule.js";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
PLAYER_WALK_SPEED,
|
||||
PLAYER_XZ_DAMPING_FACTOR,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
@@ -56,6 +57,18 @@ const _up = new THREE.Vector3(0, 1, 0);
|
||||
const _translateVec = new THREE.Vector3();
|
||||
const _collisionCorrection = new THREE.Vector3();
|
||||
|
||||
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
|
||||
return new Capsule(
|
||||
new THREE.Vector3(
|
||||
spawnPosition[0],
|
||||
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
|
||||
spawnPosition[2],
|
||||
),
|
||||
new THREE.Vector3(...spawnPosition),
|
||||
PLAYER_CAPSULE_RADIUS,
|
||||
);
|
||||
}
|
||||
|
||||
function isPlayerInputLocked(): boolean {
|
||||
return (
|
||||
useSettingsStore.getState().isSettingsMenuOpen ||
|
||||
@@ -87,20 +100,16 @@ export function PlayerController({
|
||||
spawnPosition,
|
||||
}: PlayerControllerProps): null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const movementLocked = useRepairMovementLocked();
|
||||
const movementLockedRef = useRef(movementLocked);
|
||||
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
||||
const velocity = useRef(new THREE.Vector3());
|
||||
const onFloor = useRef(false);
|
||||
const wantsJump = useRef(false);
|
||||
const initializedRef = useRef(false);
|
||||
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
||||
|
||||
const capsule = useRef(
|
||||
new Capsule(
|
||||
new THREE.Vector3(0, PLAYER_CAPSULE_RADIUS, 0),
|
||||
new THREE.Vector3(0, PLAYER_EYE_HEIGHT - PLAYER_CAPSULE_RADIUS, 0),
|
||||
PLAYER_CAPSULE_RADIUS,
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
capsule.current.start.set(
|
||||
spawnPosition[0],
|
||||
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
|
||||
@@ -111,8 +120,20 @@ export function PlayerController({
|
||||
onFloor.current = false;
|
||||
wantsJump.current = false;
|
||||
camera.position.copy(capsule.current.end);
|
||||
initializedRef.current = true;
|
||||
}, [camera, spawnPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
movementLockedRef.current = movementLocked;
|
||||
|
||||
if (!movementLocked) return;
|
||||
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
wantsJump.current = false;
|
||||
velocity.current.setX(0);
|
||||
velocity.current.setZ(0);
|
||||
}, [movementLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
const interaction = InteractionManager.getInstance();
|
||||
|
||||
@@ -120,11 +141,20 @@ export function PlayerController({
|
||||
if (isPlayerInputLocked()) return;
|
||||
|
||||
if (setMovementKey(keys.current, event.key, true)) {
|
||||
if (movementLockedRef.current) {
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === JUMP_KEY) {
|
||||
if (movementLockedRef.current) {
|
||||
wantsJump.current = false;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
wantsJump.current = true;
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -177,6 +207,8 @@ export function PlayerController({
|
||||
}, []);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!initializedRef.current) return;
|
||||
|
||||
if (isPlayerInputLocked()) {
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
velocity.current.set(0, 0, 0);
|
||||
@@ -194,10 +226,12 @@ export function PlayerController({
|
||||
}
|
||||
|
||||
_wishDir.set(0, 0, 0);
|
||||
if (keys.current.forward) _wishDir.add(_forward);
|
||||
if (keys.current.backward) _wishDir.sub(_forward);
|
||||
if (keys.current.left) _wishDir.sub(_right);
|
||||
if (keys.current.right) _wishDir.add(_right);
|
||||
if (!movementLocked) {
|
||||
if (keys.current.forward) _wishDir.add(_forward);
|
||||
if (keys.current.backward) _wishDir.sub(_forward);
|
||||
if (keys.current.left) _wishDir.sub(_right);
|
||||
if (keys.current.right) _wishDir.add(_right);
|
||||
}
|
||||
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
||||
|
||||
const accel = onFloor.current
|
||||
|
||||
Reference in New Issue
Block a user