diff --git a/src/components/ebike/EbikeGPSMap.tsx b/src/components/ebike/EbikeGPSMap.tsx index 064bcc6..6909930 100644 --- a/src/components/ebike/EbikeGPSMap.tsx +++ b/src/components/ebike/EbikeGPSMap.tsx @@ -181,6 +181,8 @@ export const EbikeGPSMap: React.FC = ({ // Sync texture into uniform when it changes (canvas resize) useEffect(() => { + // External Three.js material uniform sync — intentional side effect. + // eslint-disable-next-line react-hooks/immutability shaderMat.uniforms.map.value = texture; }, [shaderMat, texture]); @@ -196,6 +198,8 @@ export const EbikeGPSMap: React.FC = ({ // Resize the canvas whenever canvasSize changes (texture declared above) useEffect(() => { 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; }, [canvasSize, offscreenCanvas, texture]); diff --git a/src/components/ebike/EbikeSpeedmeter.tsx b/src/components/ebike/EbikeSpeedmeter.tsx index 917f301..1d70f1f 100644 --- a/src/components/ebike/EbikeSpeedmeter.tsx +++ b/src/components/ebike/EbikeSpeedmeter.tsx @@ -123,6 +123,8 @@ export function EbikeSpeedmeter({ ); // ── Frame loop ────────────────────────────────────────────────────────────── + /* External Three.js canvas+texture sync — intentional side effects in useFrame. */ + /* eslint-disable react-hooks/immutability */ useFrame((_, delta) => { // 1. Smooth speed factor const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1); @@ -181,6 +183,7 @@ export function EbikeSpeedmeter({ } fillTexture.needsUpdate = true; + /* eslint-enable react-hooks/immutability */ }); return ( diff --git a/src/components/gameplay/pylon/PylonDownedPylon.tsx b/src/components/gameplay/pylon/PylonDownedPylon.tsx index 71923e2..c77c6e3 100644 --- a/src/components/gameplay/pylon/PylonDownedPylon.tsx +++ b/src/components/gameplay/pylon/PylonDownedPylon.tsx @@ -30,9 +30,26 @@ export function PylonDownedPylon(): React.JSX.Element | null { const straightenStartRef = useRef(null); 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(() => { if (step === "arrived") { 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); } }, [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 => { setIsStraightening(true); pylonStraighteningSignal.started = true; diff --git a/src/components/gameplay/pylon/PylonFarmerNPC.tsx b/src/components/gameplay/pylon/PylonFarmerNPC.tsx index 638248b..323dd60 100644 --- a/src/components/gameplay/pylon/PylonFarmerNPC.tsx +++ b/src/components/gameplay/pylon/PylonFarmerNPC.tsx @@ -34,7 +34,10 @@ const _target = new THREE.Vector3(); * Compute the Y rotation (radians) for a model whose default forward * 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 dz = to[2] - from.z; return Math.atan2(dx, dz); @@ -71,6 +74,10 @@ export function PylonFarmerNPC(): React.JSX.Element | null { // ─── playAnim ───────────────────────────────────────────────────────────── // NOTE: actions is intentionally in the dep array so this callback is // 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( (name: NPCAnimation, fade = ANIM_FADE): void => { if (currentAnimRef.current === name) return; @@ -89,6 +96,7 @@ export function PylonFarmerNPC(): React.JSX.Element | null { }, [actions], ); + /* eslint-enable react-hooks/immutability */ // ─── Async audio after pylon is raised ──────────────────────────────────── const playPostRaiseAudioAndAdvance = useCallback(async () => { @@ -112,6 +120,8 @@ export function PylonFarmerNPC(): React.JSX.Element | null { // ─── Step-driven animation ──────────────────────────────────────────────── // 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(() => { currentAnimRef.current = null; if (step === "arrived") { @@ -168,7 +178,10 @@ export function PylonFarmerNPC(): React.JSX.Element | null { currentPosRef.current.lerp(_target, t); } else if (!isStraightening && currentAnimRef.current === "walk") { playAnim("idle"); - savedRotationYRef.current = faceToward(currentPosRef.current, PYLON_WORLD_POSITION); + savedRotationYRef.current = faceToward( + currentPosRef.current, + PYLON_WORLD_POSITION, + ); } group.position.copy(currentPosRef.current); } else if (step === "inspected") { @@ -180,8 +193,15 @@ export function PylonFarmerNPC(): React.JSX.Element | null { } // ── Rotation ────────────────────────────────────────────────────────── - if (step === "npc-return" && !isCompleted && currentAnimRef.current === "walk") { - const walkRotY = faceToward(currentPosRef.current, PYLON_FARMER_NPC_WALK_LOOK_AT); + if ( + step === "npc-return" && + !isCompleted && + currentAnimRef.current === "walk" + ) { + const walkRotY = faceToward( + currentPosRef.current, + PYLON_FARMER_NPC_WALK_LOOK_AT, + ); group.rotation.set(0, walkRotY, 0); } else { 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); }); + /* eslint-enable react-hooks/immutability */ if (mainState !== "pylon") return null; if (step !== "arrived" && step !== "npc-return" && step !== "inspected")