diff --git a/src/index.ts b/src/index.ts index b677a0a..67d39c9 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ export * from './useGame'; +export * from './useCursor'; +export * from './useGameStatus'; +export * from './useBoard'; +export * from './useScore'; export * from './types'; diff --git a/src/useBoard/index.ts b/src/useBoard/index.ts new file mode 100644 index 0000000..f381252 --- /dev/null +++ b/src/useBoard/index.ts @@ -0,0 +1 @@ +export * from './useBoard'; diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts new file mode 100644 index 0000000..a111752 --- /dev/null +++ b/src/useBoard/useBoard.ts @@ -0,0 +1,46 @@ +import { useRef } from "react"; + +export type BoardData = { + cols: number; + rows: number; +}; + +export function useBoard() { + const containerRef = useRef(null); + + const renderBoard = (cols: number, rows: number) => { + const container = containerRef.current; + if (!container) return; + + container.innerHTML = ""; + + const board = document.createElement("div"); + board.id = "snake-board"; + board.className = "snake-board"; + + for (let r = 0; r < rows; r++) { + const rowDiv = document.createElement("div"); + rowDiv.className = "snake-row"; + rowDiv.setAttribute("data-r", String(r)); + + for (let c = 0; c < cols; c++) { + const cellDiv = document.createElement("div"); + cellDiv.className = "snake-cell"; + cellDiv.setAttribute("data-r", String(r)); + cellDiv.setAttribute("data-c", String(c)); + rowDiv.appendChild(cellDiv); + } + + board.appendChild(rowDiv); + } + + container.appendChild(board); + }; + + return { + containerRef, + renderBoard, + }; +} + +export type UseBoardType = ReturnType; diff --git a/src/useCursor/index.ts b/src/useCursor/index.ts new file mode 100644 index 0000000..31ce475 --- /dev/null +++ b/src/useCursor/index.ts @@ -0,0 +1 @@ +export * from './useCursor'; diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts new file mode 100755 index 0000000..eb8127c --- /dev/null +++ b/src/useCursor/useCursor.ts @@ -0,0 +1,179 @@ +import { useRef, useState } from "react"; +import type { Direction, SnakeBody } from "../types"; +import type { UseBoardType } from "../useBoard"; + +export function useCursor( + cols: number, + rows: number, + boardManager: UseBoardType +) { + const { containerRef } = boardManager; + const [snakeBody, setSnakeBody] = useState([]); + const snakeBodyRef = useRef([]); + const [direction, setDirection] = useState("up"); + const directionRef = useRef("up"); + const lastMovedDirectionRef = 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); + snakeBodyRef.current = initialBody; + setDirection("up"); + directionRef.current = "up"; + lastMovedDirectionRef.current = "up"; + renderSnake(initialBody); + }; + + const clearSnake = () => { + setSnakeBody([]); + snakeBodyRef.current = []; + setDirection("up"); + directionRef.current = "up"; + lastMovedDirectionRef.current = "up"; + }; + + const renderSnake = (body: SnakeBody) => { + const container = containerRef.current; + if (!container) return; + + const dir = directionRef.current; + const newPositions = new Set(); + + // First, apply new positions and track them + body.forEach((segment, idx) => { + const posKey = `${segment.r},${segment.c}`; + newPositions.add(posKey); + + const cell = container.querySelector( + `.snake-cell[data-r="${segment.r}"][data-c="${segment.c}"]` + ); + if (cell) { + cell.className = "snake-cell"; + if (idx === 0) { + cell.classList.add("snake-head", `dir-${dir}`); + } else if (idx === body.length - 1) { + const tailDir = getTailDirection(body); + cell.classList.add("snake-tail", `dir-${tailDir}`); + } else { + cell.classList.add("snake-body"); + } + } + }); + + // Then clear old positions not in the new body + const allSnakeCells = container.querySelectorAll(".snake-head, .snake-body, .snake-tail"); + allSnakeCells.forEach((el: Element) => { + const r = el.getAttribute("data-r"); + const c = el.getAttribute("data-c"); + const posKey = `${r},${c}`; + if (!newPositions.has(posKey)) { + (el as HTMLElement).className = "snake-cell"; + } + }); + }; + + const getTailDirection = (body: SnakeBody): Direction => { + if (body.length < 2) return directionRef.current; + + const tail = body[body.length - 1]; + const beforeTail = body[body.length - 2]; + + // Tail points away from beforeTail + if (tail.r < beforeTail.r) return "up"; + if (tail.r > beforeTail.r) return "down"; + if (tail.c < beforeTail.c) return "left"; + if (tail.c > beforeTail.c) return "right"; + + return directionRef.current; + }; + + const changeDirection = (newDirection: Direction) => { + const opposites: Record = { + up: "down", + down: "up", + left: "right", + right: "left", + }; + + if (opposites[lastMovedDirectionRef.current] !== newDirection) { + setDirection(newDirection); + directionRef.current = newDirection; + } + }; + + const moveSnake = (grow: boolean = false): "continue" | "wall-collision" | "self-collision" => { + const body = snakeBodyRef.current; + if (body.length === 0) return "continue"; + + const head = body[0]; + const dir = directionRef.current; + + let newR = head.r; + let newC = head.c; + + switch (dir) { + case "up": + newR -= 1; + break; + case "down": + newR += 1; + break; + case "left": + newC -= 1; + break; + case "right": + newC += 1; + break; + } + + if (newR < 0 || newR >= rows || newC < 0 || newC >= cols) { + return "wall-collision"; + } + + const newHead = { r: newR, c: newC }; + + // Check self-collision (excluding tail if not growing, as tail will move) + const bodyToCheck = grow ? body : body.slice(0, -1); + const collision = bodyToCheck.some( + (segment) => segment.r === newHead.r && segment.c === newHead.c + ); + + if (collision) { + return "self-collision"; + } + + const newBody = grow + ? [newHead, ...body] + : [newHead, ...body.slice(0, -1)]; + + lastMovedDirectionRef.current = dir; + + setSnakeBody(newBody); + snakeBodyRef.current = newBody; + renderSnake(newBody); + + return "continue"; + }; + + return { + snakeBody, + snakeBodyRef, + direction, + directionRef, + initSnake, + clearSnake, + changeDirection, + moveSnake, + renderSnake, + }; +} + +export type UseCursorType = ReturnType; diff --git a/src/useGame/index.ts b/src/useGame/index.ts index 5b2f75f..1163755 100755 --- a/src/useGame/index.ts +++ b/src/useGame/index.ts @@ -1,2 +1 @@ export * from './useGame'; -export * from './useKeyBindings'; diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 424fdf6..82fd20c 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -1,21 +1,21 @@ import { useEffect } from "react"; -import { useRenderer } from "./useRenderer"; -import { useSnake } from "./useSnake"; +import { useBoard } from "../useBoard"; +import { useCursor } from "../useCursor"; import { useFood } from "./useFood"; -import { useGameStatus } from "./useGameStatus"; +import { useGameStatus } from "../useGameStatus"; import { useKeyBindings, type UseKeyBindingsType } from "./useKeyBindings"; import { useScore } from "../useScore"; export function useGame(cols: number, rows: number, platformHook?: unknown) { - const rendererManager = useRenderer(); - const { containerRef, renderBoard } = rendererManager; + const boardManager = useBoard(); + const { containerRef, renderBoard } = boardManager; - const snakeManager = useSnake(cols, rows, rendererManager); + const snakeManager = useCursor(cols, rows, boardManager); const { changeDirection } = snakeManager; - const foodManager = useFood(cols, rows, rendererManager); + const foodManager = useFood(cols, rows, boardManager); - const gameManager = useGameStatus(rendererManager, snakeManager, foodManager); + const gameManager = useGameStatus(boardManager, snakeManager, foodManager); const { gameStatus, startGame, stopGame, togglePause, score, level } = gameManager; useEffect(() => { diff --git a/src/useGameStatus/index.ts b/src/useGameStatus/index.ts new file mode 100644 index 0000000..087da7f --- /dev/null +++ b/src/useGameStatus/index.ts @@ -0,0 +1 @@ +export * from './useGameStatus'; diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts new file mode 100755 index 0000000..1476a36 --- /dev/null +++ b/src/useGameStatus/useGameStatus.ts @@ -0,0 +1,169 @@ +import { useState, useEffect, useRef } from "react"; +import type { GameStatus } from "../types"; +import type { UseBoardType } from "../useBoard"; +import type { UseCursorType } from "../useCursor"; +import type { UseFoodType } from "../useGame/useFood"; + +const INITIAL_FPS = 5; +const FOODS_PER_LEVEL = 2; + +export function useGameStatus( + boardManager: UseBoardType, + snakeManager: UseCursorType, + foodManager: UseFoodType +) { + const { containerRef } = boardManager; + const { initSnake, clearSnake, snakeBodyRef } = snakeManager; + const { spawnFood, clearFood } = foodManager; + const [gameStatus, setGameStatus] = useState("waiting"); + const [score, setScore] = useState(0); + const [level, setLevel] = useState(1); + const [paused, setPaused] = useState(false); + const levelRef = useRef(1); + const foodsEatenRef = useRef(0); + const gameLoopRef = useRef(null); + const lastUpdateRef = useRef(0); + const snakeManagerRef = useRef(snakeManager); + const foodManagerRef = useRef(foodManager); + const currentFpsRef = useRef(INITIAL_FPS); + const collisionGracePeriodRef = useRef(false); + + snakeManagerRef.current = snakeManager; + foodManagerRef.current = foodManager; + + const startGame = () => { + initSnake(); + spawnFood(snakeBodyRef.current, 3); + setScore(0); + setLevel(1); + levelRef.current = 1; + foodsEatenRef.current = 0; + currentFpsRef.current = INITIAL_FPS; + collisionGracePeriodRef.current = false; + setGameStatus("started"); + }; + + const stopGame = () => { + if (gameLoopRef.current) { + cancelAnimationFrame(gameLoopRef.current); + gameLoopRef.current = null; + } + clearSnake(); + clearFood(); + setGameStatus("waiting"); + const container = containerRef.current; + container?.querySelectorAll(".snake-head, .snake-body, .snake-tail, .collision").forEach((el: Element) => { + (el as HTMLElement).classList.remove("snake-head", "snake-body", "snake-tail", "collision"); + }); + }; + + useEffect(() => { + console.log("useGameStatus effect - gameStatus:", gameStatus); + if (gameStatus === "started") { + console.log("Starting game loop"); + if (paused) return; + let shouldGrow = false; + lastUpdateRef.current = performance.now(); + + const gameLoop = (timestamp: number) => { + const msPerFrame = 1000 / currentFpsRef.current; + 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" || result === "self-collision") { + if (collisionGracePeriodRef.current) { + const container = containerRef.current; + container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el: Element) => { + (el as HTMLElement).classList.add("collision"); + }); + setGameStatus("game-over"); + return; + } else { + collisionGracePeriodRef.current = true; + lastUpdateRef.current = timestamp; + gameLoopRef.current = requestAnimationFrame(gameLoop); + return; + } + } + + collisionGracePeriodRef.current = false; + + const head = snakeManagerRef.current.snakeBodyRef.current[0]; + if (head && foodManagerRef.current.checkFoodCollision(head)) { + console.log("Food eaten!"); + foodManagerRef.current.removeFood(head); + shouldGrow = true; + + foodsEatenRef.current += 1; + + setScore((prev) => { + let newScore = prev + levelRef.current; + console.log('newscore:', newScore, 'level:'); + if (foodsEatenRef.current >= FOODS_PER_LEVEL) { + newScore--; + console.log('newScore after decrement:', newScore); + const newLevel = levelRef.current + 1; + levelRef.current = newLevel; + setLevel(newLevel); + currentFpsRef.current = INITIAL_FPS + (newLevel - 1); + console.log(`Level up! Level ${newLevel}, FPS: ${currentFpsRef.current}`); + foodsEatenRef.current = 0; + } + + foodManagerRef.current.spawnFood( + snakeManagerRef.current.snakeBodyRef.current, + 1 + ); + console.log('Final newScore to be set:', newScore); + return newScore; + }); + + } + + lastUpdateRef.current = timestamp; + } + + gameLoopRef.current = requestAnimationFrame(gameLoop); + }; + + gameLoopRef.current = requestAnimationFrame(gameLoop); + + return () => { + console.log("Cleaning up game loop"); + if (gameLoopRef.current) { + cancelAnimationFrame(gameLoopRef.current); + gameLoopRef.current = null; + } + }; + } + }, [gameStatus, paused]); + + useEffect(() => { + if (gameStatus === "game-over") { + if (gameLoopRef.current) { + cancelAnimationFrame(gameLoopRef.current); + gameLoopRef.current = null; + } + } + }, [gameStatus]); + + return { + gameStatus, + setGameStatus, + startGame, + stopGame, + paused, + togglePause: () => setPaused(curr => !curr), + score, + level, + }; +} + +export type UseGameStatusType = ReturnType; +export type { GameStatus } from "../types";