Merge branch 'develop' into feat/e-bike
🔍 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:36:04 +02:00
83 changed files with 866 additions and 680 deletions
@@ -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>