game start/stop, snake rendered on board

This commit is contained in:
2025-10-16 12:56:15 +01:00
parent 39dde6e5e6
commit 6a3cfad409
7 changed files with 216 additions and 13 deletions
+6 -1
View File
@@ -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 (
<div className="relative mx-auto my-4 w-fit space-y-4">
<h1 className="text-2xl font-bold text-center">VIMazing Snake</h1>
<div ref={containerRef} className="relative" />
<div className="text-center text-sm text-muted-foreground">
{gameStatus === "waiting" && <p>Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to start</p>}
{gameStatus === "started" && <p>Use <kbd className="px-2 py-1 bg-muted rounded">hjkl</kbd> to move • Press <kbd className="px-2 py-1 bg-muted rounded">q</kbd> to quit</p>}
{gameStatus === "game-over" && <p>Game Over! Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to restart</p>}
</div>
</div>
);
}
+9 -8
View File
@@ -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]);
};
+16
View File
@@ -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);
}
+23 -1
View File
@@ -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") {
+36
View File
@@ -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<GameStatus>("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<typeof useGameStatus>;
export type { GameStatus } from "../types";
+47 -1
View File
@@ -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<typeof useKeyBindings>;
+79 -2
View File
@@ -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<SnakeBody>([]);
const [direction, setDirection] = useState<Direction>("up");
const directionRef = useRef<Direction>("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<Direction, Direction> = {
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<typeof useSnake>;