Skip to content

Commit 706709d

Browse files
committed
feat(game): add frame-rate independent speed modes to Infinite Runner
- Implement delta time calculation for frame-rate normalization (60 FPS target) - Add 5 speed modes: Snail Mail, Casual Stroll, Coffee Break, Rocket Fuel, Lightspeed - Create compact icon-only speed selector with tooltips - Ensure fair gameplay across all connection speeds - Add speed mode persistence with localStorage - Display current speed mode in game HUD and ready screen
1 parent 8d062fa commit 706709d

File tree

1 file changed

+164
-18
lines changed

1 file changed

+164
-18
lines changed

components/games/InfiniteRunner.tsx

Lines changed: 164 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
5454
const [isMounted, setIsMounted] = useState(false);
5555
const [isFullscreen, setIsFullscreen] = useState(false);
5656
const [showControls, setShowControls] = useState(false); // For collapsible controls
57+
58+
// Speed/Difficulty mode
59+
type SpeedMode = 'snail' | 'casual' | 'coffee' | 'rocket' | 'lightspeed';
60+
const [speedMode, setSpeedMode] = useState<SpeedMode>('coffee');
61+
const [showSpeedSelector, setShowSpeedSelector] = useState(false);
62+
63+
// Load saved speed mode from localStorage on mount
64+
useEffect(() => {
65+
if (typeof window !== 'undefined') {
66+
const savedSpeed = localStorage.getItem('infiniteRunnerSpeedMode') as SpeedMode | null;
67+
if (savedSpeed && SPEED_CONFIGS[savedSpeed]) {
68+
setSpeedMode(savedSpeed);
69+
}
70+
}
71+
}, []);
72+
73+
// Save speed mode to localStorage when it changes
74+
useEffect(() => {
75+
if (typeof window !== 'undefined' && isMounted) {
76+
localStorage.setItem('infiniteRunnerSpeedMode', speedMode);
77+
}
78+
}, [speedMode, isMounted]);
5779

5880
// Audio refs
5981
const menuMusicRef = useRef<HTMLAudioElement | null>(null);
@@ -125,6 +147,7 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
125147
const lastPowerUpRef = useRef(0);
126148
const levelTimerRef = useRef(0);
127149
const frameCountRef = useRef(0);
150+
const lastFrameTimeRef = useRef<number>(0); // For delta time calculation
128151

129152
// Constants
130153
const GRAVITY = 0.6;
@@ -136,6 +159,36 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
136159
const PLAYER_HEIGHT = 50;
137160
const DUCKING_HEIGHT = 20; // Make ducking more effective (lower to ground)
138161
const MAX_JUMPS = 2; // Allow double jump
162+
const TARGET_FPS = 60; // Target frame rate for consistent gameplay
163+
164+
// Speed mode configuration - normalized for 60 FPS
165+
const SPEED_CONFIGS = {
166+
snail: {
167+
baseSpeed: 3,
168+
label: '🐌 Snail Mail',
169+
description: 'Perfect for beginners or a chill vibe'
170+
},
171+
casual: {
172+
baseSpeed: 4.5,
173+
label: '🚶 Casual Stroll',
174+
description: 'Nice and easy, no rush'
175+
},
176+
coffee: {
177+
baseSpeed: 6,
178+
label: '☕ Coffee Break',
179+
description: 'Just right - recommended!'
180+
},
181+
rocket: {
182+
baseSpeed: 8,
183+
label: '🚀 Rocket Fuel',
184+
description: 'Fast-paced action for pros'
185+
},
186+
lightspeed: {
187+
baseSpeed: 10,
188+
label: '⚡ Lightspeed',
189+
description: 'Insane mode - are you ready?'
190+
}
191+
};
139192

140193
// Web3 Quiz Questions
141194
const quizQuestions = [
@@ -841,6 +894,9 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
841894
return;
842895
}
843896

897+
// Get the base speed from selected mode
898+
const modeSpeed = SPEED_CONFIGS[speedMode].baseSpeed;
899+
844900
setGameState('playing');
845901
setScore(0);
846902
setLevel(1);
@@ -852,8 +908,8 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
852908
setIsJumping(false);
853909
setIsDucking(false);
854910
setJumpCount(0);
855-
setGameSpeed(5);
856-
setBaseSpeed(5);
911+
setGameSpeed(modeSpeed);
912+
setBaseSpeed(modeSpeed);
857913
setObstacles([]);
858914
setCoins([]);
859915
setPowerUps([]);
@@ -871,6 +927,7 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
871927
lastPowerUpRef.current = 0;
872928
levelTimerRef.current = 0;
873929
frameCountRef.current = 0;
930+
lastFrameTimeRef.current = 0;
874931

875932
// Give player 5 seconds of invulnerability at start
876933
setInvulnerableUntil(Date.now() + 5000);
@@ -882,7 +939,7 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
882939
}
883940

884941
// Don't auto-enter fullscreen - let user choose with button
885-
}, [account, addToast]);
942+
}, [account, addToast, speedMode]);
886943

