Merge branch 'develop' into feat/polish-mission-2
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled

This commit is contained in:
math-pixel
2026-06-02 20:43:00 +02:00
91 changed files with 2119 additions and 934 deletions
+170 -28
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
import { EbikeSpeedometer } from "@/components/ebike/EbikeSpeedometer";
import { EbikeSpeedmeter } from "@/components/ebike/EbikeSpeedmeter";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
@@ -25,6 +25,12 @@ import "@/types/ebike/ebikeWindow";
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
// Reusable vectors — allocated once to avoid per-frame GC pressure
const _phareWorldPos = new THREE.Vector3();
const _bikeForward = new THREE.Vector3();
const _aimDir = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
interface EbikeProps {
position: Vector3Tuple;
}
@@ -53,6 +59,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const camera = useThree((state) => state.camera);
const threeScene = useThree((state) => state.scene);
const updateEbikeSounds = useEbikeSounds();
const repairGameOwnsEbikeModel =
mainState === "ebike" &&
@@ -96,6 +103,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
]);
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
const forkRef = useRef<THREE.Object3D | null>(null);
const phareRef = useRef<THREE.Object3D | null>(null);
const headlightRef = useRef<THREE.SpotLight | null>(null);
// SpotLight target — must live in the scene to define the cone direction.
const headlightTarget = useMemo(() => new THREE.Object3D(), []);
// Original quaternion of the Fourche node — rotation is applied on top of this.
const forkInitialQuatRef = useRef(new THREE.Quaternion());
// Smoothed steer angle for the fork (avoids direct Euler manipulation).
const forkAngleRef = useRef(0);
// Ref copy of movementMode — useFrame closures can capture stale React state.
const movementModeRef = useRef(movementMode);
// Becomes true the first time the player mounts. After that, dismounting
// must NOT reset position back to the original spawn point.
const hasRiddenRef = useRef(false);
// State for debug visualization (synced from refs during useFrame)
const [showCameraPoints, setShowCameraPoints] = useState(true);
@@ -106,9 +126,42 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
parkedPosition[2],
]);
// Keep movementModeRef in sync — useFrame closures capture React state at
// render time and can become stale between renders.
useEffect(() => {
if (movementMode === "ebike") return;
movementModeRef.current = movementMode;
}, [movementMode]);
// SpotLight target must be in the scene to define the cone direction.
useEffect(() => {
threeScene.add(headlightTarget);
return () => { threeScene.remove(headlightTarget); };
}, [threeScene, headlightTarget]);
// Link the target to the SpotLight once it mounts.
useEffect(() => {
if (headlightRef.current) {
headlightRef.current.target = headlightTarget;
}
}, [headlightTarget]);
useEffect(() => {
if (movementMode === "ebike") {
// Player just mounted — mark as ridden so we never reset position again.
hasRiddenRef.current = true;
return;
}
if (hasRiddenRef.current) {
// Player dismounted: keep the position the bike was left at.
// Just make sure the window vars are up to date for the next mount.
window.ebikeParkedPosition = restingPositionRef.current;
window.ebikeParkedRotation = restingRotationRef.current;
return;
}
// Bike has never been ridden yet — safe to (re)place it at the spawn point.
// This also fires when parkedPosition recalculates (e.g. terrain loads late).
restingPositionRef.current = parkedPosition;
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
lastGpsUpdatePos.current.set(...parkedPosition);
@@ -123,11 +176,24 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
}, [movementMode, parkedPosition]);
useEffect(() => {
if (model) {
const fork = model.getObjectByName("fourche");
if (fork) {
forkRef.current = fork;
}
if (!model) return;
let forkNode: THREE.Object3D | null = null;
model.traverse((child) => {
if (child.name.toLowerCase() === "fourche") forkNode = child;
if (child.name === "Phare") phareRef.current = child;
});
if (forkNode) {
forkRef.current = forkNode;
// Snapshot the rest-pose quaternion — steering is applied on top of this.
forkInitialQuatRef.current.copy((forkNode as THREE.Object3D).quaternion);
forkAngleRef.current = 0;
console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name);
} else {
const names: string[] = [];
model.traverse((c) => { if (c.name) names.push(c.name); });
console.warn("[Ebike] Fork not found. All nodes:", names);
}
}, [model]);
@@ -154,11 +220,48 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
}, []);
useFrame((_, delta) => {
// ── SpotLight headlight — tune the constants below ────────────────────────
// ── SpotLight headlight — tune these four constants ───────────────────────
const LIGHT_OFFSET_X = -0.7; // position : left(-) / right(+)
const LIGHT_OFFSET_Y = 1.5; // position : down(-) / up(+)
const LIGHT_OFFSET_Z = 0; // position : backward(-) / forward(+)
const LIGHT_AIM_DEG = 90; // aim rotation around Y : 0=forward, -90=left, +90=right
const LIGHT_TARGET_DIST = 20; // metres devant la position de la lumière
// ─────────────────────────────────────────────────────────────────────────
if (headlightRef.current && phareRef.current && groupRef.current) {
phareRef.current.getWorldPosition(_phareWorldPos);
groupRef.current.getWorldDirection(_bikeForward);
// Position offset in bike-local space (no GC — reusing module-level vectors)
const right = _bikeForward.clone().cross(_up).normalize();
_phareWorldPos
.addScaledVector(right, LIGHT_OFFSET_X)
.addScaledVector(_up, LIGHT_OFFSET_Y)
.addScaledVector(_bikeForward, LIGHT_OFFSET_Z);
headlightRef.current.position.copy(_phareWorldPos);
// Aim direction: rotate forward around Y by LIGHT_AIM_DEG
_aimDir
.copy(_bikeForward)
.applyAxisAngle(_up, THREE.MathUtils.degToRad(LIGHT_AIM_DEG));
headlightTarget.position
.copy(_phareWorldPos)
.addScaledVector(_aimDir, LIGHT_TARGET_DIST);
headlightTarget.updateMatrixWorld();
}
// ──────────────────────────────────────────────────────────────────────────
if (groupRef.current) {
if (movementMode === "ebike") {
// Use the ref — not the React state — to avoid stale closure bugs in
// R3F's frame loop (the state value may not update until the next render).
if (movementModeRef.current === "ebike") {
// Sound plays whenever the bike is actually moving (speedFactor > 5 %),
// not only while the input key is held.
updateEbikeSounds({
mounted: true,
driving: window.ebikeDriveInputActive === true,
driving: (window.ebikeSpeedFactor ?? 0) > 0.05,
breakdown: window.ebikeBreakdownActive === true,
});
@@ -169,16 +272,31 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
];
restingRotationRef.current = groupRef.current.rotation.y;
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
// ── Fork steering via quaternion ──────────────────────────────────────
// We rotate around the fork's LOCAL Y axis (steering tube) by composing
// a fresh quaternion on top of the rest-pose snapshot taken at load time.
// This is axis-agnostic: correct regardless of how Blender exported the node.
// Tune FORK_ANGLE (radians) or negate it if the visual direction is wrong.
const FORK_ANGLE = 0.12; // 10°
const steerFactor = window.ebikeSteerFactor ?? 0;
if (forkRef.current) {
// 15 degrees is 0.26 radians
const targetForkRotation = steerFactor * 0.26;
forkRef.current.rotation.z = THREE.MathUtils.lerp(
forkRef.current.rotation.z,
targetForkRotation,
// Smooth the angle separately so we can apply it cleanly each frame.
forkAngleRef.current = THREE.MathUtils.lerp(
forkAngleRef.current,
steerFactor * FORK_ANGLE,
12 * delta,
);
// Build steer quat around LOCAL Y of the fork node.
const steerQuat = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
forkAngleRef.current,
);
// Apply on top of rest-pose: Q_final = Q_rest × Q_steer
forkRef.current.quaternion.multiplyQuaternions(
forkInitialQuatRef.current,
steerQuat,
);
}
// Throttled GPS start position update to prevent performance loss
@@ -197,9 +315,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
groupRef.current.position.set(...restingPositionRef.current);
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
// Reset fork rotation when parked
// Reset fork to rest-pose when parked
if (forkRef.current) {
forkRef.current.rotation.z = 0;
forkRef.current.quaternion.copy(forkInitialQuatRef.current);
forkAngleRef.current = 0;
}
}
window.ebikeParkedPosition = restingPositionRef.current;
@@ -329,6 +448,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
scale={EBIKE_WORLD_SCALE}
>
<primitive object={model} />
{/* radius 20 → ~7 unités monde (scale 0.35).
Sphère omnidirectionnelle pour que le raycast fonctionne
quelle que soit l'orientation de la caméra (montée ou à pied). */}
<InteractableObject
kind="trigger"
label={interactionLabel}
@@ -337,16 +459,25 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
onPress={handleInteract}
>
<mesh>
<boxGeometry args={[8, 9, 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
<sphereGeometry args={[8, 15, 12]} />
<meshBasicMaterial colorWrite={false} color={"red"} depthWrite={false} />
</mesh>
</InteractableObject>
{/* Dynamic 3D GPS Dashboard Screen */}
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
{/* GPS + Speedmeter same group so they are perfectly co-localised.
GPS: full circle (Fresnel mask), renderOrder 10 000
Speedmeter: upper-half arc overlay, renderOrder 10 001
rotation: Math.PI/2 radians = 90° (NOT the number 90 which = ~116.6°) */}
<group position={[2, 6, 0]} rotation={[0, -80, 0]}>
<EbikeSpeedmeter width={3} height={1.5} position={[0, 0.4, 0]} gaugeInnerR={0.33} gaugeOuterR={0.445}
gaugeWidth={2.5}
gaugeHeight={2.1}
gaugeOffsetX={0}
gaugeOffsetY={-0.19}
/>
<EbikeGPSMap
width={0.8}
height={0.8}
width={1.3}
height={1}
startPos={gpsStartPos}
destPos={destPos}
mapImageUrl="/assets/world/gps/map_background.png"
@@ -359,15 +490,26 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
zoom={4}
/>
</group>
<group position={[0, 6.35, 0]} rotation={[0, 90, 0]}>
<EbikeSpeedometer />
</group>
</group>
) : null}
{/* SpotLight headlight — cone aimed forward, position & target via useFrame */}
{!repairGameOwnsEbikeModel && (
<spotLight
ref={headlightRef}
intensity={100}
color="#ffca60"
angle={Math.PI / 5} // 22.5° demi-angle — cone étroit comme une torche
penumbra={0.5} // bord doux (0 = dur, 1 = très doux)
distance={50}
decay={2.5}
castShadow={false}
/>
)}
{showCameraPoints && !repairGameOwnsEbikeModel && (
<>
<mesh position={camPointPos}>
{/* <mesh position={camPointPos}>
<sphereGeometry args={[0.3, 16, 16]} />
<meshStandardMaterial
color="yellow"
@@ -382,7 +524,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
emissive="cyan"
emissiveIntensity={0.5}
/>
</mesh>
</mesh> */}
</>
)}
</>
+75 -37
View File
@@ -12,6 +12,28 @@ import {
} from "@/pathfinding/WaypointAStar";
import type { Waypoint } from "@/pathfinding/types";
import type { Vector3Tuple } from "@/types/three/three";
const VERT_SHADER = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
// Circular Fresnel mask: fully visible inside innerRadius, fades out to outerRadius
const FRAG_SHADER = /* glsl */ `
uniform sampler2D map;
uniform float innerRadius;
uniform float outerRadius;
varying vec2 vUv;
void main() {
vec4 color = texture2D(map, vUv);
float dist = length(vUv - vec2(0.5));
float mask = 1.0 - smoothstep(innerRadius, outerRadius, dist);
gl_FragColor = vec4(color.rgb, color.a * mask);
}
`;
function computeImageSource(
img: HTMLImageElement | HTMLCanvasElement,
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
@@ -126,19 +148,57 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- Canvas should only be created once
}, []);
// Resize the canvas whenever canvasSize changes
// Note: Modifying canvas dimensions is intentional and necessary for rendering
useEffect(() => {
// Use Object.assign to resize canvas - this is a necessary mutation for canvas rendering
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
}, [canvasSize, offscreenCanvas]);
const textureRef = useRef<THREE.CanvasTexture | null>(null);
const animTimeRef = useRef<number>(0);
// Imperative CanvasTexture — must be declared before the resize effect below
const texture = useMemo(() => {
const tex = new THREE.CanvasTexture(offscreenCanvas);
tex.format = THREE.RGBAFormat;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
return tex;
}, [offscreenCanvas]);
// ShaderMaterial with circular Fresnel mask (created once)
const shaderMat = useMemo(
() =>
new THREE.ShaderMaterial({
uniforms: {
map: { value: null },
innerRadius: { value: 0.45 },
outerRadius: { value: 0.5 },
},
vertexShader: VERT_SHADER,
fragmentShader: FRAG_SHADER,
transparent: true,
depthTest: false,
depthWrite: false,
side: THREE.DoubleSide,
toneMapped: false,
}),
[],
);
// Sync texture into uniform when it changes (canvas resize)
useEffect(() => {
shaderMat.uniforms.map.value = texture;
}, [shaderMat, texture]);
// Cleanup on unmount
useEffect(
() => () => {
shaderMat.dispose();
texture.dispose();
},
[shaderMat, texture],
);
// Resize the canvas whenever canvasSize changes (texture declared above)
useEffect(() => {
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
texture.needsUpdate = true;
}, [canvasSize, offscreenCanvas, texture]);
// Load waypoints (localStorage with /roadNetwork.json fallback)
useEffect(() => {
let cancelled = false;
@@ -492,42 +552,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
useEffect(() => {
let animId: number;
const tick = () => {
animTimeRef.current += 0.004; // Slow, premium sweep speed
animTimeRef.current += 0.004;
if (animTimeRef.current > 1) animTimeRef.current = 0;
draw();
// Update texture after draw
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
texture.needsUpdate = true;
animId = requestAnimationFrame(tick);
};
animId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animId);
}, [draw]);
}, [draw, texture]);
return (
<mesh position={position} renderOrder={renderOrder}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
toneMapped={false}
transparent={true}
opacity={1}
depthTest={false}
depthWrite={false}
side={THREE.DoubleSide}
>
<canvasTexture
ref={textureRef}
attach="map"
image={offscreenCanvas}
format={THREE.RGBAFormat}
minFilter={THREE.LinearFilter}
magFilter={THREE.LinearFilter}
/>
</meshBasicMaterial>
<primitive object={shaderMat} attach="material" />
</mesh>
);
};
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import * as THREE from "three";
import type { Vector3Tuple } from "@/types/three/three";
import "@/types/ebike/ebikeWindow";
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
export interface EbikeSpeedmeterProps {
width?: number;
height?: number;
/** Local position offset within the parent group. Default: [0, 0, 0] */
position?: Vector3Tuple;
/**
* Needle rotation.z when speedFactor = 0.
* Default: Math.PI / 2 (pointing left — 9 o'clock)
*/
minAngle?: number;
/**
* Needle rotation.z when speedFactor = 1.
* Default: -Math.PI / 2 (pointing right — 3 o'clock)
*/
maxAngle?: number;
renderOrder?: number;
/**
* Inner radius of the gauge-fill arc, as a fraction of the canvas half-width.
* Tune this to align the fill with the cadran.png track. Default: 0.33
*/
gaugeInnerR?: number;
/**
* Outer radius of the gauge-fill arc, as a fraction of the canvas half-width.
* Tune this to align the fill with the cadran.png track. Default: 0.445
*/
gaugeOuterR?: number;
/**
* Width of the gauge-fill plane. Defaults to `width` when omitted.
* Lets you resize the fill independently of the cadran/needle.
*/
gaugeWidth?: number;
/**
* Height of the gauge-fill plane. Defaults to `height` when omitted.
* Lets you resize the fill independently of the cadran/needle.
*/
gaugeHeight?: number;
/**
* Horizontal offset of the arc pivot from the canvas centre.
* Expressed as a fraction of the canvas size: -0.1 = shift 10 % to the left,
* +0.1 = shift 10 % to the right. Default: 0
*/
gaugeOffsetX?: number;
/**
* Vertical offset of the arc pivot from its default position.
* Expressed as a fraction of the canvas size: -0.1 = shift upward (toward top
* of the plane), +0.1 = shift downward. Default: 0
*/
gaugeOffsetY?: number;
}
// The needle pivot is always at -height*0.38 in local space,
// which is always 12 % from the bottom of the plane (UV y = 0.12).
// With Three.js flipY texture convention, canvas y = (1 - 0.12) * size = 0.88 * size.
const NEEDLE_PIVOT_UV_Y = 0.12; // fraction from bottom
export function EbikeSpeedmeter({
width = 0.8,
height = 0.8,
position = [0, 0, 0],
minAngle = Math.PI / 2,
maxAngle = -Math.PI / 2,
renderOrder = 1000,
gaugeInnerR = 0.33,
gaugeOuterR = 0.445,
gaugeWidth,
gaugeHeight,
gaugeOffsetX = 0,
gaugeOffsetY = 0,
}: EbikeSpeedmeterProps): React.JSX.Element {
// Fall back to the main dimensions when gauge-specific ones aren't provided
const fillW = gaugeWidth ?? width;
const fillH = gaugeHeight ?? height;
const needleGroupRef = useRef<THREE.Group>(null);
const speedFactorRef = useRef(0);
// ── Dial & needle textures ──────────────────────────────────────────────────
const [dialTexture, needleTexture] = useTexture([
SPEEDOMETER_DIAL_TEXTURE,
SPEEDOMETER_NEEDLE_TEXTURE,
]) as [THREE.Texture, THREE.Texture];
const needleWidth = width * 0.68;
const needleHeight = needleWidth / 2;
useEffect(() => {
[dialTexture, needleTexture].forEach((tex) => {
tex.colorSpace = THREE.SRGBColorSpace;
tex.needsUpdate = true;
});
}, [dialTexture, needleTexture]);
// ── Gauge-fill canvas ───────────────────────────────────────────────────────
const fillCanvas = useMemo(() => {
const c = document.createElement("canvas");
c.width = 256;
c.height = 256;
return c;
}, []);
const fillTexture = useMemo(() => {
const tex = new THREE.CanvasTexture(fillCanvas);
tex.format = THREE.RGBAFormat;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
return tex;
}, [fillCanvas]);
useEffect(
() => () => {
fillTexture.dispose();
},
[fillTexture],
);
// ── Frame loop ──────────────────────────────────────────────────────────────
useFrame((_, delta) => {
// 1. Smooth speed factor
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
speedFactorRef.current = THREE.MathUtils.lerp(
speedFactorRef.current,
target,
Math.min(1, delta * 10),
);
// 2. Needle rotation
if (needleGroupRef.current) {
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
minAngle,
maxAngle,
speedFactorRef.current,
);
}
// 3. Draw gauge fill -------------------------------------------------------
const ctx = fillCanvas.getContext("2d", { alpha: true });
if (!ctx) return;
const size = fillCanvas.width;
ctx.clearRect(0, 0, size, size);
// Default centre: horizontal middle + needle-pivot height.
// gaugeOffsetX/Y shift the pivot so the arc aligns with cadran.png.
const cx = size * (0.5 + gaugeOffsetX);
const cy = size * ((1 - NEEDLE_PIVOT_UV_Y) + gaugeOffsetY); // default ≈ 0.88 × size
const outerR = size * gaugeOuterR;
const innerR = size * gaugeInnerR;
// Arc sweeps clockwise from π (left) to current needle angle
const arcStart = Math.PI;
const arcEnd = Math.PI + speedFactorRef.current * Math.PI;
if (speedFactorRef.current > 0.005) {
// Radial gradient using #3F67DD — slightly transparent at inner edge,
// fully solid at outer edge for a depth effect.
const radial = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
radial.addColorStop(0, "rgba(191, 234, 255, 0)"); // inner edge
radial.addColorStop(0.7, "rgba(118, 152, 255, 0.95)"); // outer edge
// Annular sector shape (outer arc + inner arc reversed)
ctx.beginPath();
ctx.arc(cx, cy, outerR, arcStart, arcEnd, false);
ctx.arc(cx, cy, innerR, arcEnd, arcStart, true);
ctx.closePath();
ctx.fillStyle = radial;
ctx.shadowBlur = 16;
ctx.shadowColor = "#3F67DD";
ctx.fill();
ctx.shadowBlur = 0;
}
fillTexture.needsUpdate = true;
});
return (
<group renderOrder={renderOrder} position={position}>
{/* Gauge fill — behind the cadran frame (size controlled by gaugeWidth/gaugeHeight) */}
<mesh renderOrder={renderOrder - 1} position={[0, 0, -0.001]}>
<planeGeometry args={[fillW, fillH]} />
<meshBasicMaterial
map={fillTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
{/* Dial frame (cadran.png) */}
<mesh renderOrder={renderOrder}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={dialTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
{/* Needle — pivot at bottom-centre of the arc */}
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]} rotation={[0, 0, 0]}>
<mesh
position={[0, needleHeight / 2, 0]}
renderOrder={renderOrder + 1}
>
<planeGeometry args={[needleWidth, needleHeight]} />
<meshBasicMaterial
map={needleTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
</group>
</group>
);
}
@@ -15,11 +15,15 @@ import {
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
REPAIR_CASE_CLOSE_SOUND_PATH,
REPAIR_CASE_OPEN_SOUND_PATH,
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION,
REPAIR_CASE_PART_ANCHOR_FALLBACKS,
REPAIR_CASE_PART_ANCHOR_NAMES,
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
REPAIR_CASE_POP_DURATION,
REPAIR_CASE_POP_Y_OFFSET,
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
REPAIR_CASE_ROTATION_RESET_SPEED,
type RepairCasePartAnchorName,
} from "@/data/gameplay/repairCaseConfig";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
@@ -32,6 +36,10 @@ export interface RepairCasePlaceholder {
position: Vector3Tuple;
}
export type RepairCasePartAnchors = Partial<
Record<RepairCasePartAnchorName, Vector3Tuple>
>;
interface RepairCaseModelProps extends ModelTransformProps {
modelPath: string;
open: boolean;
@@ -40,6 +48,7 @@ interface RepairCaseModelProps extends ModelTransformProps {
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onAnchorsChange?: ((anchors: RepairCasePartAnchors) => void) | undefined;
onExitComplete?: (() => void) | undefined;
}
@@ -59,6 +68,7 @@ export function RepairCaseModel({
exiting = false,
floating = true,
onPlaceholdersChange,
onAnchorsChange,
onExitComplete,
position = [0, 0, 0],
rotation = [0, 0, 0],
@@ -81,6 +91,7 @@ export function RepairCaseModel({
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
const onExitCompleteRef = useRef(onExitComplete);
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
const onAnchorsChangeRef = useRef(onAnchorsChange);
const initialOpen = useRef(open);
const previousOpen = useRef(open);
const openedRotationZ = useRef(0);
@@ -89,6 +100,12 @@ export function RepairCaseModel({
const placeholderSignature = useRef("__initial__");
const placeholderPosition = useRef(new THREE.Vector3());
const placeholderLocalPosition = useRef(new THREE.Vector3());
const anchorNodes = useRef<Map<RepairCasePartAnchorName, THREE.Object3D>>(
new Map(),
);
const anchorSignature = useRef("__initial__");
const anchorWorldPosition = useRef(new THREE.Vector3());
const anchorLocalPosition = useRef(new THREE.Vector3());
useEffect(() => {
onExitCompleteRef.current = onExitComplete;
@@ -98,6 +115,10 @@ export function RepairCaseModel({
onPlaceholdersChangeRef.current = onPlaceholdersChange;
}, [onPlaceholdersChange]);
useEffect(() => {
onAnchorsChangeRef.current = onAnchorsChange;
}, [onAnchorsChange]);
useEffect(() => {
const popAnimation = pop.current;
@@ -153,6 +174,37 @@ export function RepairCaseModel({
}
});
// Resolve part anchor nodes (cabledroit, cablegauche, pucehaut, pucebas,
// refroidisseur). Existing GLTF nodes are reused and their meshes are
// hidden so the standalone model injected at the same position becomes
// the only visible representation. Missing nodes are created on the fly
// at the configured fallback case-local position.
anchorNodes.current = new Map();
REPAIR_CASE_PART_ANCHOR_NAMES.forEach((anchorName) => {
let node = model.getObjectByName(anchorName);
if (node) {
node.traverse((descendant) => {
if ((descendant as THREE.Mesh).isMesh) {
descendant.visible = false;
}
});
} else {
const placeholder = new THREE.Object3D();
placeholder.name = anchorName;
const fallback = REPAIR_CASE_PART_ANCHOR_FALLBACKS[anchorName];
placeholder.position.set(fallback[0], fallback[1], fallback[2]);
placeholder.quaternion.set(
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[0],
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[1],
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[2],
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[3],
);
model.add(placeholder);
node = placeholder;
}
anchorNodes.current.set(anchorName, node);
});
if (lid) {
lid.rotation.z =
openedRotationZ.current +
@@ -250,6 +302,31 @@ export function RepairCaseModel({
}
}
if (anchorNodes.current.size > 0) {
const anchors: RepairCasePartAnchors = {};
const signatureParts: string[] = [];
anchorNodes.current.forEach((node, anchorName) => {
node.getWorldPosition(anchorWorldPosition.current);
anchorLocalPosition.current.copy(anchorWorldPosition.current);
group.parent?.worldToLocal(anchorLocalPosition.current);
const tuple: Vector3Tuple = [
anchorLocalPosition.current.x,
anchorLocalPosition.current.y,
anchorLocalPosition.current.z,
];
anchors[anchorName] = tuple;
signatureParts.push(
`${anchorName}:${tuple.map((value) => value.toFixed(3)).join(",")}`,
);
});
signatureParts.sort();
const nextAnchorSignature = signatureParts.join("|");
if (nextAnchorSignature !== anchorSignature.current) {
anchorSignature.current = nextAnchorSignature;
onAnchorsChangeRef.current?.(anchors);
}
}
animationActiveRef.current = isNear;
if (animationActiveRef.current) {
+41 -7
View File
@@ -1,7 +1,11 @@
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 type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel";
import type {
RepairCasePartAnchors,
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";
@@ -63,12 +67,15 @@ export function RepairGame({
const [casePlaceholders, setCasePlaceholders] = useState<
readonly RepairCasePlaceholder[]
>([]);
const [caseAnchors, setCaseAnchors] = useState<RepairCasePartAnchors>({});
const [brokenAnchors, setBrokenAnchors] = useState<ExplodedNodeAnchors>({});
const [scannedBrokenParts, setScannedBrokenParts] = useState<
readonly RepairScannedBrokenPart[]
>([]);
const parsedScale = toVector3Scale(scale);
const snappedPosition = useTerrainSnappedPosition(position);
const readyForFragmentation = step === "inspected";
const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]);
useRepairFragmentationInput({
enabled: mainState === mission && readyForFragmentation,
@@ -81,6 +88,8 @@ export function RepairGame({
const timeoutId = window.setTimeout(() => {
setCasePlaceholders([]);
setCaseAnchors({});
setBrokenAnchors({});
setScannedBrokenParts([]);
}, 0);
@@ -136,12 +145,24 @@ export function RepairGame({
/>
) : null}
{step === "repairing" ? (
<RepairRepairingStep
brokenParts={scannedBrokenParts}
config={config}
placeholders={casePlaceholders}
onRepair={() => setMissionStep(mission, "reassembling")}
/>
<>
<ExplodableModel
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
split
hideNodeNames={brokenNodeNames}
nodeAnchorNames={brokenNodeNames}
onNodeAnchorsChange={setBrokenAnchors}
/>
<RepairRepairingStep
anchors={caseAnchors}
brokenAnchors={brokenAnchors}
brokenParts={scannedBrokenParts}
config={config}
placeholders={casePlaceholders}
onRepair={() => setMissionStep(mission, "reassembling")}
/>
</>
) : null}
{step === "reassembling" ? (
<RepairReassemblyStep
@@ -159,6 +180,7 @@ export function RepairGame({
<RepairMissionCase
config={config}
onPlaceholdersChange={setCasePlaceholders}
onAnchorsChange={setCaseAnchors}
open={step === "repairing"}
zoomed={step === "repairing"}
showFragmentationPrompt={readyForFragmentation}
@@ -188,3 +210,15 @@ function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
]),
];
}
function getBrokenNodeNames(config: RepairMissionConfig): readonly string[] {
const names = new Set<string>();
config.brokenParts.forEach((part) => {
if (part.targetNodeName) names.add(part.targetNodeName);
else if (part.nodeName) names.add(part.nodeName);
});
config.replacementParts.forEach((part) => {
if (part.targetNodeName) names.add(part.targetNodeName);
});
return Array.from(names);
}
@@ -1,5 +1,6 @@
import {
RepairCaseModel,
type RepairCasePartAnchors,
type RepairCasePlaceholder,
} from "@/components/three/gameplay/RepairCaseModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
@@ -19,6 +20,7 @@ interface RepairMissionCaseProps {
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onAnchorsChange?: ((anchors: RepairCasePartAnchors) => void) | undefined;
onExitComplete?: (() => void) | undefined;
open?: boolean;
zoomed?: boolean;
@@ -30,6 +32,7 @@ export function RepairMissionCase({
config,
exiting = false,
onPlaceholdersChange,
onAnchorsChange,
onExitComplete,
open = false,
zoomed = false,
@@ -57,6 +60,7 @@ export function RepairMissionCase({
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
onAnchorsChange={onAnchorsChange}
open={open}
floating={!zoomed}
position={modelPosition}
@@ -70,6 +74,7 @@ export function RepairMissionCase({
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
onAnchorsChange={onAnchorsChange}
open={open}
floating={!zoomed}
position={modelPosition}
@@ -8,6 +8,7 @@ import { toVector3Scale } from "@/utils/three/scale";
interface RepairObjectModelProps extends ModelTransformProps {
label: string;
modelPath: string;
ghosted?: boolean;
}
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
@@ -73,6 +74,7 @@ export function RepairObjectModel({
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
ghosted = false,
}: RepairObjectModelProps): React.JSX.Element {
return (
<RepairObjectModelBoundary
@@ -87,6 +89,7 @@ export function RepairObjectModel({
position={position}
rotation={rotation}
scale={scale}
opacity={ghosted ? 0.35 : 1}
/>
</RepairObjectModelBoundary>
);
@@ -1,6 +1,10 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import type {
RepairCasePartAnchors,
RepairCasePlaceholder,
} from "@/components/three/gameplay/RepairCaseModel";
import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
@@ -38,6 +42,8 @@ const STORED_BROKEN_PART_COLOR = "#38bdf8";
let hasWarnedMissingPlaceholders = false;
interface RepairRepairingStepProps {
anchors?: RepairCasePartAnchors;
brokenAnchors?: ExplodedNodeAnchors;
brokenParts: readonly RepairScannedBrokenPart[];
config: RepairMissionConfig;
placeholders: readonly RepairCasePlaceholder[];
@@ -63,6 +69,8 @@ interface RepairPartPlacementFeedbackProps {
}
export function RepairRepairingStep({
anchors = {},
brokenAnchors = {},
brokenParts,
config,
placeholders,
@@ -76,12 +84,15 @@ export function RepairRepairingStep({
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
Record<string, boolean>
>({});
const [heldPartByLockGroup, setHeldPartByLockGroup] = useState<
Record<string, string>
>({});
const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] =
useState(false);
const replacementParts = getReplacementParts(config);
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
const requiredReplacementPart = replacementParts.find(
(part) => part.id === config.requiredReplacementPartId,
const requiredReplacementPart = replacementParts.find((part) =>
config.requiredReplacementPartIds.includes(part.id),
);
const requiredReplacementLabel =
requiredReplacementPart?.label ?? config.label;
@@ -89,15 +100,16 @@ export function RepairRepairingStep({
const placeholderPositions = placeholderTargets.map(
(target) => target.position,
);
const hasCorrectPartPlaced = Boolean(
placedPartIds[config.requiredReplacementPartId],
const hasCorrectPartPlaced = config.requiredReplacementPartIds.some(
(id) => placedPartIds[id],
);
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
(part) => depositedBrokenPartIds[part.id],
);
const hasWrongPartPlaced = replacementParts.some(
(part) =>
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
!config.requiredReplacementPartIds.includes(part.id) &&
placedPartIds[part.id],
);
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
const installColor = isReadyToInstall
@@ -177,6 +189,24 @@ export function RepairRepairingStep({
});
}
function handleReplacementGrabChange(
part: RepairMissionPartConfig,
held: boolean,
): void {
if (!part.caseLockGroup) return;
const group = part.caseLockGroup;
setHeldPartByLockGroup((current) => {
if (held) {
if (current[group] === part.id) return current;
return { ...current, [group]: part.id };
}
if (current[group] !== part.id) return current;
const next = { ...current };
delete next[group];
return next;
});
}
return (
<group ref={groupRef}>
<RepairInstallTarget
@@ -192,15 +222,23 @@ export function RepairRepairingStep({
<RepairPlaceholderMarkers positions={placeholderPositions} />
{replacementParts.map((part, index) => {
const anchorPosition = part.caseAnchor
? anchors[part.caseAnchor]
: undefined;
const placeholderPosition =
anchorPosition ??
placeholderPositions[index % placeholderPositions.length] ??
placeholderPositions[0]!;
const isPlaced = Boolean(placedPartIds[part.id]);
const feedbackState = getReplacementFeedbackState(
part.id,
config.requiredReplacementPartId,
config.requiredReplacementPartIds,
isPlaced,
);
const lockedByOther =
part.caseLockGroup !== undefined &&
heldPartByLockGroup[part.caseLockGroup] !== undefined &&
heldPartByLockGroup[part.caseLockGroup] !== part.id;
return (
<GrabbableObject
@@ -208,7 +246,11 @@ export function RepairRepairingStep({
position={placeholderPosition}
colliders="ball"
handControlled
disabled={lockedByOther}
label={`Prendre ${part.label}`}
onGrabChange={(held) => {
handleReplacementGrabChange(part, held);
}}
onPositionChange={(position) => {
handleReplacementPosition(part.id, position);
}}
@@ -224,6 +266,7 @@ export function RepairRepairingStep({
label={part.label}
modelPath={part.modelPath ?? config.modelPath}
scale={0.36}
ghosted={lockedByOther}
/>
<RepairPartPlacementFeedback state={feedbackState} />
</group>
@@ -232,14 +275,18 @@ export function RepairRepairingStep({
})}
{brokenPartsToDeposit.map((part, index) => {
const startOffset =
const fallbackOffset =
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 fallbackPosition: Vector3Tuple = [
REPAIR_CASE_FOCUS_POSITION[0] + fallbackOffset[0],
REPAIR_CASE_FOCUS_POSITION[1] + fallbackOffset[1],
REPAIR_CASE_FOCUS_POSITION[2] + fallbackOffset[2],
];
const anchorPosition = part.targetNodeName
? brokenAnchors[part.targetNodeName]
: undefined;
const startPosition: Vector3Tuple = anchorPosition ?? fallbackPosition;
const targetPositions = getBrokenPartTargetPositions(
part,
placeholderTargets,
@@ -387,12 +434,12 @@ function getPlacementFeedbackColor(
function getReplacementFeedbackState(
partId: string,
requiredPartId: string,
requiredPartIds: readonly string[],
isPlaced: boolean,
): RepairPartPlacementFeedbackProps["state"] {
if (!isPlaced) return null;
return partId === requiredPartId ? "valid" : "invalid";
return requiredPartIds.includes(partId) ? "valid" : "invalid";
}
function getPlaceholderTargets(
@@ -466,9 +513,12 @@ function getReplacementParts(
): readonly RepairMissionPartConfig[] {
if (config.replacementParts.length > 0) return config.replacementParts;
const fallbackId =
config.requiredReplacementPartIds[0] ?? `${config.id}-replacement`;
return [
{
id: config.requiredReplacementPartId,
id: fallbackId,
label: config.label,
modelPath: config.modelPath,
},
@@ -486,5 +536,6 @@ function getBrokenPartsToDeposit(
label: part.label,
modelPath: part.modelPath ?? config.modelPath,
...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}),
...(part.targetNodeName ? { targetNodeName: part.targetNodeName } : {}),
}));
}
@@ -97,6 +97,9 @@ function getScannedBrokenParts(
...(match.config.caseSlotName
? { caseSlotName: match.config.caseSlotName }
: {}),
...(match.config.targetNodeName
? { targetNodeName: match.config.targetNodeName }
: {}),
};
});
}
@@ -12,6 +12,11 @@ import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { HandTrackingLandmark } from "@/types/handTracking/handTracking";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
// Both gloves share the same source mesh (gant_l). The right glove is
// rendered by mirroring scale.x at the group level — this is more
// reliable than the historical gant_r GLTF, which embeds multiple
// skeletons (Hand_l, Hand_l_pad, Hand_r) and was breaking the finger
// rig.
const GLOVE_CONFIGS: Record<
HandTrackingGloveHandedness,
{
@@ -24,8 +29,8 @@ const GLOVE_CONFIGS: Record<
rootNodeName: "Armature",
},
right: {
modelPath: "/models/gant_r/model.gltf",
rootNodeName: "Hand_r",
modelPath: "/models/gant_l/model.gltf",
rootNodeName: "Armature",
},
};
@@ -226,7 +231,10 @@ function applyFingerPose(
_boneTargetQuaternion
.copy(_boneDeltaQuaternion)
.multiply(pose.restQuaternion);
pose.bone.quaternion.slerp(_boneTargetQuaternion, 0.45);
// Lower slerp factor = smoother but more latency. MediaPipe at
// ~10fps produces noisy landmark frames; smoothing cuts the
// jitter the user sees on every finger bone.
pose.bone.quaternion.slerp(_boneTargetQuaternion, 0.3);
}
}
}
@@ -334,12 +342,18 @@ function HandTrackingGloveModel({
_matrix.makeBasis(_xAxis, _yAxis, _zAxis);
_targetQuaternion.setFromRotationMatrix(_matrix);
group.position.lerp(_targetPosition, Math.min(1, delta * 18));
group.quaternion.slerp(_targetQuaternion, Math.min(1, delta * 18));
// Lower factor (was 18) damps the glove jitter caused by noisy
// landmarks while keeping a responsive feel.
group.position.lerp(_targetPosition, Math.min(1, delta * 12));
group.quaternion.slerp(_targetQuaternion, Math.min(1, delta * 12));
const palmLength = _wristPosition.distanceTo(_middlePosition);
const scale = palmLength * GLOVE_MODEL_SCALE;
group.scale.setScalar(scale);
// Both gloves use the gant_l mesh; flip X for the right hand so the
// thumb ends up on the correct side instead of being a left-glove
// clone on the right hand.
const mirrorSignX = handedness === "right" ? -1 : 1;
group.scale.set(scale * mirrorSignX, scale, scale);
group.updateMatrixWorld(true);
applyFingerPose(fingerPoseChains, trackedHand.landmarks, camera);
});
@@ -34,6 +34,8 @@ interface GrabbableObjectProps {
colliders?: ColliderShape;
label?: string;
handControlled?: boolean;
disabled?: boolean;
onGrabChange?: (held: boolean) => void;
onPositionChange?: (position: THREE.Vector3) => void;
onSnap?: (position: THREE.Vector3) => void;
snapDuration?: number;
@@ -131,6 +133,8 @@ export function GrabbableObject({
colliders = GRAB_DEFAULT_COLLIDERS,
label = GRAB_DEFAULT_LABEL,
handControlled = false,
disabled = false,
onGrabChange,
onPositionChange,
onSnap,
snapDuration = 0.25,
@@ -152,6 +156,19 @@ export function GrabbableObject({
};
}, []);
useEffect(() => {
if (!disabled) return;
if (isHolding.current) {
isHolding.current = false;
onGrabChange?.(false);
}
if (isHandHolding.current) {
isHandHolding.current = false;
InteractionManager.getInstance().setHandHolding(false);
onGrabChange?.(false);
}
}, [disabled, onGrabChange]);
function snapToNearestTarget(): void {
const body = rbRef.current;
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
@@ -242,14 +259,16 @@ export function GrabbableObject({
useFrame(() => {
if (!rbRef.current) return;
const fistHand = handControlled
? hands.find((hand) => hand.isFist)
: undefined;
const t = rbRef.current.translation();
_currentPos.set(t.x, t.y, t.z);
onPositionChange?.(_currentPos);
if (disabled) return;
const fistHand = handControlled
? hands.find((hand) => hand.isFist)
: undefined;
if (fistHand) {
const handCenter = getHandCenterPoint(fistHand);
@@ -267,15 +286,20 @@ export function GrabbableObject({
? getHandHit(groupRef.current, camera, _cameraPos, handCenter)
: null;
isHandHolding.current = Boolean(hit);
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
const hadHit = Boolean(hit);
if (hadHit) {
isHandHolding.current = true;
InteractionManager.getInstance().setHandHolding(true);
onGrabChange?.(true);
}
}
} else {
if (isHandHolding.current) {
snapToNearestTarget();
isHandHolding.current = false;
InteractionManager.getInstance().setHandHolding(false);
onGrabChange?.(false);
}
isHandHolding.current = false;
InteractionManager.getInstance().setHandHolding(false);
}
if (!isHolding.current && !isHandHolding.current) return;
@@ -311,35 +335,41 @@ export function GrabbableObject({
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>
{disabled ? (
children
) : (
<InteractableObject
kind="grab"
label={label}
position={position}
bodyRef={rbRef}
onPress={() => {
isHolding.current = true;
onGrabChange?.(true);
}}
onRelease={() => {
isHolding.current = false;
onGrabChange?.(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>
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { Component, useEffect, useMemo } from "react";
import { Component, useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
@@ -9,6 +10,10 @@ import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import { toVector3Scale } from "@/utils/three/scale";
export type ExplodedNodeAnchors = Readonly<Record<string, Vector3Tuple>>;
const _anchorWorld = new THREE.Vector3();
interface ModelErrorBoundaryProps {
children: ReactNode;
modelPath: string;
@@ -67,6 +72,9 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
split: boolean;
splitDistance?: number;
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
hideNodeNames?: readonly string[];
nodeAnchorNames?: readonly string[];
onNodeAnchorsChange?: (anchors: ExplodedNodeAnchors) => void;
}
export function ExplodableModel(
@@ -93,6 +101,9 @@ function ExplodableModelInner({
scale = 1,
splitDistance = 1.2,
onPartsReady,
hideNodeNames,
nodeAnchorNames,
onNodeAnchorsChange,
}: ExplodableModelInnerProps): React.JSX.Element {
const { scene } = useLoggedGLTF(modelPath, {
scope: "ExplodableModel",
@@ -106,6 +117,24 @@ function ExplodableModelInner({
[model, splitDistance],
);
const parsedScale = toVector3Scale(scale);
const anchorSignatureRef = useRef("");
useEffect(() => {
if (!hideNodeNames || hideNodeNames.length === 0) return;
const hidden: THREE.Object3D[] = [];
model.traverse((child) => {
if (hideNodeNames.includes(child.name)) {
hidden.push(child);
child.visible = false;
}
});
return () => {
hidden.forEach((object) => {
object.visible = true;
});
};
}, [hideNodeNames, model]);
useEffect(() => {
explodedModel.setSplit(split);
@@ -117,6 +146,35 @@ function ExplodableModelInner({
useFrame((_, delta) => {
explodedModel.update(delta);
if (
!onNodeAnchorsChange ||
!nodeAnchorNames ||
nodeAnchorNames.length === 0
) {
return;
}
const anchors: Record<string, Vector3Tuple> = {};
nodeAnchorNames.forEach((name) => {
const node = model.getObjectByName(name);
if (!node) return;
node.getWorldPosition(_anchorWorld);
anchors[name] = [_anchorWorld.x, _anchorWorld.y, _anchorWorld.z];
});
const signature = nodeAnchorNames
.map((name) => {
const a = anchors[name];
return a
? `${name}:${a[0].toFixed(3)},${a[1].toFixed(3)},${a[2].toFixed(3)}`
: `${name}:?`;
})
.join("|");
if (signature === anchorSignatureRef.current) return;
anchorSignatureRef.current = signature;
onNodeAnchorsChange(anchors);
});
return (
@@ -17,10 +17,29 @@ function applyShadowSettings(
});
}
function applyOpacity(object: THREE.Object3D, opacity: number): void {
object.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const materials = Array.isArray(child.material)
? child.material
: [child.material];
materials.forEach((material) => {
if (!(material instanceof THREE.Material)) return;
material.transparent = opacity < 1;
material.opacity = opacity;
material.depthWrite = opacity >= 1;
material.needsUpdate = true;
});
});
}
interface SimpleModelConfig extends ModelTransformProps {
modelPath: string;
castShadow?: boolean;
receiveShadow?: boolean;
opacity?: number;
}
interface SimpleModelProps extends SimpleModelConfig {
@@ -34,6 +53,7 @@ export function SimpleModel({
scale = 1,
castShadow = true,
receiveShadow = true,
opacity = 1,
children,
}: SimpleModelProps): React.JSX.Element {
const { scene } = useLoggedGLTF(modelPath, {
@@ -48,6 +68,10 @@ export function SimpleModel({
applyShadowSettings(model, castShadow, receiveShadow);
}, [castShadow, model, receiveShadow]);
useEffect(() => {
applyOpacity(model, opacity);
}, [model, opacity]);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
+2
View File
@@ -1,6 +1,7 @@
import { Crosshair } from "@/components/ui/Crosshair";
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
import { HandTrackingFallback } from "@/components/ui/HandTrackingFallback";
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
@@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element {
<RepairMovementLockIndicator />
<InteractPrompt />
<HandTrackingVisualizer />
<HandTrackingFallback />
<Subtitles />
<TalkieDialogueOverlay />
<GameSettingsMenu />
@@ -0,0 +1,82 @@
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import {
useHandTrackingGloveStatus,
type HandTrackingGloveHandedness,
} from "@/hooks/handTracking/useHandTrackingGloveStatus";
// Simple schematic silhouettes used as a last-resort fallback when the
// rigged glove model has failed to load. Both icons share the same
// 48x48 viewBox and the same stroke/fill rules from the .css.
const OpenHandShape = (): React.JSX.Element => (
<>
<ellipse cx="9" cy="30" rx="3" ry="6" transform="rotate(-25 9 30)" />
<rect x="14" y="8" width="4" height="22" rx="2" />
<rect x="20" y="4" width="4" height="26" rx="2" />
<rect x="26" y="6" width="4" height="24" rx="2" />
<rect x="32" y="10" width="4" height="20" rx="2" />
<rect x="10" y="26" width="28" height="18" rx="6" />
</>
);
const FistShape = (): React.JSX.Element => (
<>
<ellipse cx="8" cy="26" rx="3" ry="5" />
<rect x="10" y="14" width="28" height="30" rx="10" />
<circle cx="15" cy="14" r="3" />
<circle cx="21" cy="13" r="3" />
<circle cx="27" cy="13" r="3" />
<circle cx="33" cy="14" r="3" />
</>
);
function getHandedness(raw: string): HandTrackingGloveHandedness | null {
const lower = raw.toLowerCase();
if (lower === "left" || lower === "right") return lower;
return null;
}
export function HandTrackingFallback(): React.JSX.Element | null {
const { hands } = useHandTrackingSnapshot();
const gloveStatus = useHandTrackingGloveStatus((state) => state.gloves);
const visibleHands = hands.flatMap((hand, index) => {
const handedness = getHandedness(hand.handedness);
if (!handedness) return [];
if (gloveStatus[handedness] !== "error") return [];
const wrist = hand.landmarks[0];
if (!wrist) return [];
return [{ hand, handedness, wrist, index }];
});
if (visibleHands.length === 0) return null;
return (
<div className="hand-tracking-fallback" aria-hidden="true">
{visibleHands.map(({ hand, handedness, wrist, index }) => {
// MediaPipe coords are mirrored (selfie cam), keep the same
// mapping the SVG visualizer uses.
const leftPercent = (1 - wrist.x) * 100;
const topPercent = wrist.y * 100;
const flipX = handedness === "right" ? -1 : 1;
return (
<svg
key={`${handedness}-${index}`}
className="hand-tracking-fallback__icon"
viewBox="0 0 48 48"
style={{
left: `${leftPercent}%`,
top: `${topPercent}%`,
transform: `translate(-50%, -50%) scaleX(${flipX})`,
}}
>
{hand.isFist ? <FistShape /> : <OpenHandShape />}
</svg>
);
})}
</div>
);
}
+15 -5
View File
@@ -26,6 +26,12 @@ const HAND_CONNECTIONS: Array<[number, number]> = [
[0, 17],
];
const LANDMARK_FILL = "#67e8f9"; // cyan-300, opaque interior
const LANDMARK_STROKE = "#0c4a6e"; // sky-900, dark blue outline
const LANDMARK_STROKE_FIST = "#1e3a8a"; // blue-900, thicker accent when fist
const CONNECTION_STROKE = "#ffffff"; // white bones
const INDEX_TIP_LANDMARK = 8;
export function HandTrackingVisualizer(): React.JSX.Element | null {
const { hands, status } = useHandTrackingSnapshot();
const showHandTrackingSvg = useDebugStore((debug) =>
@@ -50,7 +56,9 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
const landmarks = hand.landmarks;
if (landmarks.length === 0) return null;
const color = hand.isFist ? "#facc15" : "#38bdf8";
const landmarkStroke = hand.isFist
? LANDMARK_STROKE_FIST
: LANDMARK_STROKE;
return (
<g key={`${hand.handedness}-${handIndex}`}>
@@ -66,8 +74,8 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
y1={`${fromPoint.y * 100}%`}
x2={`${(1 - toPoint.x) * 100}%`}
y2={`${toPoint.y * 100}%`}
stroke={color}
strokeWidth="2"
stroke={CONNECTION_STROKE}
strokeWidth="2.5"
strokeLinecap="round"
/>
);
@@ -78,8 +86,10 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
key={landmarkIndex}
cx={`${(1 - landmark.x) * 100}%`}
cy={`${landmark.y * 100}%`}
r={landmarkIndex === 8 ? 5 : 3}
fill={landmarkIndex === 8 ? "#ffffff" : color}
r={landmarkIndex === INDEX_TIP_LANDMARK ? 6 : 4}
fill={LANDMARK_FILL}
stroke={landmarkStroke}
strokeWidth={hand.isFist ? 2.5 : 2}
/>
))}
</g>