food!
This commit is contained in:
+4
-1
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(() => {
|
|
||||||
|
let shouldGrow = false;
|
||||||
|
const msPerFrame = 1000 / FRAMES_PER_SECOND;
|
||||||
|
lastUpdateRef.current = performance.now();
|
||||||
|
|
||||||
|
const gameLoop = (timestamp: number) => {
|
||||||
|
const elapsed = timestamp - lastUpdateRef.current;
|
||||||
|
|
||||||
|
if (elapsed >= msPerFrame) {
|
||||||
console.log("Game loop tick");
|
console.log("Game loop tick");
|
||||||
const result = snakeManagerRef.current.moveSnake();
|
const result = snakeManagerRef.current.moveSnake(shouldGrow);
|
||||||
console.log("Move result:", result);
|
console.log("Move result:", result);
|
||||||
|
|
||||||
|
shouldGrow = false;
|
||||||
|
|
||||||
if (result === "wall-collision") {
|
if (result === "wall-collision") {
|
||||||
setGameStatus("game-over");
|
setGameStatus("game-over");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, INITIAL_SPEED);
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user