From 6a3cfad4098b04d89a44ef5c843e27c8221d9e4e Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Thu, 16 Oct 2025 12:56:15 +0100 Subject: [PATCH] game start/stop, snake rendered on board --- example/src/App.tsx | 7 ++- example/src/useKeyBindings.ts | 17 ++++---- src/game.css | 16 +++++++ src/useGame/useGame.ts | 24 ++++++++++- src/useGame/useGameStatus.ts | 36 ++++++++++++++++ src/useGame/useKeyBindings.ts | 48 ++++++++++++++++++++- src/useGame/useSnake.ts | 81 ++++++++++++++++++++++++++++++++++- 7 files changed, 216 insertions(+), 13 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index 4d25c6a..d5f92e1 100755 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,12 +3,17 @@ import "@vimazing/vim-snake/game.css"; import { useKeyBindings } from "./useKeyBindings"; function App() { - const { containerRef } = useGame(30, 20, useKeyBindings); + const { containerRef, gameStatus } = useGame(30, 20, useKeyBindings); return (

VIMazing Snake

+
+ {gameStatus === "waiting" &&

Press space to start

} + {gameStatus === "started" &&

Use hjkl to move • Press q to quit

} + {gameStatus === "game-over" &&

Game Over! Press space to restart

} +
); } diff --git a/example/src/useKeyBindings.ts b/example/src/useKeyBindings.ts index 91e6895..60bbc65 100755 --- a/example/src/useKeyBindings.ts +++ b/example/src/useKeyBindings.ts @@ -1,21 +1,22 @@ import { useEffect } from "react"; -import type { UseGameType } from "@vimazing/vim-maze"; +import type { UseGameType } from "@vimazing/vim-snake"; export const useKeyBindings = (gameManager: UseGameType) => { - const { gameStatus, initGame, stopGame, clearLog, resetCount } = gameManager; + const { gameStatus, startGame, stopGame, clearLog, resetCount } = gameManager; useEffect(() => { const handler = (e: KeyboardEvent) => { + console.log("key pressed:", e.key, e.code); // start game - if (e.key === "i" || e.key === "I") { + if (e.code === "Space") { if (["waiting", "game-over", "game-won"].includes(gameStatus)) { - clearLog(); - resetCount(); + // clearLog(); + // resetCount(); if (gameStatus === "game-over" || gameStatus === "game-won") { stopGame(); - setTimeout(() => initGame(true), 0); + // setTimeout(() => initGame(true), 0); } else { - initGame(true); + startGame(); } return; } @@ -41,6 +42,6 @@ export const useKeyBindings = (gameManager: UseGameType) => { window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [gameStatus, initGame, stopGame, clearLog, resetCount]); + }, [gameStatus, startGame, stopGame, clearLog, resetCount]); }; diff --git a/src/game.css b/src/game.css index 5ebff21..78d6c4d 100755 --- a/src/game.css +++ b/src/game.css @@ -29,5 +29,21 @@ background-color: var(--muted, #2f334d); border: 1px solid var(--border, #3b3f5c); box-sizing: border-box; + transition: background-color 0.1s ease; +} + +/* ========================================================= + Snake Rendering + ========================================================= */ +.snake-cell.snake-head { + background-color: var(--primary, #82aaff); + border-radius: 30%; + box-shadow: 0 0 8px color-mix(in srgb, var(--primary, #82aaff) 70%, transparent); +} + +.snake-cell.snake-body { + background-color: var(--primary, #82aaff); + opacity: 0.7; + box-shadow: inset 0 0 3px color-mix(in srgb, var(--primary, #82aaff) 50%, transparent); } diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index e0ad4c7..56fba49 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -1,17 +1,39 @@ import { useEffect } from "react"; import { useRenderer } from "./useRenderer"; +import { useSnake } from "./useSnake"; +import { useGameStatus } from "./useGameStatus"; +import { useKeyBindings, type UseKeyBindingsType } from "./useKeyBindings"; export function useGame(cols: number, rows: number, platformHook?: unknown) { const rendererManager = useRenderer(); const { containerRef, renderBoard } = rendererManager; + const snakeManager = useSnake(cols, rows, rendererManager); + const { changeDirection } = snakeManager; + + const gameManager = useGameStatus(rendererManager, snakeManager); + const { gameStatus, startGame, stopGame } = gameManager; + useEffect(() => { renderBoard(cols, rows); }, [cols, rows]); + const keyBindings: UseKeyBindingsType = useKeyBindings({ + gameManager: { + gameStatus, + startGame, + stopGame, + changeDirection, + }, + }); + const fullGameManager = { containerRef, - renderBoard, + gameStatus, + startGame, + stopGame, + ...snakeManager, + ...keyBindings, }; if (typeof platformHook === "function") { diff --git a/src/useGame/useGameStatus.ts b/src/useGame/useGameStatus.ts index 76e0b6b..1398730 100755 --- a/src/useGame/useGameStatus.ts +++ b/src/useGame/useGameStatus.ts @@ -1 +1,37 @@ +import { useState } from "react"; +import type { GameStatus } from "../types"; +import type { UseRendererType } from "./useRenderer"; +import type { UseSnakeType } from "./useSnake"; + +export function useGameStatus( + rendererManager: UseRendererType, + snakeManager: UseSnakeType +) { + const { containerRef } = rendererManager; + const { initSnake, clearSnake } = snakeManager; + const [gameStatus, setGameStatus] = useState("waiting"); + + const startGame = () => { + initSnake(); + setGameStatus("started"); + }; + + const stopGame = () => { + clearSnake(); + setGameStatus("waiting"); + const container = containerRef.current; + container?.querySelectorAll(".snake-head, .snake-body").forEach((el) => { + el.classList.remove("snake-head", "snake-body"); + }); + }; + + return { + gameStatus, + setGameStatus, + startGame, + stopGame, + }; +} + +export type UseGameStatusType = ReturnType; export type { GameStatus } from "../types"; diff --git a/src/useGame/useKeyBindings.ts b/src/useGame/useKeyBindings.ts index a92bf7d..5f75d14 100755 --- a/src/useGame/useKeyBindings.ts +++ b/src/useGame/useKeyBindings.ts @@ -1,3 +1,49 @@ -export function useKeyBindings() { +import { useEffect } from "react"; +import type { GameStatus, Direction } from "../types"; + +export type GameBindingContext = { + gameStatus: GameStatus; + startGame: () => void; + stopGame: () => void; + changeDirection: (dir: Direction) => void; +}; + +type UseKeyBindingsParams = { + gameManager: GameBindingContext; +}; + +export function useKeyBindings({ gameManager }: UseKeyBindingsParams) { + const { gameStatus, startGame, stopGame, changeDirection } = gameManager; + + useEffect(() => { + const handler = (ev: KeyboardEvent) => { + if (!["started"].includes(gameStatus)) return; + + switch (ev.key) { + case "h": + case "H": + changeDirection("left"); + break; + case "j": + case "J": + changeDirection("down"); + break; + case "k": + case "K": + changeDirection("up"); + break; + case "l": + case "L": + changeDirection("right"); + break; + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [gameStatus, startGame, stopGame, changeDirection]); + return {}; } + +export type UseKeyBindingsType = ReturnType; diff --git a/src/useGame/useSnake.ts b/src/useGame/useSnake.ts index 246cb4a..d4d5fae 100755 --- a/src/useGame/useSnake.ts +++ b/src/useGame/useSnake.ts @@ -1,3 +1,80 @@ -export function useSnake() { - return {}; +import { useRef, useState } from "react"; +import type { Direction, SnakeBody } from "../types"; +import type { UseRendererType } from "./useRenderer"; + +export function useSnake( + cols: number, + rows: number, + rendererManager: UseRendererType +) { + const { containerRef } = rendererManager; + const [snakeBody, setSnakeBody] = useState([]); + const [direction, setDirection] = useState("up"); + const directionRef = useRef("up"); + + const initSnake = () => { + const centerR = Math.floor(rows / 2); + const centerC = Math.floor(cols / 2); + + const initialBody: SnakeBody = [ + { r: centerR, c: centerC }, + { r: centerR + 1, c: centerC }, + { r: centerR + 2, c: centerC }, + ]; + + setSnakeBody(initialBody); + setDirection("up"); + directionRef.current = "up"; + renderSnake(initialBody); + }; + + const clearSnake = () => { + setSnakeBody([]); + setDirection("up"); + directionRef.current = "up"; + }; + + const renderSnake = (body: SnakeBody) => { + const container = containerRef.current; + if (!container) return; + + container.querySelectorAll(".snake-head, .snake-body").forEach((el) => { + el.classList.remove("snake-head", "snake-body"); + }); + + body.forEach((segment, idx) => { + const cell = container.querySelector( + `.snake-cell[data-r="${segment.r}"][data-c="${segment.c}"]` + ); + if (cell) { + cell.classList.add(idx === 0 ? "snake-head" : "snake-body"); + } + }); + }; + + const changeDirection = (newDirection: Direction) => { + const opposites: Record = { + up: "down", + down: "up", + left: "right", + right: "left", + }; + + if (opposites[directionRef.current] !== newDirection) { + setDirection(newDirection); + directionRef.current = newDirection; + } + }; + + return { + snakeBody, + direction, + directionRef, + initSnake, + clearSnake, + changeDirection, + renderSnake, + }; } + +export type UseSnakeType = ReturnType;