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
🔍 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:
+170
-28
@@ -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> */}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user