This commit is contained in:
2025-10-16 16:22:09 +01:00
parent ab78c6a881
commit e4f5be1a56
7 changed files with 188 additions and 26 deletions
+4 -1
View File
@@ -3,7 +3,7 @@ import "@vimazing/vim-snake/game.css";
import { useKeyBindings } from "./useKeyBindings"; import { useKeyBindings } from "./useKeyBindings";
function App() { function App() {
const { containerRef, gameStatus, scoreManager, keyLog } = useGame(30, 20, useKeyBindings); const { containerRef, gameStatus, scoreManager, keyLog, score } = useGame(30, 20, useKeyBindings);
const formatTime = (ms: number) => { const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000); const seconds = Math.floor(ms / 1000);
@@ -20,6 +20,9 @@ function App() {
<div className="px-3 py-1 bg-muted rounded"> <div className="px-3 py-1 bg-muted rounded">
Time: {formatTime(scoreManager.timeValue)} Time: {formatTime(scoreManager.timeValue)}
</div> </div>
<div className="px-3 py-1 bg-muted rounded">
Score: {score}
</div>
<div className="px-3 py-1 bg-muted rounded"> <div className="px-3 py-1 bg-muted rounded">
Keys: {keyLog.length} Keys: {keyLog.length}
</div> </div>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@vimazing/vim-snake", "name": "@vimazing/vim-snake",
"version": "0.2.0", "version": "0.3.0",
"description": "Classic Snake reimagined with Vim controls — a lightweight, typed React hook library for building snake games with hjkl navigation.", "description": "Classic Snake reimagined with Vim controls — a lightweight, typed React hook library for building snake games with hjkl navigation.",
"author": "André Padez <andre.padez@gmail.com>", "author": "André Padez <andre.padez@gmail.com>",
"keywords": [ "keywords": [
+21
View File
@@ -99,3 +99,24 @@
border-radius: 2px 6px 6px 2px; border-radius: 2px 6px 6px 2px;
} }
/* =========================================================
Food Rendering
========================================================= */
.snake-cell.food {
background-color: var(--chart-2, #c3e88d);
border-radius: 50%;
box-shadow:
0 0 8px color-mix(in srgb, var(--chart-2, #c3e88d) 60%, transparent),
inset 0 0 4px color-mix(in srgb, var(--chart-2, #c3e88d) 80%, transparent);
position: relative;
}
.snake-cell.food::after {
content: "🍎";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
}
+95 -2
View File
@@ -1,3 +1,96 @@
export function useFood() { import { useState, useRef } from "react";
return {}; import type { Position, SnakeBody } from "../types";
import type { UseRendererType } from "./useRenderer";
export function useFood(
cols: number,
rows: number,
rendererManager: UseRendererType
) {
const { containerRef } = rendererManager;
const [foodPositions, setFoodPositions] = useState<Position[]>([]);
const foodPositionsRef = useRef<Position[]>([]);
const spawnFood = (snakeBody: SnakeBody, count: number = 1) => {
const occupiedPositions = new Set(
snakeBody.map((seg) => `${seg.r},${seg.c}`)
);
const availablePositions: Position[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const key = `${r},${c}`;
if (!occupiedPositions.has(key)) {
availablePositions.push({ r, c });
}
}
}
const newFood: Position[] = [];
for (let i = 0; i < count && availablePositions.length > 0; i++) {
const randomIndex = Math.floor(Math.random() * availablePositions.length);
const food = availablePositions.splice(randomIndex, 1)[0];
newFood.push(food);
}
setFoodPositions(newFood);
foodPositionsRef.current = newFood;
renderFood(newFood);
};
const clearFood = () => {
setFoodPositions([]);
foodPositionsRef.current = [];
const container = containerRef.current;
container?.querySelectorAll(".food").forEach((el) => {
el.classList.remove("food");
});
};
const renderFood = (positions: Position[]) => {
const container = containerRef.current;
if (!container) return;
container.querySelectorAll(".food").forEach((el) => {
el.classList.remove("food");
});
positions.forEach((pos) => {
const cell = container.querySelector(
`.snake-cell[data-r="${pos.r}"][data-c="${pos.c}"]`
);
if (cell) {
cell.classList.add("food");
}
});
};
const checkFoodCollision = (headPos: Position): boolean => {
const food = foodPositionsRef.current;
const collision = food.some(
(f) => f.r === headPos.r && f.c === headPos.c
);
return collision;
};
const removeFood = (pos: Position) => {
const newFood = foodPositionsRef.current.filter(
(f) => !(f.r === pos.r && f.c === pos.c)
);
setFoodPositions(newFood);
foodPositionsRef.current = newFood;
renderFood(newFood);
};
return {
foodPositions,
foodPositionsRef,
spawnFood,
clearFood,
checkFoodCollision,
removeFood,
renderFood,
};
} }
export type UseFoodType = ReturnType<typeof useFood>;
+7 -2
View File
@@ -1,6 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useRenderer } from "./useRenderer"; import { useRenderer } from "./useRenderer";
import { useSnake } from "./useSnake"; import { useSnake } from "./useSnake";
import { useFood } from "./useFood";
import { useGameStatus } from "./useGameStatus"; import { useGameStatus } from "./useGameStatus";
import { useKeyBindings, type UseKeyBindingsType } from "./useKeyBindings"; import { useKeyBindings, type UseKeyBindingsType } from "./useKeyBindings";
import { useScore } from "../useScore"; import { useScore } from "../useScore";
@@ -12,8 +13,10 @@ export function useGame(cols: number, rows: number, platformHook?: unknown) {
const snakeManager = useSnake(cols, rows, rendererManager); const snakeManager = useSnake(cols, rows, rendererManager);
const { changeDirection } = snakeManager; const { changeDirection } = snakeManager;
const gameManager = useGameStatus(rendererManager, snakeManager); const foodManager = useFood(cols, rows, rendererManager);
const { gameStatus, startGame, stopGame } = gameManager;
const gameManager = useGameStatus(rendererManager, snakeManager, foodManager);
const { gameStatus, startGame, stopGame, score } = gameManager;
useEffect(() => { useEffect(() => {
renderBoard(cols, rows); renderBoard(cols, rows);
@@ -33,7 +36,9 @@ export function useGame(cols: number, rows: number, platformHook?: unknown) {
gameStatus, gameStatus,
startGame, startGame,
stopGame, stopGame,
score,
...snakeManager, ...snakeManager,
...foodManager,
...keyBindings, ...keyBindings,
scoreManager, scoreManager,
}; };
+56 -15
View File
@@ -2,32 +2,42 @@ import { useState, useEffect, useRef } from "react";
import type { GameStatus } from "../types"; import type { GameStatus } from "../types";
import type { UseRendererType } from "./useRenderer"; import type { UseRendererType } from "./useRenderer";
import type { UseSnakeType } from "./useSnake"; import type { UseSnakeType } from "./useSnake";
import type { UseFoodType } from "./useFood";
const INITIAL_SPEED = 200; const FRAMES_PER_SECOND = 5;
export function useGameStatus( export function useGameStatus(
rendererManager: UseRendererType, rendererManager: UseRendererType,
snakeManager: UseSnakeType snakeManager: UseSnakeType,
foodManager: UseFoodType
) { ) {
const { containerRef } = rendererManager; const { containerRef } = rendererManager;
const { initSnake, clearSnake } = snakeManager; const { initSnake, clearSnake, snakeBodyRef } = snakeManager;
const { spawnFood, clearFood } = foodManager;
const [gameStatus, setGameStatus] = useState<GameStatus>("waiting"); const [gameStatus, setGameStatus] = useState<GameStatus>("waiting");
const gameLoopRef = useRef<ReturnType<typeof setInterval> | null>(null); const [score, setScore] = useState(0);
const gameLoopRef = useRef<number | null>(null);
const lastUpdateRef = useRef<number>(0);
const snakeManagerRef = useRef(snakeManager); const snakeManagerRef = useRef(snakeManager);
const foodManagerRef = useRef(foodManager);
snakeManagerRef.current = snakeManager; snakeManagerRef.current = snakeManager;
foodManagerRef.current = foodManager;
const startGame = () => { const startGame = () => {
initSnake(); initSnake();
spawnFood(snakeBodyRef.current, 3);
setScore(0);
setGameStatus("started"); setGameStatus("started");
}; };
const stopGame = () => { const stopGame = () => {
if (gameLoopRef.current) { if (gameLoopRef.current) {
clearInterval(gameLoopRef.current); cancelAnimationFrame(gameLoopRef.current);
gameLoopRef.current = null; gameLoopRef.current = null;
} }
clearSnake(); clearSnake();
clearFood();
setGameStatus("waiting"); setGameStatus("waiting");
const container = containerRef.current; const container = containerRef.current;
container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el) => { container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el) => {
@@ -39,20 +49,50 @@ export function useGameStatus(
console.log("useGameStatus effect - gameStatus:", gameStatus); console.log("useGameStatus effect - gameStatus:", gameStatus);
if (gameStatus === "started") { if (gameStatus === "started") {
console.log("Starting game loop"); console.log("Starting game loop");
gameLoopRef.current = setInterval(() => {
console.log("Game loop tick"); let shouldGrow = false;
const result = snakeManagerRef.current.moveSnake(); const msPerFrame = 1000 / FRAMES_PER_SECOND;
console.log("Move result:", result); lastUpdateRef.current = performance.now();
if (result === "wall-collision") { const gameLoop = (timestamp: number) => {
setGameStatus("game-over"); const elapsed = timestamp - lastUpdateRef.current;
if (elapsed >= msPerFrame) {
console.log("Game loop tick");
const result = snakeManagerRef.current.moveSnake(shouldGrow);
console.log("Move result:", result);
shouldGrow = false;
if (result === "wall-collision") {
setGameStatus("game-over");
return;
}
const head = snakeManagerRef.current.snakeBodyRef.current[0];
if (head && foodManagerRef.current.checkFoodCollision(head)) {
console.log("Food eaten!");
foodManagerRef.current.removeFood(head);
shouldGrow = true;
setScore((prev) => prev + 1);
foodManagerRef.current.spawnFood(
snakeManagerRef.current.snakeBodyRef.current,
1
);
}
lastUpdateRef.current = timestamp;
} }
}, INITIAL_SPEED);
gameLoopRef.current = requestAnimationFrame(gameLoop);
};
gameLoopRef.current = requestAnimationFrame(gameLoop);
return () => { return () => {
console.log("Cleaning up game loop"); console.log("Cleaning up game loop");
if (gameLoopRef.current) { if (gameLoopRef.current) {
clearInterval(gameLoopRef.current); cancelAnimationFrame(gameLoopRef.current);
gameLoopRef.current = null; gameLoopRef.current = null;
} }
}; };
@@ -62,7 +102,7 @@ export function useGameStatus(
useEffect(() => { useEffect(() => {
if (gameStatus === "game-over") { if (gameStatus === "game-over") {
if (gameLoopRef.current) { if (gameLoopRef.current) {
clearInterval(gameLoopRef.current); cancelAnimationFrame(gameLoopRef.current);
gameLoopRef.current = null; gameLoopRef.current = null;
} }
} }
@@ -73,6 +113,7 @@ export function useGameStatus(
setGameStatus, setGameStatus,
startGame, startGame,
stopGame, stopGame,
score,
}; };
} }
+4 -5
View File
@@ -21,9 +21,6 @@ export function useSnake(
{ r: centerR, c: centerC }, { r: centerR, c: centerC },
{ r: centerR + 1, c: centerC }, { r: centerR + 1, c: centerC },
{ r: centerR + 2, c: centerC }, { r: centerR + 2, c: centerC },
{ r: centerR + 3, c: centerC },
{ r: centerR + 4, c: centerC },
{ r: centerR + 5, c: centerC },
]; ];
setSnakeBody(initialBody); setSnakeBody(initialBody);
@@ -109,7 +106,7 @@ export function useSnake(
} }
}; };
const moveSnake = (): "continue" | "wall-collision" | "self-collision" => { const moveSnake = (grow: boolean = false): "continue" | "wall-collision" | "self-collision" => {
const body = snakeBodyRef.current; const body = snakeBodyRef.current;
if (body.length === 0) return "continue"; if (body.length === 0) return "continue";
@@ -139,7 +136,9 @@ export function useSnake(
} }
const newHead = { r: newR, c: newC }; const newHead = { r: newR, c: newC };
const newBody = [newHead, ...body.slice(0, -1)]; const newBody = grow
? [newHead, ...body]
: [newHead, ...body.slice(0, -1)];
setSnakeBody(newBody); setSnakeBody(newBody);
snakeBodyRef.current = newBody; snakeBodyRef.current = newBody;