From f33c613b6d4715b294ed4c551c9102328bc150cc Mon Sep 17 00:00:00 2001 From: Senlar Date: Wed, 11 Feb 2026 09:22:54 -0800 Subject: [PATCH] =?UTF-8?q?Fix:=20Skillcheck=20animation=20speed=20inconsi?= =?UTF-8?q?stencies=20by=20switching=20to=20time-=E2=80=A6=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: Skillcheck animation speed inconsistencies by switching to time-based RAF loop This PR fixes long-standing issues with the skillcheck mini-game animation speed being inconsistent across machines and after long play sessions. Some players reported extremely slow indicator movement, others extremely fast, and many noticed that the speed changed unpredictably the longer they stayed logged in. The root cause was that the skillcheck relied on a useInterval tick (setInterval-like behavior) and assumed it fired every 1ms. In reality, browser timer clamping and throttling make interval timing highly unpredictable — especially in embedded CEF browsers like FiveM’s NUI. This PR replaces tick-based animation with a time-based requestAnimationFrame loop using performance.now(), ensuring perfectly consistent animation timing across all hardware and browser states. I have used this method in other NUI based skillcheck scripts to address this same behavior. Signed-off-by: Senlar * Refactor keyHandler and clean up code Signed-off-by: Senlar * Refactor keyHandler and cleanup useEffect logic again Signed-off-by: Senlar --------- Signed-off-by: Senlar --- web/src/features/skillcheck/indicator.tsx | 97 ++++++++++++++++++----- 1 file changed, 75 insertions(+), 22 deletions(-) diff --git a/web/src/features/skillcheck/indicator.tsx b/web/src/features/skillcheck/indicator.tsx index f961faa..86381f0 100644 --- a/web/src/features/skillcheck/indicator.tsx +++ b/web/src/features/skillcheck/indicator.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { SkillCheckProps } from '../../typings'; -import { useInterval } from '@mantine/hooks'; interface Props { angle: number; @@ -11,15 +10,58 @@ interface Props { handleComplete: (success: boolean) => void; } -const Indicator: React.FC = ({ angle, offset, multiplier, handleComplete, skillCheck, className }) => { +const BASE_DURATION_MS = 2000; + +const Indicator: React.FC = ({ + angle, + offset, + multiplier, + handleComplete, + skillCheck, + className, +}) => { const [indicatorAngle, setIndicatorAngle] = useState(-90); const [keyPressed, setKeyPressed] = useState(false); - const interval = useInterval( - () => - setIndicatorAngle((prevState) => { - return (prevState += multiplier); - }), - 1 + + const rafIdRef = useRef(null); + const startTimeRef = useRef(null); + const completedRef = useRef(false); + + const stopAnimation = () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + }; + + const animate = useCallback( + (time: number) => { + if (completedRef.current) return; + + if (startTimeRef.current === null) { + startTimeRef.current = time; + } + + const elapsed = time - startTimeRef.current; + + const speed = Math.max(multiplier || 0, 0.0001); + const duration = BASE_DURATION_MS / speed; + + const progress = Math.min(elapsed / duration, 1); + const newAngle = -90 + progress * 360; + + setIndicatorAngle(newAngle); + + if (newAngle + 90 >= 360) { + completedRef.current = true; + stopAnimation(); + handleComplete(false); + return; + } + + rafIdRef.current = requestAnimationFrame(animate); + }, + [multiplier, handleComplete] ); const keyHandler = useCallback( (e: KeyboardEvent) => { @@ -42,32 +84,43 @@ const Indicator: React.FC = ({ angle, offset, multiplier, handleComplete, useEffect(() => { setIndicatorAngle(-90); + startTimeRef.current = null; + completedRef.current = false; + window.addEventListener('keydown', keyHandler); - interval.start(); - }, [skillCheck]); + rafIdRef.current = requestAnimationFrame(animate); + + return () => { + stopAnimation(); + window.removeEventListener('keydown', keyHandler); + startTimeRef.current = null; + completedRef.current = true; + }; + }, [skillCheck, keyHandler, animate]); useEffect(() => { - if (indicatorAngle + 90 >= 360) { - interval.stop(); - handleComplete(false); - } - }, [indicatorAngle]); - - useEffect(() => { - if (!keyPressed) return; + if (!keyPressed || completedRef.current) return; if (skillCheck.keys && !skillCheck.keys?.includes(keyPressed)) return; - interval.stop(); - + stopAnimation(); window.removeEventListener('keydown', keyHandler); + completedRef.current = true; if (keyPressed !== skillCheck.key || indicatorAngle < angle || indicatorAngle > angle + offset) handleComplete(false); else handleComplete(true); setKeyPressed(false); - }, [keyPressed]); + }, [ + keyPressed, + angle, + offset, + indicatorAngle, + skillCheck, + keyHandler, + handleComplete, + ]); return ; };