Skip to content

Commit f33c613

Browse files
authored
Fix: Skillcheck animation speed inconsistencies by switching to time-… (#66)
* 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 <brandonrhue@gmail.com> * Refactor keyHandler and clean up code Signed-off-by: Senlar <brandonrhue@gmail.com> * Refactor keyHandler and cleanup useEffect logic again Signed-off-by: Senlar <brandonrhue@gmail.com> --------- Signed-off-by: Senlar <brandonrhue@gmail.com>
1 parent e66b680 commit f33c613

File tree

1 file changed

+74
-21
lines changed

1 file changed

+74
-21
lines changed

web/src/features/skillcheck/indicator.tsx

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
22
import type { SkillCheckProps } from '../../typings';
3-
import { useInterval } from '@mantine/hooks';
43

54
interface Props {
65
angle: number;
@@ -11,15 +10,58 @@ interface Props {
1110
handleComplete: (success: boolean) => void;
1211
}
1312

14-
const Indicator: React.FC<Props> = ({ angle, offset, multiplier, handleComplete, skillCheck, className }) => {
13+
const BASE_DURATION_MS = 2000;
14+
15+
const Indicator: React.FC<Props> = ({
16+
angle,
17+
offset,
18+
multiplier,
19+
handleComplete,
20+
skillCheck,
21+
className,
22+
}) => {
1523
const [indicatorAngle, setIndicatorAngle] = useState(-90);
1624
const [keyPressed, setKeyPressed] = useState<false | string>(false);
17-
const interval = useInterval(
18-
() =>
19-
setIndicatorAngle((prevState) => {
20-
return (prevState += multiplier);
21-
}),
22-
1
25+
26+
const rafIdRef = useRef<number | null>(null);
27+
const startTimeRef = useRef<number | null>(null);
28+
const completedRef = useRef(false);
29+
30+
const stopAnimation = () => {
31+
if (rafIdRef.current !== null) {
32+
cancelAnimationFrame(rafIdRef.current);
33+
rafIdRef.current = null;
34+
}
35+
};
36+
37+
const animate = useCallback(
38+
(time: number) => {
39+
if (completedRef.current) return;
40+
41+
if (startTimeRef.current === null) {
42+
startTimeRef.current = time;
43+
}
44+
45+
const elapsed = time - startTimeRef.current;
46+
47+
const speed = Math.max(multiplier || 0, 0.0001);
48+
const duration = BASE_DURATION_MS / speed;
49+
50+
const progress = Math.min(elapsed / duration, 1);
51+
const newAngle = -90 + progress * 360;
52+
53+
setIndicatorAngle(newAngle);
54+
55+
if (newAngle + 90 >= 360) {
56+
completedRef.current = true;
57+
stopAnimation();
58+
handleComplete(false);
59+
return;
60+
}
61+
62+
rafIdRef.current = requestAnimationFrame(animate);
63+
},
64+
[multiplier, handleComplete]
2365
);
2466
const keyHandler = useCallback(
2567
(e: KeyboardEvent) => {
@@ -42,32 +84,43 @@ const Indicator: React.FC<Props> = ({ angle, offset, multiplier, handleComplete,
4284

4385
useEffect(() => {
4486
setIndicatorAngle(-90);
87+
startTimeRef.current = null;
88+
completedRef.current = false;
89+
4590
window.addEventListener('keydown', keyHandler);
46-
interval.start();
47-
}, [skillCheck]);
91+
rafIdRef.current = requestAnimationFrame(animate);
4892

49-
useEffect(() => {
50-
if (indicatorAngle + 90 >= 360) {
51-
interval.stop();
52-
handleComplete(false);
53-
}
54-
}, [indicatorAngle]);
93+
return () => {
94+
stopAnimation();
95+
window.removeEventListener('keydown', keyHandler);
96+
startTimeRef.current = null;
97+
completedRef.current = true;
98+
};
99+
}, [skillCheck, keyHandler, animate]);
55100

56101
useEffect(() => {
57-
if (!keyPressed) return;
102+
if (!keyPressed || completedRef.current) return;
58103

59104
if (skillCheck.keys && !skillCheck.keys?.includes(keyPressed)) return;
60105

61-
interval.stop();
62-
106+
stopAnimation();
63107
window.removeEventListener('keydown', keyHandler);
108+
completedRef.current = true;
64109

65110
if (keyPressed !== skillCheck.key || indicatorAngle < angle || indicatorAngle > angle + offset)
66111
handleComplete(false);
67112
else handleComplete(true);
68113

69114
setKeyPressed(false);
70-
}, [keyPressed]);
115+
}, [
116+
keyPressed,
117+
angle,
118+
offset,
119+
indicatorAngle,
120+
skillCheck,
121+
keyHandler,
122+
handleComplete,
123+
]);
71124

72125
return <circle transform={`rotate(${indicatorAngle}, 250, 250)`} className={className} />;
73126
};

0 commit comments

Comments
 (0)