From 6f3f00523807ac452d57ab24df2fb663db17b9d4 Mon Sep 17 00:00:00 2001 From: Senlar Date: Fri, 28 Nov 2025 17:29:25 -0500 Subject: [PATCH 1/3] Fix: Skillcheck animation speed inconsistencies by switching to time-based RAF loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/features/skillcheck/indicator.tsx | 122 +++++++++++++++++----- 1 file changed, 95 insertions(+), 27 deletions(-) diff --git a/web/src/features/skillcheck/indicator.tsx b/web/src/features/skillcheck/indicator.tsx index f961faafa..5390a0c7b 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,65 +10,134 @@ 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) => { const capitalHetaCode = 880; const isNonLatin = e.key.charCodeAt(0) >= capitalHetaCode; - var convKey = e.key.toLowerCase() + let convKey = e.key.toLowerCase(); + if (isNonLatin) { - if (e.code.indexOf('Key') === 0 && e.code.length === 4) { // i.e. 'KeyW' + if (e.code.indexOf('Key') === 0 && e.code.length === 4) { convKey = e.code.charAt(3); } - if (e.code.indexOf('Digit') === 0 && e.code.length === 6) { // i.e. 'Digit7' + if (e.code.indexOf('Digit') === 0 && e.code.length === 6) { convKey = e.code.charAt(5); } } + setKeyPressed(convKey.toLowerCase()); }, [skillCheck] ); + // Start / reset animation whenever a new skillcheck starts useEffect(() => { setIndicatorAngle(-90); + startTimeRef.current = null; + completedRef.current = false; + window.addEventListener('keydown', keyHandler); - interval.start(); - }, [skillCheck]); + rafIdRef.current = requestAnimationFrame(animate); - useEffect(() => { - if (indicatorAngle + 90 >= 360) { - interval.stop(); - handleComplete(false); - } - }, [indicatorAngle]); + return () => { + stopAnimation(); + window.removeEventListener('keydown', keyHandler); + startTimeRef.current = null; + completedRef.current = true; + }; + }, [skillCheck, keyHandler, animate]); 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) + if ( + keyPressed !== skillCheck.key || + indicatorAngle < angle || + indicatorAngle > angle + offset + ) { handleComplete(false); - else handleComplete(true); + } else { + handleComplete(true); + } setKeyPressed(false); - }, [keyPressed]); + }, [ + keyPressed, + angle, + offset, + indicatorAngle, + skillCheck, + keyHandler, + handleComplete, + ]); - return ; + return ( + + ); }; export default Indicator; From 96005eb280bc32914526424a41559a6a371aa980 Mon Sep 17 00:00:00 2001 From: Senlar Date: Mon, 8 Dec 2025 00:10:10 -0500 Subject: [PATCH 2/3] Refactor keyHandler and clean up code Signed-off-by: Senlar --- web/src/features/skillcheck/indicator.tsx | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/web/src/features/skillcheck/indicator.tsx b/web/src/features/skillcheck/indicator.tsx index 5390a0c7b..9579f1848 100644 --- a/web/src/features/skillcheck/indicator.tsx +++ b/web/src/features/skillcheck/indicator.tsx @@ -68,14 +68,14 @@ const Indicator: React.FC = ({ (e: KeyboardEvent) => { const capitalHetaCode = 880; const isNonLatin = e.key.charCodeAt(0) >= capitalHetaCode; - let convKey = e.key.toLowerCase(); + var convKey = e.key.toLowerCase() if (isNonLatin) { - if (e.code.indexOf('Key') === 0 && e.code.length === 4) { + if (e.code.indexOf('Key') === 0 && e.code.length === 4) { // i.e. 'KeyW' convKey = e.code.charAt(3); } - if (e.code.indexOf('Digit') === 0 && e.code.length === 6) { + if (e.code.indexOf('Digit') === 0 && e.code.length === 6) { // i.e. 'Digit7' convKey = e.code.charAt(5); } } @@ -84,8 +84,7 @@ const Indicator: React.FC = ({ }, [skillCheck] ); - - // Start / reset animation whenever a new skillcheck starts + useEffect(() => { setIndicatorAngle(-90); startTimeRef.current = null; @@ -111,11 +110,7 @@ const Indicator: React.FC = ({ window.removeEventListener('keydown', keyHandler); completedRef.current = true; - if ( - keyPressed !== skillCheck.key || - indicatorAngle < angle || - indicatorAngle > angle + offset - ) { + if (keyPressed !== skillCheck.key || indicatorAngle < angle || indicatorAngle > angle + offset) { handleComplete(false); } else { handleComplete(true); @@ -132,12 +127,7 @@ const Indicator: React.FC = ({ handleComplete, ]); - return ( - - ); + return ; }; export default Indicator; From dc5bfadef39a7ab1bed27153c015c149649a9ece Mon Sep 17 00:00:00 2001 From: Senlar Date: Mon, 8 Dec 2025 00:14:38 -0500 Subject: [PATCH 3/3] Refactor keyHandler and cleanup useEffect logic again Signed-off-by: Senlar --- web/src/features/skillcheck/indicator.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/web/src/features/skillcheck/indicator.tsx b/web/src/features/skillcheck/indicator.tsx index 9579f1848..86381f012 100644 --- a/web/src/features/skillcheck/indicator.tsx +++ b/web/src/features/skillcheck/indicator.tsx @@ -63,13 +63,11 @@ const Indicator: React.FC = ({ }, [multiplier, handleComplete] ); - const keyHandler = useCallback( (e: KeyboardEvent) => { const capitalHetaCode = 880; const isNonLatin = e.key.charCodeAt(0) >= capitalHetaCode; var convKey = e.key.toLowerCase() - if (isNonLatin) { if (e.code.indexOf('Key') === 0 && e.code.length === 4) { // i.e. 'KeyW' convKey = e.code.charAt(3); @@ -79,12 +77,11 @@ const Indicator: React.FC = ({ convKey = e.code.charAt(5); } } - setKeyPressed(convKey.toLowerCase()); }, [skillCheck] ); - + useEffect(() => { setIndicatorAngle(-90); startTimeRef.current = null; @@ -110,11 +107,9 @@ const Indicator: React.FC = ({ window.removeEventListener('keydown', keyHandler); completedRef.current = true; - if (keyPressed !== skillCheck.key || indicatorAngle < angle || indicatorAngle > angle + offset) { + if (keyPressed !== skillCheck.key || indicatorAngle < angle || indicatorAngle > angle + offset) handleComplete(false); - } else { - handleComplete(true); - } + else handleComplete(true); setKeyPressed(false); }, [