From e4f5be1a56147381105032fad0b1a574be9163e4 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Thu, 16 Oct 2025 16:22:09 +0100 Subject: [PATCH] food! --- example/src/App.tsx | 5 +- package.json | 2 +- src/game.css | 21 ++++++++ src/useGame/useFood.ts | 97 +++++++++++++++++++++++++++++++++++- src/useGame/useGame.ts | 9 +++- src/useGame/useGameStatus.ts | 71 ++++++++++++++++++++------ src/useGame/useSnake.ts | 9 ++-- 7 files changed, 188 insertions(+), 26 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index 82c5bb2..58feba4 100755 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,7 +3,7 @@ import "@vimazing/vim-snake/game.css"; import { useKeyBindings } from "./useKeyBindings"; 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 seconds = Math.floor(ms / 1000); @@ -20,6 +20,9 @@ function App() {
Time: {formatTime(scoreManager.timeValue)}
+
+ Score: {score} +
Keys: {keyLog.length}
diff --git a/package.json b/package.json index 7aa3b4a..1d0ca75 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "author": "André Padez ", "keywords": [ diff --git a/src/game.css b/src/game.css index 49ac1d0..5b99c1d 100755 --- a/src/game.css +++ b/src/game.css @@ -99,3 +99,24 @@ 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; +} + diff --git a/src/useGame/useFood.ts b/src/useGame/useFood.ts index 917566f..da85d09 100755 --- a/src/useGame/useFood.ts +++ b/src/useGame/useFood.ts @@ -1,3 +1,96 @@ -export function useFood() { - return {}; +import { useState, useRef } from "react"; +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([]); + const foodPositionsRef = useRef([]); + + 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; diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 31dfd8a..883ae2c 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useRenderer } from "./useRenderer"; import { useSnake } from "./useSnake"; +import { useFood } from "./useFood"; import { useGameStatus } from "./useGameStatus"; import { useKeyBindings, type UseKeyBindingsType } from "./useKeyBindings"; import { useScore } from "../useScore"; @@ -12,8 +13,10 @@ export function useGame(cols: number, rows: number, platformHook?: unknown) { const snakeManager = useSnake(cols, rows, rendererManager); const { changeDirection } = snakeManager; - const gameManager = useGameStatus(rendererManager, snakeManager); - const { gameStatus, startGame, stopGame } = gameManager; + const foodManager = useFood(cols, rows, rendererManager); + + const gameManager = useGameStatus(rendererManager, snakeManager, foodManager); + const { gameStatus, startGame, stopGame, score } = gameManager; useEffect(() => { renderBoard(cols, rows); @@ -33,7 +36,9 @@ export function useGame(cols: number, rows: number, platformHook?: unknown) { gameStatus, startGame, stopGame, + score, ...snakeManager, + ...foodManager, ...keyBindings, scoreManager, }; diff --git a/src/useGame/useGameStatus.ts b/src/useGame/useGameStatus.ts index ee7262f..ee1ac51 100755 --- a/src/useGame/useGameStatus.ts +++ b/src/useGame/useGameStatus.ts @@ -2,32 +2,42 @@ import { useState, useEffect, useRef } from "react"; import type { GameStatus } from "../types"; import type { UseRendererType } from "./useRenderer"; import type { UseSnakeType } from "./useSnake"; +import type { UseFoodType } from "./useFood"; -const INITIAL_SPEED = 200; +const FRAMES_PER_SECOND = 5; export function useGameStatus( rendererManager: UseRendererType, - snakeManager: UseSnakeType + snakeManager: UseSnakeType, + foodManager: UseFoodType ) { const { containerRef } = rendererManager; - const { initSnake, clearSnake } = snakeManager; + const { initSnake, clearSnake, snakeBodyRef } = snakeManager; + const { spawnFood, clearFood } = foodManager; const [gameStatus, setGameStatus] = useState("waiting"); - const gameLoopRef = useRef | null>(null); + const [score, setScore] = useState(0); + const gameLoopRef = useRef(null); + const lastUpdateRef = useRef(0); const snakeManagerRef = useRef(snakeManager); + const foodManagerRef = useRef(foodManager); snakeManagerRef.current = snakeManager; + foodManagerRef.current = foodManager; const startGame = () => { initSnake(); + spawnFood(snakeBodyRef.current, 3); + setScore(0); setGameStatus("started"); }; const stopGame = () => { if (gameLoopRef.current) { - clearInterval(gameLoopRef.current); + cancelAnimationFrame(gameLoopRef.current); gameLoopRef.current = null; } clearSnake(); + clearFood(); setGameStatus("waiting"); const container = containerRef.current; container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el) => { @@ -39,20 +49,50 @@ export function useGameStatus( console.log("useGameStatus effect - gameStatus:", gameStatus); if (gameStatus === "started") { console.log("Starting game loop"); - gameLoopRef.current = setInterval(() => { - console.log("Game loop tick"); - const result = snakeManagerRef.current.moveSnake(); - console.log("Move result:", result); - - if (result === "wall-collision") { - setGameStatus("game-over"); + + 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"); + 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 () => { console.log("Cleaning up game loop"); if (gameLoopRef.current) { - clearInterval(gameLoopRef.current); + cancelAnimationFrame(gameLoopRef.current); gameLoopRef.current = null; } }; @@ -62,7 +102,7 @@ export function useGameStatus( useEffect(() => { if (gameStatus === "game-over") { if (gameLoopRef.current) { - clearInterval(gameLoopRef.current); + cancelAnimationFrame(gameLoopRef.current); gameLoopRef.current = null; } } @@ -73,6 +113,7 @@ export function useGameStatus( setGameStatus, startGame, stopGame, + score, }; } diff --git a/src/useGame/useSnake.ts b/src/useGame/useSnake.ts index f0554c5..6c30688 100755 --- a/src/useGame/useSnake.ts +++ b/src/useGame/useSnake.ts @@ -21,9 +21,6 @@ export function useSnake( { r: centerR, c: centerC }, { r: centerR + 1, c: centerC }, { r: centerR + 2, c: centerC }, - { r: centerR + 3, c: centerC }, - { r: centerR + 4, c: centerC }, - { r: centerR + 5, c: centerC }, ]; 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; if (body.length === 0) return "continue"; @@ -139,7 +136,9 @@ export function useSnake( } 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); snakeBodyRef.current = newBody;