887944
// Handle keyboard input
888945
useEffect(() => {
@@ -981,11 +1038,21 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
9811038
return;
9821039
}
9831040

984-
const gameLoop = () => {
985-
// Update player physics
986-
setPlayerVelocity(v => v + GRAVITY);
1041+
const gameLoop = (currentTime: number) => {
1042+
// Calculate delta time for frame-rate independence
1043+
if (lastFrameTimeRef.current === 0) {
1044+
lastFrameTimeRef.current = currentTime;
1045+
}
1046+
const deltaTime = currentTime - lastFrameTimeRef.current;
1047+
lastFrameTimeRef.current = currentTime;
1048+
1049+
// Normalize to 60 FPS (16.67ms per frame)
1050+
const deltaMultiplier = deltaTime / (1000 / TARGET_FPS);
1051+
1052+
// Update player physics with delta time
1053+
setPlayerVelocity(v => v + (GRAVITY * deltaMultiplier));
9871054
setPlayerY(y => {
988-
const newY = y + playerVelocity;
1055+
const newY = y + (playerVelocity * deltaMultiplier);
9891056
if (newY >= GROUND_Y) {
9901057
setIsJumping(false);
9911058
setJumpCount(0); // Reset jump count when landing
@@ -994,9 +1061,10 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
9941061
return newY;
9951062
});
9961063

997-
// Update background
998-
setBgOffset(offset => (offset + gameSpeed) % 1000);
999-
setBgImageOffset(offset => (offset + gameSpeed * 0.5) % 2000); // Slower parallax for background image
1064+
// Update background with delta time normalization
1065+
const normalizedSpeed = gameSpeed * deltaMultiplier;
1066+
setBgOffset(offset => (offset + normalizedSpeed) % 1000);
1067+
setBgImageOffset(offset => (offset + normalizedSpeed * 0.5) % 2000); // Slower parallax for background image
10001068

10011069
// Update score - only if not in safe zone
10021070
const isInSafeZone = Date.now() < invulnerableUntil;
@@ -1146,7 +1214,7 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
11461214
}
11471215
}
11481216

1149-
// Move obstacles with dynamic vertical movement
1217+
// Move obstacles with dynamic vertical movement (normalized with delta time)
11501218
setObstacles(obs =>
11511219
obs
11521220
.map(o => {
@@ -1161,22 +1229,22 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
11611229
newY = Math.max(GROUND_Y - 220, Math.min(GROUND_Y - 50, newY));
11621230
}
11631231

1164-
return { ...o, x: o.x - gameSpeed, y: newY };
1232+
return { ...o, x: o.x - normalizedSpeed, y: newY };
11651233
})
11661234
.filter(o => o.x > -100)
11671235
);
11681236

1169-
// Move coins
1237+
// Move coins (normalized with delta time)
11701238
setCoins(c =>
11711239
c
1172-
.map(coin => ({ ...coin, x: coin.x - gameSpeed }))
1240+
.map(coin => ({ ...coin, x: coin.x - normalizedSpeed }))
11731241
.filter(coin => coin.x > -50)
11741242
);
11751243

1176-
// Move power-ups
1244+
// Move power-ups (normalized with delta time)
11771245
setPowerUps(p =>
11781246
p
1179-
.map(powerUp => ({ ...powerUp, x: powerUp.x - gameSpeed }))
1247+
.map(powerUp => ({ ...powerUp, x: powerUp.x - normalizedSpeed }))
11801248
.filter(powerUp => powerUp.x > -50)
11811249
);
11821250

@@ -1663,6 +1731,9 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
16631731
<div className='text-white font-bold text-2xl mb-1'>Score: {score}</div>
16641732
<div className='text-white/80 text-sm'>High Score: {highScore}</div>
16651733
<div className='text-cyan-400 text-sm font-semibold'>Level: {level}</div>
1734+
<div className='text-purple-400 text-xs font-semibold mt-1'>
1735+
{SPEED_CONFIGS[speedMode].label}
1736+
</div>
16661737
{/* Power-up status indicator */}
16671738
{isPoweredUp && (
16681739
<div className='text-yellow-400 text-xs font-bold mt-2 animate-pulse'>
@@ -2083,6 +2154,17 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
20832154
<p className='text-cyan-300 font-semibold text-sm'>🎯 Level up every 1,000 points • 🧠 Answer Web3 quizzes</p>
20842155
</div>
20852156

2157+
{/* Current Speed Mode Indicator */}
2158+
<div className='bg-gradient-to-r from-cyan-500/20 to-purple-500/20 rounded-xl p-3 mb-4 border border-cyan-400/30'>
2159+
<div className='flex items-center justify-center gap-2'>
2160+
<span className='text-white/70 text-sm'>Current Speed:</span>
2161+
<span className='text-cyan-300 font-bold text-sm'>{SPEED_CONFIGS[speedMode].label}</span>
2162+
</div>
2163+
<p className='text-white/50 text-xs mt-1'>
2164+
⚙️ Change speed when starting or replaying
2165+
</p>
2166+
</div>
2167+
20862168
{/* Compact Controls */}
20872169
<div className='bg-black/30 rounded-xl p-4 mb-4 border border-white/10'>
20882170
<div className='grid grid-cols-2 gap-2 text-white/70 text-xs'>
@@ -2465,10 +2547,42 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
24652547
</div>
24662548

24672549
{/* Description */}
2468-
<p className='text-white/80 text-sm mb-6 leading-relaxed'>
2550+
<p className='text-white/80 text-sm mb-4 leading-relaxed'>
24692551
Starting this game will deduct 250 points from your account. You'll earn XP and rewards based on your performance!
24702552
</p>
24712553

2554+
{/* Speed Mode Selector */}
2555+
<div className='bg-gradient-to-br from-cyan-500/10 to-purple-500/10 rounded-2xl p-3 mb-6 border border-cyan-400/30'>
2556+
<div className='text-cyan-300 text-xs mb-2 font-semibold text-center'>⚡ Game Speed</div>
2557+
<div className='flex justify-center gap-2 mb-2'>
2558+
{(Object.keys(SPEED_CONFIGS) as SpeedMode[]).map((mode) => {
2559+
const icon = SPEED_CONFIGS[mode].label.split(' ')[0]; // Extract emoji
2560+
return (
2561+
<button
2562+
key={mode}
2563+
onClick={() => setSpeedMode(mode)}
2564+
title={`${SPEED_CONFIGS[mode].label}\n${SPEED_CONFIGS[mode].description}`}
2565+
className={`relative w-12 h-12 rounded-lg border-2 transition-all duration-200 ${
2566+
speedMode === mode
2567+
? 'bg-gradient-to-r from-cyan-500/30 to-purple-600/30 border-cyan-400 shadow-lg scale-110'
2568+
: 'bg-white/5 border-white/10 hover:border-white/30 hover:bg-white/10 hover:scale-105'
2569+
}`}
2570+
>
2571+
<div className='text-2xl'>{icon}</div>
2572+
{speedMode === mode && (
2573+
<div className='absolute -top-1 -right-1 bg-cyan-400 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center'>
2574+
2575+
</div>
2576+
)}
2577+
</button>
2578+
);
2579+
})}
2580+
</div>
2581+
<div className='text-white/50 text-[10px] text-center'>
2582+
Hover to see speed names • Frame-rate normalized!
2583+
</div>
2584+
</div>
2585+
24722586
{/* Buttons */}
24732587
<div className='grid grid-cols-2 gap-3'>
24742588
<button
@@ -2513,10 +2627,42 @@ const InfiniteRunner: React.FC<InfiniteRunnerProps> = ({ gameId, gameTitle, embe
25132627
</div>
25142628

25152629
{/* Description */}
2516-
<p className='text-white/80 text-sm mb-6 leading-relaxed'>
2630+
<p className='text-white/80 text-sm mb-4 leading-relaxed'>
25172631
Playing again will deduct 100 points from your account. Try to beat your high score of {highScore} points!
25182632
</p>
25192633

2634+
{/* Speed Mode Selector */}
2635+
<div className='bg-gradient-to-br from-cyan-500/10 to-purple-500/10 rounded-2xl p-3 mb-6 border border-cyan-400/30'>
2636+
<div className='text-cyan-300 text-xs mb-2 font-semibold text-center'>⚡ Game Speed</div>
2637+
<div className='flex justify-center gap-2 mb-2'>
2638+
{(Object.keys(SPEED_CONFIGS) as SpeedMode[]).map((mode) => {
2639+
const icon = SPEED_CONFIGS[mode].label.split(' ')[0]; // Extract emoji
2640+
return (
2641+
<button
2642+
key={mode}
2643+
onClick={() => setSpeedMode(mode)}
2644+
title={`${SPEED_CONFIGS[mode].label}\n${SPEED_CONFIGS[mode].description}`}
2645+
className={`relative w-12 h-12 rounded-lg border-2 transition-all duration-200 ${
2646+
speedMode === mode
2647+
? 'bg-gradient-to-r from-cyan-500/30 to-purple-600/30 border-cyan-400 shadow-lg scale-110'
2648+
: 'bg-white/5 border-white/10 hover:border-white/30 hover:bg-white/10 hover:scale-105'
2649+
}`}
2650+
>
2651+
<div className='text-2xl'>{icon}</div>
2652+
{speedMode === mode && (
2653+
<div className='absolute -top-1 -right-1 bg-cyan-400 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center'>
2654+
2655+
</div>
2656+
)}
2657+
</button>
2658+
);
2659+
})}
2660+
</div>
2661+
<div className='text-white/50 text-[10px] text-center'>
2662+
Hover to see speed names • Frame-rate normalized!
2663+
</div>
2664+
</div>
2665+
25202666
{/* Buttons */}
25212667
<div className='grid grid-cols-2 gap-3'>
25222668
<button

0 commit comments

Comments
 (0)