fix: prevent button focus from interfering with game controls when changing modes
- Add blur() to mode buttons to remove focus after clicking - Add preventDefault() to space/escape handlers to stop button activation - Fix stale closures in useKeyBindings by accessing gameManager directly - Remove all useCallback hooks per React 19 best practices - Remove function dependencies from useEffect hooks
This commit is contained in:
+17
-16
@@ -4,8 +4,8 @@ import "@vimazing/typing-chud/game.css";
|
||||
import { useKeyBindings } from "./useKeyBindings";
|
||||
|
||||
function App() {
|
||||
const [gameMode, setGameMode] = useState<GameMode>("words");
|
||||
const gameManager = useGame({ gameMode }, useKeyBindings);
|
||||
const [gameMode, setGameMode] = useState<GameMode>("letters");
|
||||
const gameManager = useGame({ initialGameMode: gameMode }, useKeyBindings);
|
||||
const { containerRef, gameStatus, scoreManager } = gameManager;
|
||||
|
||||
const formatTime = (ms: number) => {
|
||||
@@ -21,20 +21,21 @@ function App() {
|
||||
<h1 className="text-2xl font-bold text-center">Typing Chud</h1>
|
||||
<div className="flex gap-2 justify-center text-sm">
|
||||
{(['letters', 'words', 'sentences', 'paragraphs'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
setGameMode(mode);
|
||||
gameManager.quitGame();
|
||||
}}
|
||||
className={`px-3 py-1 rounded capitalize font-medium transition ${
|
||||
gameMode === mode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-muted text-foreground hover:bg-blue-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
<button
|
||||
key={mode}
|
||||
onClick={(e) => {
|
||||
setGameMode(mode);
|
||||
gameManager.changeGameMode(mode);
|
||||
e.currentTarget.blur(); // Remove focus from button
|
||||
}}
|
||||
className={`px-3 py-1 rounded capitalize font-medium transition ${
|
||||
gameMode === mode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-muted text-foreground hover:bg-blue-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,44 +2,44 @@ import { useEffect } from "react";
|
||||
import type { GameManager } from "../../src";
|
||||
|
||||
export const useKeyBindings = (gameManager: GameManager) => {
|
||||
const { gameStatus, renderBoard, startGame, quitGame, clearKeyLog, handleTypedLetter } = gameManager;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: KeyboardEvent) => {
|
||||
// start game
|
||||
if (ev.code === "Space") {
|
||||
if (["waiting", "game-over", "game-won"].includes(gameStatus)) {
|
||||
clearKeyLog();
|
||||
renderBoard();
|
||||
if (gameStatus === "game-over" || gameStatus === "game-won") {
|
||||
quitGame();
|
||||
setTimeout(() => startGame(), 0);
|
||||
ev.preventDefault(); // Prevent button activation
|
||||
if (["waiting", "game-over", "game-won"].includes(gameManager.gameStatus)) {
|
||||
gameManager.clearKeyLog();
|
||||
gameManager.renderBoard();
|
||||
if (gameManager.gameStatus === "game-over" || gameManager.gameStatus === "game-won") {
|
||||
gameManager.quitGame();
|
||||
setTimeout(() => gameManager.startGame(), 0);
|
||||
} else {
|
||||
startGame();
|
||||
gameManager.startGame();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ignore other keys while waiting
|
||||
if (gameStatus === "waiting") return;
|
||||
if (gameManager.gameStatus === "waiting") return;
|
||||
|
||||
// quit
|
||||
if (ev.key === "Escape") {
|
||||
quitGame();
|
||||
ev.preventDefault();
|
||||
gameManager.quitGame();
|
||||
return;
|
||||
}
|
||||
|
||||
// handle letter typing during game
|
||||
if (gameStatus === "started") {
|
||||
if (gameManager.gameStatus === "started") {
|
||||
if (ev.key.length === 1 && /[a-zA-Z]/.test(ev.key)) {
|
||||
handleTypedLetter(ev.key);
|
||||
gameManager.handleTypedLetter(ev.key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [gameStatus, handleTypedLetter]);
|
||||
}, [gameManager.gameStatus]);
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -43,7 +43,7 @@ export type FallingLetter = {
|
||||
export type KeyLogEntry = { key: string; timestamp: number };
|
||||
|
||||
export type GameOptions = {
|
||||
gameMode?: GameMode;
|
||||
initialGameMode?: GameMode;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -153,6 +153,7 @@ export type GameManager = {
|
||||
|
||||
// Game-specific
|
||||
handleTypedLetter: (letter: string) => void;
|
||||
changeGameMode: (mode: GameMode) => void;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+14
-5
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { GameManager, GameOptions, KeyLogEntry, FallingLetter } from "../types";
|
||||
import type { GameManager, GameOptions, KeyLogEntry, FallingLetter, GameMode } from "../types";
|
||||
import { useBoard } from "../useBoard";
|
||||
import { useCursor } from "../useCursor";
|
||||
import { useGameStatus } from "../useGameStatus";
|
||||
@@ -10,11 +10,11 @@ export function useGame(
|
||||
platformHook?: unknown
|
||||
): GameManager {
|
||||
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
|
||||
const gameMode = options?.gameMode ?? 'letters';
|
||||
const [gameMode, setGameModeState] = useState<GameMode>(options?.initialGameMode ?? 'letters');
|
||||
|
||||
const boardManager = useBoard();
|
||||
const cursorManager = useCursor();
|
||||
const gameStatusManager = useGameStatus();
|
||||
const gameStatusManager = useGameStatus(undefined, gameMode);
|
||||
|
||||
// Wrap renderBoard to include player position
|
||||
const renderBoardWrapped = (letters: any = []) => {
|
||||
@@ -38,12 +38,20 @@ export function useGame(
|
||||
// Update gameMode when it changes (without recreating gameStatus state)
|
||||
useEffect(() => {
|
||||
gameStatusManager.setGameMode(gameMode);
|
||||
}, [gameMode, gameStatusManager]);
|
||||
}, [gameMode]);
|
||||
|
||||
const changeGameMode = (mode: GameMode) => {
|
||||
setGameModeState(mode);
|
||||
// Only quit if the game is actively running
|
||||
if (gameStatusManager.gameStatus === 'started') {
|
||||
gameStatusManager.quitGame();
|
||||
}
|
||||
};
|
||||
|
||||
// Set the game loop callback
|
||||
useEffect(() => {
|
||||
gameStatusManager.setGameLoopCallback(handleGameLoopTick);
|
||||
}, [gameStatusManager, cursorManager, boardManager]);
|
||||
}, []);
|
||||
|
||||
// Initial render on mount
|
||||
useEffect(() => {
|
||||
@@ -65,6 +73,7 @@ export function useGame(
|
||||
clearKeyLog: () => setKeyLog([]),
|
||||
getKeyLog: () => [...keyLog],
|
||||
handleTypedLetter: gameStatusManager.handleTypedLetter,
|
||||
changeGameMode,
|
||||
};
|
||||
|
||||
if (typeof platformHook === 'function') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import type { GameStatus, FallingLetter, ShipState, GameMode } from "../types";
|
||||
import { getRandomWord } from "../wordLists";
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall
|
||||
const gameModeRef = useRef<GameMode>(gameMode);
|
||||
|
||||
// Game loop
|
||||
const gameLoop = useCallback((currentTime: number) => {
|
||||
const gameLoop = (currentTime: number) => {
|
||||
const deltaTime = currentTime - lastFrameTimeRef.current;
|
||||
lastFrameTimeRef.current = currentTime;
|
||||
|
||||
@@ -92,9 +92,9 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall
|
||||
|
||||
// Continue loop
|
||||
gameLoopRef.current = requestAnimationFrame(gameLoop);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
const startGame = () => {
|
||||
lastFrameTimeRef.current = performance.now();
|
||||
lastSpawnRef.current = performance.now();
|
||||
lettersRef.current = [];
|
||||
@@ -104,17 +104,17 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall
|
||||
setDeaths(0);
|
||||
setGameStatus("started");
|
||||
gameLoopRef.current = requestAnimationFrame(gameLoop);
|
||||
}, [gameLoop]);
|
||||
};
|
||||
|
||||
const quitGame = useCallback(() => {
|
||||
const quitGame = () => {
|
||||
setGameStatus("waiting");
|
||||
if (gameLoopRef.current !== null) {
|
||||
cancelAnimationFrame(gameLoopRef.current);
|
||||
gameLoopRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleTypedLetter = useCallback((typedLetter: string) => {
|
||||
const handleTypedLetter = (typedLetter: string) => {
|
||||
if (lettersRef.current.length === 0) return;
|
||||
|
||||
const currentLetter = lettersRef.current[0];
|
||||
@@ -155,33 +155,33 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const updatePlayerX = useCallback((x: number) => {
|
||||
const updatePlayerX = (x: number) => {
|
||||
playerXRef.current = x;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const setGameLoopCallback = useCallback((callback: (deltaTime: number, letters: FallingLetter[]) => void) => {
|
||||
const setGameLoopCallback = (callback: (deltaTime: number, letters: FallingLetter[]) => void) => {
|
||||
onGameLoopTickRef.current = callback;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const setGameMode = useCallback((mode: GameMode) => {
|
||||
const setGameMode = (mode: GameMode) => {
|
||||
gameModeRef.current = mode;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const addCorrect = useCallback(() => {
|
||||
const addCorrect = () => {
|
||||
setScore(prev => prev + 1);
|
||||
setCorrect(prev => prev + 1);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const addMissed = useCallback(() => {
|
||||
const addMissed = () => {
|
||||
setScore(prev => Math.max(0, prev - 1));
|
||||
setMissed(prev => prev + 1);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const addDeath = useCallback(() => {
|
||||
const addDeath = () => {
|
||||
setDeaths(prev => prev + 1);
|
||||
}, []);
|
||||
};
|
||||
|
||||
return {
|
||||
gameStatus,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type UseTimerReturn = {
|
||||
timeValue: number;
|
||||
@@ -22,16 +22,16 @@ export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
|
||||
const offsetRef = useRef(0);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
const update = useCallback(() => {
|
||||
const update = () => {
|
||||
if (startTimeRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
setTimeValue(offsetRef.current + (now - startTimeRef.current));
|
||||
}, []);
|
||||
};
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
const startTimer = () => {
|
||||
if (intervalIdRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
@@ -42,9 +42,9 @@ export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
|
||||
|
||||
update();
|
||||
intervalIdRef.current = setInterval(update, Math.max(16, interval));
|
||||
}, [interval, update]);
|
||||
};
|
||||
|
||||
const stopTimer = useCallback(() => {
|
||||
const stopTimer = () => {
|
||||
if (intervalIdRef.current !== null) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
intervalIdRef.current = null;
|
||||
@@ -55,9 +55,9 @@ export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
|
||||
offsetRef.current += performance.now() - startTimeRef.current;
|
||||
startTimeRef.current = null;
|
||||
}
|
||||
}, [update]);
|
||||
};
|
||||
|
||||
const resetTimer = useCallback(() => {
|
||||
const resetTimer = () => {
|
||||
if (intervalIdRef.current !== null) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
intervalIdRef.current = null;
|
||||
@@ -66,7 +66,7 @@ export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
|
||||
startTimeRef.current = null;
|
||||
offsetRef.current = 0;
|
||||
setTimeValue(0);
|
||||
}, []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user