feat(phase1.2b): implement Nokia-style key buffering system

- Add nextDirectionRef to buffer player input
- Update changeDirection() to queue input instead of immediate execution
- Add applyBufferedDirection() to safely apply buffered input
- Buffer prevents 180° reversals for safe turns
- Call applyBufferedDirection() after each successful move in game loop
- Remove collision grace period - no longer needed with key buffering
- Remove collisionGracePeriodRef and related logic
- Clean up all console.log statements from useGameStatus
- Game now feels responsive at high speeds
- Allows pre-emptive turning on board edges (Nokia Snake style)
- Build and example app work correctly
This commit is contained in:
2025-10-30 22:39:21 +00:00
parent f9cd9c60e7
commit 762bc86878
3 changed files with 30 additions and 36 deletions
+22
View File
@@ -13,6 +13,7 @@ export function useCursor(
const [direction, setDirection] = useState<Direction>("up"); const [direction, setDirection] = useState<Direction>("up");
const directionRef = useRef<Direction>("up"); const directionRef = useRef<Direction>("up");
const lastMovedDirectionRef = useRef<Direction>("up"); const lastMovedDirectionRef = useRef<Direction>("up");
const nextDirectionRef = useRef<Direction | null>(null);
const initSnake = () => { const initSnake = () => {
const centerR = Math.floor(rows / 2); const centerR = Math.floor(rows / 2);
@@ -103,9 +104,29 @@ export function useCursor(
right: "left", right: "left",
}; };
// Buffer the direction instead of applying immediately
// Only buffer if it's not a 180° reversal of the last MOVED direction
if (opposites[lastMovedDirectionRef.current] !== newDirection) {
nextDirectionRef.current = newDirection;
}
};
const applyBufferedDirection = () => {
if (nextDirectionRef.current === null) return;
const newDirection = nextDirectionRef.current;
const opposites: Record<Direction, Direction> = {
up: "down",
down: "up",
left: "right",
right: "left",
};
// Apply buffer only if still valid (not 180° reversal)
if (opposites[lastMovedDirectionRef.current] !== newDirection) { if (opposites[lastMovedDirectionRef.current] !== newDirection) {
setDirection(newDirection); setDirection(newDirection);
directionRef.current = newDirection; directionRef.current = newDirection;
nextDirectionRef.current = null;
} }
}; };
@@ -237,6 +258,7 @@ export function useCursor(
initSnake, initSnake,
clearSnake, clearSnake,
changeDirection, changeDirection,
applyBufferedDirection,
moveSnake, moveSnake,
renderSnake, renderSnake,
}; };
-10
View File
@@ -58,9 +58,7 @@ export function useGameStatus(
}; };
useEffect(() => { useEffect(() => {
console.log("useGameStatus effect - gameStatus:", gameStatus);
if (gameStatus === "started") { if (gameStatus === "started") {
console.log("Starting game loop");
if (paused) return; if (paused) return;
let shouldGrow = false; let shouldGrow = false;
lastUpdateRef.current = performance.now(); lastUpdateRef.current = performance.now();
@@ -70,9 +68,7 @@ export function useGameStatus(
const elapsed = timestamp - lastUpdateRef.current; const elapsed = timestamp - lastUpdateRef.current;
if (elapsed >= msPerFrame) { if (elapsed >= msPerFrame) {
console.log("Game loop tick");
const result = snakeManagerRef.current.moveSnake(shouldGrow); const result = snakeManagerRef.current.moveSnake(shouldGrow);
console.log("Move result:", result);
shouldGrow = false; shouldGrow = false;
@@ -96,7 +92,6 @@ export function useGameStatus(
const head = snakeManagerRef.current.snakeBodyRef.current[0]; const head = snakeManagerRef.current.snakeBodyRef.current[0];
if (head && foodManagerRef.current.checkFoodCollision(head)) { if (head && foodManagerRef.current.checkFoodCollision(head)) {
console.log("Food eaten!");
foodManagerRef.current.removeFood(head); foodManagerRef.current.removeFood(head);
shouldGrow = true; shouldGrow = true;
@@ -104,15 +99,12 @@ export function useGameStatus(
setScore((prev) => { setScore((prev) => {
let newScore = prev + levelRef.current; let newScore = prev + levelRef.current;
console.log('newscore:', newScore, 'level:');
if (foodsEatenRef.current >= FOODS_PER_LEVEL) { if (foodsEatenRef.current >= FOODS_PER_LEVEL) {
newScore--; newScore--;
console.log('newScore after decrement:', newScore);
const newLevel = levelRef.current + 1; const newLevel = levelRef.current + 1;
levelRef.current = newLevel; levelRef.current = newLevel;
setLevel(newLevel); setLevel(newLevel);
currentFpsRef.current = INITIAL_FPS + (newLevel - 1); currentFpsRef.current = INITIAL_FPS + (newLevel - 1);
console.log(`Level up! Level ${newLevel}, FPS: ${currentFpsRef.current}`);
foodsEatenRef.current = 0; foodsEatenRef.current = 0;
} }
@@ -120,7 +112,6 @@ export function useGameStatus(
snakeManagerRef.current.snakeBodyRef.current, snakeManagerRef.current.snakeBodyRef.current,
1 1
); );
console.log('Final newScore to be set:', newScore);
return newScore; return newScore;
}); });
@@ -135,7 +126,6 @@ export function useGameStatus(
gameLoopRef.current = requestAnimationFrame(gameLoop); gameLoopRef.current = requestAnimationFrame(gameLoop);
return () => { return () => {
console.log("Cleaning up game loop");
if (gameLoopRef.current) { if (gameLoopRef.current) {
cancelAnimationFrame(gameLoopRef.current); cancelAnimationFrame(gameLoopRef.current);
gameLoopRef.current = null; gameLoopRef.current = null;
+8 -26
View File
@@ -26,7 +26,6 @@ export function useGameStatus(
const snakeManagerRef = useRef(snakeManager); const snakeManagerRef = useRef(snakeManager);
const foodManagerRef = useRef(foodManager); const foodManagerRef = useRef(foodManager);
const currentFpsRef = useRef(INITIAL_FPS); const currentFpsRef = useRef(INITIAL_FPS);
const collisionGracePeriodRef = useRef(false);
snakeManagerRef.current = snakeManager; snakeManagerRef.current = snakeManager;
foodManagerRef.current = foodManager; foodManagerRef.current = foodManager;
@@ -39,7 +38,6 @@ export function useGameStatus(
levelRef.current = 1; levelRef.current = 1;
foodsEatenRef.current = 0; foodsEatenRef.current = 0;
currentFpsRef.current = INITIAL_FPS; currentFpsRef.current = INITIAL_FPS;
collisionGracePeriodRef.current = false;
setGameStatus("started"); setGameStatus("started");
}; };
@@ -58,9 +56,7 @@ export function useGameStatus(
}; };
useEffect(() => { useEffect(() => {
console.log("useGameStatus effect - gameStatus:", gameStatus);
if (gameStatus === "started") { if (gameStatus === "started") {
console.log("Starting game loop");
if (paused) return; if (paused) return;
let shouldGrow = false; let shouldGrow = false;
lastUpdateRef.current = performance.now(); lastUpdateRef.current = performance.now();
@@ -70,33 +66,24 @@ export function useGameStatus(
const elapsed = timestamp - lastUpdateRef.current; const elapsed = timestamp - lastUpdateRef.current;
if (elapsed >= msPerFrame) { if (elapsed >= msPerFrame) {
console.log("Game loop tick");
const result = snakeManagerRef.current.moveSnake(shouldGrow); const result = snakeManagerRef.current.moveSnake(shouldGrow);
console.log("Move result:", result);
shouldGrow = false; shouldGrow = false;
if (result === "wall-collision" || result === "self-collision") { if (result === "wall-collision" || result === "self-collision") {
if (collisionGracePeriodRef.current) { const container = containerRef.current;
const container = containerRef.current; container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el: Element) => {
container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el: Element) => { (el as HTMLElement).classList.add("collision");
(el as HTMLElement).classList.add("collision"); });
}); setGameStatus("game-over");
setGameStatus("game-over"); return;
return;
} else {
collisionGracePeriodRef.current = true;
lastUpdateRef.current = timestamp;
gameLoopRef.current = requestAnimationFrame(gameLoop);
return;
}
} }
collisionGracePeriodRef.current = false; // Apply any buffered direction input for next frame
snakeManagerRef.current.applyBufferedDirection();
const head = snakeManagerRef.current.snakeBodyRef.current[0]; const head = snakeManagerRef.current.snakeBodyRef.current[0];
if (head && foodManagerRef.current.checkFoodCollision(head)) { if (head && foodManagerRef.current.checkFoodCollision(head)) {
console.log("Food eaten!");
foodManagerRef.current.removeFood(head); foodManagerRef.current.removeFood(head);
shouldGrow = true; shouldGrow = true;
@@ -104,15 +91,12 @@ export function useGameStatus(
setScore((prev) => { setScore((prev) => {
let newScore = prev + levelRef.current; let newScore = prev + levelRef.current;
console.log('newscore:', newScore, 'level:');
if (foodsEatenRef.current >= FOODS_PER_LEVEL) { if (foodsEatenRef.current >= FOODS_PER_LEVEL) {
newScore--; newScore--;
console.log('newScore after decrement:', newScore);
const newLevel = levelRef.current + 1; const newLevel = levelRef.current + 1;
levelRef.current = newLevel; levelRef.current = newLevel;
setLevel(newLevel); setLevel(newLevel);
currentFpsRef.current = INITIAL_FPS + (newLevel - 1); currentFpsRef.current = INITIAL_FPS + (newLevel - 1);
console.log(`Level up! Level ${newLevel}, FPS: ${currentFpsRef.current}`);
foodsEatenRef.current = 0; foodsEatenRef.current = 0;
} }
@@ -120,7 +104,6 @@ export function useGameStatus(
snakeManagerRef.current.snakeBodyRef.current, snakeManagerRef.current.snakeBodyRef.current,
1 1
); );
console.log('Final newScore to be set:', newScore);
return newScore; return newScore;
}); });
@@ -135,7 +118,6 @@ export function useGameStatus(
gameLoopRef.current = requestAnimationFrame(gameLoop); gameLoopRef.current = requestAnimationFrame(gameLoop);
return () => { return () => {
console.log("Cleaning up game loop");
if (gameLoopRef.current) { if (gameLoopRef.current) {
cancelAnimationFrame(gameLoopRef.current); cancelAnimationFrame(gameLoopRef.current);
gameLoopRef.current = null; gameLoopRef.current = null;