4 Commits

Author SHA1 Message Date
Tom Boullay ff4ead1d24 fix(lint): satisfy react-hooks immutability + set-state-in-effect rules
🔍 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
The new react-compiler-aware lint rules flag legitimate Three.js
external-system synchronizations (texture/uniform/AnimationAction
mutations) and a derived-state reset in PylonDownedPylon. None of
these are bugs — they're the canonical way to bridge React state
with imperative graphics objects — so they're annotated with
targeted eslint-disable comments and a small reorder.

- EbikeGPSMap: disable on uniform/texture sync effects
- EbikeSpeedmeter: disable around the canvas+texture useFrame sync
- PylonFarmerNPC: disable around playAnim (drei AnimationAction
  fadeIn/fadeOut/setLoop/clampWhenFinished) and the effects/frame
  callbacks that invoke it
- PylonDownedPylon: move showUpright/isPylonInteractive declarations
  above the useFrame that reads them (fixes access-before-declared)
  and disable set-state-in-effect on the per-step isRaised reset
2026-06-03 00:04:14 +02:00
Tom Boullay 974f340d33 style: prettier reflow pylon config and lighting effect
Mechanical formatting cleanup carried over from the develop merge:
inline single-line tuples and break long lines per project prettier
config. No behavior change.
2026-06-03 00:03:59 +02:00
Tom Boullay c6283d492c refactor(debug): rename hand-tracking SVG toggle to Model
The debug control now reflects what it actually gates: the 3D hand
model rendering (used by World.tsx to decide whether to show the
hand-tracking gloves), not the legacy SVG visualizer.

- Debug.ts: rename showHandTrackingSvg → showHandTrackingModel
  (state, GUI label "Show Model", getter/setter)
- World.tsx: gate showHandTrackingGloves on the new toggle and
  drop the unused HandTrackingGloveHandedness import
2026-06-03 00:03:44 +02:00
Tom Boullay 83194df14f fix(ebike): allow player input during mount/dismount camera transition
Add lockInput option (default true) to animateCameraTransformTransition
so ebike mount/dismount can keep player input active during the 1s
camera tween instead of locking via setCinematicPlaying.

Also drop the unused camPointPos/dropPointPos debug vars and the
matching debugRestingPosition state — the consuming JSX has been
commented out for a while.
2026-06-03 00:03:29 +02:00
11 changed files with 102 additions and 70 deletions
+17 -24
View File
@@ -129,12 +129,6 @@ export function Ebike({
// State for debug visualization (synced from refs during useFrame) // State for debug visualization (synced from refs during useFrame)
const [showCameraPoints, setShowCameraPoints] = useState(true); const [showCameraPoints, setShowCameraPoints] = useState(true);
const [debugRestingPosition, setDebugRestingPosition] =
useState<Vector3Tuple>([
parkedPosition[0],
parkedPosition[1],
parkedPosition[2],
]);
// Keep movementModeRef in sync — useFrame closures capture React state at // Keep movementModeRef in sync — useFrame closures capture React state at
// render time and can become stale between renders. // render time and can become stale between renders.
@@ -321,9 +315,7 @@ export function Ebike({
} }
// Sync debug visualization state (throttled to avoid excessive re-renders) // Sync debug visualization state (throttled to avoid excessive re-renders)
if (showCameraPoints) { // Debug visualization positions are derived elsewhere when needed.
setDebugRestingPosition([...restingPositionRef.current]);
}
} else { } else {
updateEbikeSounds({ mounted: false, driving: false, breakdown: false }); updateEbikeSounds({ mounted: false, driving: false, breakdown: false });
groupRef.current.position.set(...restingPositionRef.current); groupRef.current.position.set(...restingPositionRef.current);
@@ -340,17 +332,6 @@ export function Ebike({
} }
}); });
// Debug visualization positions computed from state (not refs)
const camPointPos: Vector3Tuple = [
debugRestingPosition[0] + EBIKE_CAMERA_TRANSFORM.position[0],
debugRestingPosition[1] + EBIKE_CAMERA_TRANSFORM.position[1],
debugRestingPosition[2] + EBIKE_CAMERA_TRANSFORM.position[2],
];
const dropPointPos: Vector3Tuple = [
debugRestingPosition[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
];
const interactionLabel = const interactionLabel =
mainState === "ebike" mainState === "ebike"
? "Lancer le repair game" ? "Lancer le repair game"
@@ -409,9 +390,15 @@ export function Ebike({
EBIKE_CAMERA_TRANSFORM.rotation[2], EBIKE_CAMERA_TRANSFORM.rotation[2],
]; ];
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => { animateCameraTransformTransition(
targetCamPos,
targetRotation,
1,
() => {
useGameStore.getState().setPlayerMovementMode("ebike"); useGameStore.getState().setPlayerMovementMode("ebike");
}); },
{ lockInput: false },
);
} else { } else {
const currentPos = new THREE.Vector3(); const currentPos = new THREE.Vector3();
if (groupRef.current) { if (groupRef.current) {
@@ -437,9 +424,15 @@ export function Ebike({
THREE.MathUtils.radToDeg(currentEuler.z), THREE.MathUtils.radToDeg(currentEuler.z),
]; ];
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => { animateCameraTransformTransition(
targetCamPos,
targetRotation,
1,
() => {
useGameStore.getState().setPlayerMovementMode("walk"); useGameStore.getState().setPlayerMovementMode("walk");
}); },
{ lockInput: false },
);
} }
}, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]); }, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]);
+4
View File
@@ -181,6 +181,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Sync texture into uniform when it changes (canvas resize) // Sync texture into uniform when it changes (canvas resize)
useEffect(() => { useEffect(() => {
// External Three.js material uniform sync — intentional side effect.
// eslint-disable-next-line react-hooks/immutability
shaderMat.uniforms.map.value = texture; shaderMat.uniforms.map.value = texture;
}, [shaderMat, texture]); }, [shaderMat, texture]);
@@ -196,6 +198,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Resize the canvas whenever canvasSize changes (texture declared above) // Resize the canvas whenever canvasSize changes (texture declared above)
useEffect(() => { useEffect(() => {
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize }); Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
// External Three.js texture invalidation — intentional side effect.
// eslint-disable-next-line react-hooks/immutability
texture.needsUpdate = true; texture.needsUpdate = true;
}, [canvasSize, offscreenCanvas, texture]); }, [canvasSize, offscreenCanvas, texture]);
+3
View File
@@ -123,6 +123,8 @@ export function EbikeSpeedmeter({
); );
// ── Frame loop ────────────────────────────────────────────────────────────── // ── Frame loop ──────────────────────────────────────────────────────────────
/* External Three.js canvas+texture sync — intentional side effects in useFrame. */
/* eslint-disable react-hooks/immutability */
useFrame((_, delta) => { useFrame((_, delta) => {
// 1. Smooth speed factor // 1. Smooth speed factor
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1); const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
@@ -181,6 +183,7 @@ export function EbikeSpeedmeter({
} }
fillTexture.needsUpdate = true; fillTexture.needsUpdate = true;
/* eslint-enable react-hooks/immutability */
}); });
return ( return (
@@ -30,9 +30,26 @@ export function PylonDownedPylon(): React.JSX.Element | null {
const straightenStartRef = useRef<number | null>(null); const straightenStartRef = useRef<number | null>(null);
const hasPlayedFirstAudioRef = useRef(false); const hasPlayedFirstAudioRef = useRef(false);
const showUpright =
isRaised ||
mainState !== "pylon" ||
step === "waiting" ||
step === "inspected" ||
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling" ||
step === "done" ||
step === "narrator-outro";
const isPylonInteractive = step === "arrived" || step === "npc-return";
useEffect(() => { useEffect(() => {
if (step === "arrived") { if (step === "arrived") {
hasPlayedFirstAudioRef.current = false; hasPlayedFirstAudioRef.current = false;
// Reset the "raised" latch when a new run begins. This is derived
// resync from the step prop and runs once per step transition.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsRaised(false); setIsRaised(false);
} }
}, [step]); }, [step]);
@@ -62,20 +79,6 @@ export function PylonDownedPylon(): React.JSX.Element | null {
); );
}); });
const showUpright =
isRaised ||
mainState !== "pylon" ||
step === "waiting" ||
step === "inspected" ||
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling" ||
step === "done" ||
step === "narrator-outro";
const isPylonInteractive = step === "arrived" || step === "npc-return";
const beginStraighten = (): void => { const beginStraighten = (): void => {
setIsStraightening(true); setIsStraightening(true);
pylonStraighteningSignal.started = true; pylonStraighteningSignal.started = true;
@@ -34,7 +34,10 @@ const _target = new THREE.Vector3();
* Compute the Y rotation (radians) for a model whose default forward * Compute the Y rotation (radians) for a model whose default forward
* direction is +Z, so that it faces from `from` toward `to`. * direction is +Z, so that it faces from `from` toward `to`.
*/ */
function faceToward(from: THREE.Vector3, to: readonly [number, number, number]): number { function faceToward(
from: THREE.Vector3,
to: readonly [number, number, number],
): number {
const dx = to[0] - from.x; const dx = to[0] - from.x;
const dz = to[2] - from.z; const dz = to[2] - from.z;
return Math.atan2(dx, dz); return Math.atan2(dx, dz);
@@ -71,6 +74,10 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
// ─── playAnim ───────────────────────────────────────────────────────────── // ─── playAnim ─────────────────────────────────────────────────────────────
// NOTE: actions is intentionally in the dep array so this callback is // NOTE: actions is intentionally in the dep array so this callback is
// recreated when drei's internal state populates the actions map. // recreated when drei's internal state populates the actions map.
// External THREE.AnimationAction lifecycle methods (fadeOut/fadeIn/play +
// setLoop/clampWhenFinished mutations) are intentional side effects on
// drei-managed objects.
/* eslint-disable react-hooks/immutability */
const playAnim = useCallback( const playAnim = useCallback(
(name: NPCAnimation, fade = ANIM_FADE): void => { (name: NPCAnimation, fade = ANIM_FADE): void => {
if (currentAnimRef.current === name) return; if (currentAnimRef.current === name) return;
@@ -89,6 +96,7 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
}, },
[actions], [actions],
); );
/* eslint-enable react-hooks/immutability */
// ─── Async audio after pylon is raised ──────────────────────────────────── // ─── Async audio after pylon is raised ────────────────────────────────────
const playPostRaiseAudioAndAdvance = useCallback(async () => { const playPostRaiseAudioAndAdvance = useCallback(async () => {
@@ -112,6 +120,8 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
// ─── Step-driven animation ──────────────────────────────────────────────── // ─── Step-driven animation ────────────────────────────────────────────────
// Fires when step changes OR when playAnim changes (i.e. when actions load). // Fires when step changes OR when playAnim changes (i.e. when actions load).
// playAnim mutates drei-managed AnimationAction internals (intentional).
/* eslint-disable react-hooks/immutability */
useEffect(() => { useEffect(() => {
currentAnimRef.current = null; currentAnimRef.current = null;
if (step === "arrived") { if (step === "arrived") {
@@ -168,7 +178,10 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
currentPosRef.current.lerp(_target, t); currentPosRef.current.lerp(_target, t);
} else if (!isStraightening && currentAnimRef.current === "walk") { } else if (!isStraightening && currentAnimRef.current === "walk") {
playAnim("idle"); playAnim("idle");
savedRotationYRef.current = faceToward(currentPosRef.current, PYLON_WORLD_POSITION); savedRotationYRef.current = faceToward(
currentPosRef.current,
PYLON_WORLD_POSITION,
);
} }
group.position.copy(currentPosRef.current); group.position.copy(currentPosRef.current);
} else if (step === "inspected") { } else if (step === "inspected") {
@@ -180,8 +193,15 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
} }
// ── Rotation ────────────────────────────────────────────────────────── // ── Rotation ──────────────────────────────────────────────────────────
if (step === "npc-return" && !isCompleted && currentAnimRef.current === "walk") { if (
const walkRotY = faceToward(currentPosRef.current, PYLON_FARMER_NPC_WALK_LOOK_AT); step === "npc-return" &&
!isCompleted &&
currentAnimRef.current === "walk"
) {
const walkRotY = faceToward(
currentPosRef.current,
PYLON_FARMER_NPC_WALK_LOOK_AT,
);
group.rotation.set(0, walkRotY, 0); group.rotation.set(0, walkRotY, 0);
} else { } else {
group.rotation.set(0, savedRotationYRef.current, 0); group.rotation.set(0, savedRotationYRef.current, 0);
@@ -189,6 +209,7 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE); group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
}); });
/* eslint-enable react-hooks/immutability */
if (mainState !== "pylon") return null; if (mainState !== "pylon") return null;
if (step !== "arrived" && step !== "npc-return" && step !== "inspected") if (step !== "arrived" && step !== "npc-return" && step !== "inspected")
@@ -20,14 +20,17 @@ export function PylonLightingEffect(): null {
const step = useGameStore((state) => state.pylon.currentStep); const step = useGameStore((state) => state.pylon.currentStep);
// True from "approaching" until narrator-outro (lighting resets before the outro audio) // True from "approaching" until narrator-outro (lighting resets before the outro audio)
const isActive = mainState === "pylon" && step !== "locked" && step !== "narrator-outro"; const isActive =
mainState === "pylon" && step !== "locked" && step !== "narrator-outro";
// Working THREE.Color instances — lerped every frame // Working THREE.Color instances — lerped every frame
const ambientRef = useRef(new THREE.Color(LIGHTING_STATE.ambientColor)); const ambientRef = useRef(new THREE.Color(LIGHTING_STATE.ambientColor));
const sunRef = useRef(new THREE.Color(LIGHTING_STATE.sunColor)); const sunRef = useRef(new THREE.Color(LIGHTING_STATE.sunColor));
// Target colours — updated reactively when isActive changes // Target colours — updated reactively when isActive changes
const targetAmbientRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.ambientColor)); const targetAmbientRef = useRef(
new THREE.Color(LIGHTING_DEFAULTS.ambientColor),
);
const targetSunRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.sunColor)); const targetSunRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.sunColor));
useEffect(() => { useEffect(() => {
+1 -5
View File
@@ -6,11 +6,7 @@ export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -0.9];
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0]; export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [ export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [-16.13, 3.2, 52.46];
-16.13,
3.2,
52.46
];
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [ export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
PYLON_WORLD_POSITION[0] + 3, PYLON_WORLD_POSITION[0] + 3,
+1 -5
View File
@@ -4,11 +4,7 @@ import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
// Zones qui active la coupure de courant // Zones qui active la coupure de courant
export const PYLON_APPROACH_ZONE: ZoneConfig = { export const PYLON_APPROACH_ZONE: ZoneConfig = {
id: "pylon-approach", id: "pylon-approach",
position: [ position: [5, 4, -21.5],
5,
4,
-21.5
],
radius: 10, radius: 10,
height: 18, height: 18,
oneShot: true, oneShot: true,
+9 -9
View File
@@ -85,7 +85,7 @@ export class Debug {
fogEnabled: boolean; fogEnabled: boolean;
handTrackingSource: HandTrackingSource; handTrackingSource: HandTrackingSource;
showDebugOverlay: boolean; showDebugOverlay: boolean;
showHandTrackingSvg: boolean; showHandTrackingModel: boolean;
showInteractionSpheres: boolean; showInteractionSpheres: boolean;
showPerf: boolean; showPerf: boolean;
sceneMode: SceneMode; sceneMode: SceneMode;
@@ -108,7 +108,7 @@ export class Debug {
fogEnabled: FOG_CONFIG.enabled, fogEnabled: FOG_CONFIG.enabled,
handTrackingSource: storedControls.handTrackingSource ?? "browser", handTrackingSource: storedControls.handTrackingSource ?? "browser",
showDebugOverlay: true, showDebugOverlay: true,
showHandTrackingSvg: false, showHandTrackingModel: false,
showInteractionSpheres: false, showInteractionSpheres: false,
showPerf: true, showPerf: true,
sceneMode: storedControls.sceneMode ?? "game", sceneMode: storedControls.sceneMode ?? "game",
@@ -156,10 +156,10 @@ export class Debug {
const handTrackingFolder = this.createFolder("Hand Tracking"); const handTrackingFolder = this.createFolder("Hand Tracking");
handTrackingFolder handTrackingFolder
?.add(this.controls, "showHandTrackingSvg") ?.add(this.controls, "showHandTrackingModel")
.name("Show SVG") .name("Show Model")
.onChange((value: boolean) => { .onChange((value: boolean) => {
this.controls.showHandTrackingSvg = value; this.controls.showHandTrackingModel = value;
this.emit(); this.emit();
}); });
@@ -281,12 +281,12 @@ export class Debug {
return this.controls.showInteractionSpheres; return this.controls.showInteractionSpheres;
} }
getShowHandTrackingSvg(): boolean { getShowHandTrackingModel(): boolean {
return this.controls.showHandTrackingSvg; return this.controls.showHandTrackingModel;
} }
setShowHandTrackingSvg(value: boolean): void { setShowHandTrackingModel(value: boolean): void {
this.controls.showHandTrackingSvg = value; this.controls.showHandTrackingModel = value;
this.emit(); this.emit();
} }
+7
View File
@@ -242,7 +242,10 @@ export function animateCameraTransformTransition(
targetRotation: Vector3Tuple, targetRotation: Vector3Tuple,
duration: number = 1, duration: number = 1,
onComplete?: () => void, onComplete?: () => void,
options: { lockInput?: boolean } = {},
): void { ): void {
const { lockInput = true } = options;
if (!globalCamera) { if (!globalCamera) {
logger.warn("GameCinematics", "Camera not found for transition"); logger.warn("GameCinematics", "Camera not found for transition");
onComplete?.(); onComplete?.();
@@ -252,7 +255,9 @@ export function animateCameraTransformTransition(
const camera = globalCamera; const camera = globalCamera;
cameraTransitionTimeline?.kill(); cameraTransitionTimeline?.kill();
if (lockInput) {
useGameStore.getState().setCinematicPlaying(true); useGameStore.getState().setCinematicPlaying(true);
}
// Convert target rotation in degrees to quaternion // Convert target rotation in degrees to quaternion
const targetEuler = new THREE.Euler( const targetEuler = new THREE.Euler(
@@ -274,7 +279,9 @@ export function animateCameraTransformTransition(
}, },
onComplete: () => { onComplete: () => {
cameraTransitionTimeline = null; cameraTransitionTimeline = null;
if (lockInput) {
useGameStore.getState().setCinematicPlaying(false); useGameStore.getState().setCinematicPlaying(false);
}
onComplete?.(); onComplete?.();
}, },
}); });
+9 -3
View File
@@ -6,6 +6,7 @@ import {
} from "@/data/player/playerConfig"; } from "@/data/player/playerConfig";
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig"; import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug"; import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug"; import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug"; import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
@@ -32,7 +33,6 @@ import { CharacterSystem } from "@/world/characters/CharacterSystem";
import { Player } from "@/world/player/Player"; import { Player } from "@/world/player/Player";
import { TestMap } from "@/world/debug/TestMap"; import { TestMap } from "@/world/debug/TestMap";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import type { HandTrackingGloveHandedness } from "@/hooks/handTracking/useHandTrackingGloveStatus";
import type { HandTrackingHand } from "@/types/handTracking/handTracking"; import type { HandTrackingHand } from "@/types/handTracking/handTracking";
interface WorldProps { interface WorldProps {
@@ -41,7 +41,7 @@ interface WorldProps {
function hasTrackedHand( function hasTrackedHand(
hands: HandTrackingHand[], hands: HandTrackingHand[],
handedness: HandTrackingGloveHandedness, handedness: "left" | "right",
): boolean { ): boolean {
return hands.some((hand) => hand.handedness.toLowerCase() === handedness); return hands.some((hand) => hand.handedness.toLowerCase() === handedness);
} }
@@ -60,6 +60,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
(state) => state.showPlayerModel, (state) => state.showPlayerModel,
); );
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree); const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
const showHandTrackingModel = useDebugStore((debug) =>
debug.getShowHandTrackingModel(),
);
const { hands, status, usageStatus } = useHandTrackingSnapshot(); const { hands, status, usageStatus } = useHandTrackingSnapshot();
const { const {
octree, octree,
@@ -74,7 +77,10 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
? PLAYER_SPAWN_POSITION_GAME ? PLAYER_SPAWN_POSITION_GAME
: PLAYER_SPAWN_POSITION_PHYSICS; : PLAYER_SPAWN_POSITION_PHYSICS;
const showHandTrackingGloves = const showHandTrackingGloves =
status === "connected" && usageStatus !== "inactive" && hands.length > 0; showHandTrackingModel &&
status === "connected" &&
usageStatus !== "inactive" &&
hands.length > 0;
const showLeftHandTrackingGlove = const showLeftHandTrackingGlove =
showHandTrackingGloves && hasTrackedHand(hands, "left"); showHandTrackingGloves && hasTrackedHand(hands, "left");
const showRightHandTrackingGlove = const showRightHandTrackingGlove =