Last active
September 29, 2024 19:30
-
-
Save vale-c/ddc883f1f9a13793d1d49197294780ff to your computer and use it in GitHub Desktop.
Snake Game
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable react/jsx-no-comment-textnodes */ | |
"use client"; | |
import React, { useState, useCallback, useRef, useEffect } from "react"; | |
import { CardContent } from "@/components/ui/card"; | |
import Link from "next/link"; | |
import { Button } from "@/components/ui/button"; | |
import { | |
RxTriangleLeft, | |
RxTriangleDown, | |
RxTriangleUp, | |
RxTriangleRight, | |
} from "react-icons/rx"; | |
import Bolt from "./bolt"; | |
// Types and Constants | |
type Coordinate = { x: number; y: number }; | |
const GRID_SIZE = { x: 20, y: 25 }; | |
const CELL_WIDTH = 14; | |
const CELL_HEIGHT = CELL_WIDTH * (GRID_SIZE.y / GRID_SIZE.x); | |
const INITIAL_GAME_SPEED = 150; | |
const INITIAL_SNAKE = [{ x: 2, y: 2 }]; | |
const INITIAL_DIRECTION = { x: 1, y: 0 }; | |
const INITIAL_FOOD_COUNT = 10; | |
const SNAKE_COLOR = "#43D9AD"; | |
const FOOD_COLOR = "#43D9AD"; | |
const BACKGROUND_COLOR = "#011627"; | |
// Utility functions | |
const generateFood = (snake: Coordinate[]): Coordinate => { | |
let newFood: Coordinate; | |
do { | |
newFood = { | |
x: Math.floor(Math.random() * GRID_SIZE.x), | |
y: Math.floor(Math.random() * GRID_SIZE.y), | |
}; | |
} while ( | |
snake.some((segment) => segment.x === newFood.x && segment.y === newFood.y) | |
); | |
return newFood; | |
}; | |
const checkCollision = (head: Coordinate, snake: Coordinate[]): boolean => { | |
return ( | |
snake | |
.slice(1) | |
.some((segment) => segment.x === head.x && segment.y === head.y) || | |
head.x < 0 || | |
head.x >= GRID_SIZE.x || | |
head.y < 0 || | |
head.y >= GRID_SIZE.y | |
); | |
}; | |
// Main component | |
export default function SnakeGame() { | |
const canvasRef = useRef<HTMLCanvasElement>(null); | |
const [gameState, setGameState] = useState({ | |
snake: INITIAL_SNAKE, | |
food: null as Coordinate | null, | |
direction: INITIAL_DIRECTION, | |
gameOver: false, | |
foodCount: INITIAL_FOOD_COUNT, | |
gameActive: false, | |
score: 0, | |
}); | |
const animationRef = useRef<number>(); | |
const lastUpdateTimeRef = useRef<number>(0); | |
const gameSpeedRef = useRef<number>(INITIAL_GAME_SPEED); | |
// Game logic | |
const moveSnake = useCallback(() => { | |
if (!gameState.gameActive || !gameState.food) return; | |
setGameState((prevState) => { | |
const newHead = { | |
x: prevState.snake[0].x + prevState.direction.x, | |
y: prevState.snake[0].y + prevState.direction.y, | |
}; | |
if (checkCollision(newHead, prevState.snake)) { | |
return { ...prevState, gameOver: true, gameActive: false }; | |
} | |
const newSnake = [newHead, ...prevState.snake]; | |
let newFood = prevState.food; | |
let newFoodCount = prevState.foodCount; | |
let newScore = prevState.score; | |
if (newHead.x === prevState?.food?.x && newHead.y === prevState.food.y) { | |
newFoodCount--; | |
newScore += 10; | |
gameSpeedRef.current = Math.max(50, INITIAL_GAME_SPEED - newScore); | |
if (newFoodCount === 0) { | |
return { | |
...prevState, | |
snake: newSnake, | |
food: null, | |
foodCount: 0, | |
gameActive: false, | |
score: newScore, | |
}; | |
} else { | |
newFood = generateFood(newSnake); | |
} | |
} else { | |
newSnake.pop(); | |
} | |
return { | |
...prevState, | |
snake: newSnake, | |
food: newFood, | |
foodCount: newFoodCount, | |
score: newScore, | |
}; | |
}); | |
}, [gameState.gameActive, gameState.food]); | |
// Rendering logic | |
const drawGame = useCallback( | |
(timestamp: number) => { | |
const canvas = canvasRef.current; | |
if (!canvas) return; | |
const ctx = canvas.getContext("2d"); | |
if (!ctx) return; | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
const circleRadius = (Math.min(CELL_WIDTH, CELL_HEIGHT) / 2) * 0.8; | |
// Draw snake | |
ctx.fillStyle = SNAKE_COLOR; | |
ctx.strokeStyle = SNAKE_COLOR; | |
gameState.snake.forEach((segment, index) => { | |
const [x, y] = [ | |
segment.x * CELL_WIDTH + CELL_WIDTH / 2, | |
segment.y * CELL_HEIGHT + CELL_HEIGHT / 2, | |
]; | |
if (index === 0) { | |
// Draw head | |
ctx.beginPath(); | |
ctx.arc(x, y, circleRadius, 0, 2 * Math.PI); | |
ctx.fill(); | |
} else { | |
// Draw body segment | |
const [prevX, prevY] = [ | |
gameState.snake[index - 1].x * CELL_WIDTH + CELL_WIDTH / 2, | |
gameState.snake[index - 1].y * CELL_HEIGHT + CELL_HEIGHT / 2, | |
]; | |
ctx.lineWidth = circleRadius * 2; | |
ctx.lineCap = "round"; | |
ctx.beginPath(); | |
ctx.moveTo(prevX, prevY); | |
ctx.lineTo(x, y); | |
ctx.stroke(); | |
} | |
}); | |
// Draw food with pulsing effect | |
if (gameState.food) { | |
const [x, y] = [ | |
gameState.food.x * CELL_WIDTH + CELL_WIDTH / 2, | |
gameState.food.y * CELL_HEIGHT + CELL_HEIGHT / 2, | |
]; | |
const pulseFactor = Math.sin(timestamp * 0.01) * 0.2 + 0.8; | |
ctx.fillStyle = FOOD_COLOR; | |
ctx.beginPath(); | |
ctx.arc(x, y, circleRadius, 0, 2 * Math.PI); | |
ctx.fill(); | |
ctx.strokeStyle = `rgba(67, 217, 173, ${pulseFactor * 0.5})`; | |
ctx.lineWidth = 2; | |
ctx.beginPath(); | |
ctx.arc(x, y, circleRadius + 2, 0, 2 * Math.PI); | |
ctx.stroke(); | |
} | |
if (timestamp - lastUpdateTimeRef.current > gameSpeedRef.current) { | |
moveSnake(); | |
lastUpdateTimeRef.current = timestamp; | |
} | |
animationRef.current = requestAnimationFrame(drawGame); | |
}, | |
[gameState, moveSnake] | |
); | |
useEffect(() => { | |
animationRef.current = requestAnimationFrame(drawGame); | |
return () => { | |
if (animationRef.current) cancelAnimationFrame(animationRef.current); | |
}; | |
}, [drawGame]); | |
const startGame = useCallback((e?: React.MouseEvent) => { | |
if (e) e.preventDefault(); | |
gameSpeedRef.current = INITIAL_GAME_SPEED; | |
setGameState({ | |
snake: INITIAL_SNAKE, | |
food: generateFood(INITIAL_SNAKE), | |
direction: INITIAL_DIRECTION, | |
gameOver: false, | |
foodCount: INITIAL_FOOD_COUNT, | |
gameActive: true, | |
score: 0, | |
}); | |
}, []); | |
const changeDirection = useCallback( | |
(newDirection: Coordinate) => { | |
if (!gameState.gameActive) return; | |
setGameState((prev) => { | |
return newDirection.x === -prev.direction.x && | |
newDirection.y === -prev.direction.y | |
? prev | |
: { ...prev, direction: newDirection }; | |
}); | |
}, | |
[gameState.gameActive] | |
); | |
const handleKeyDown = useCallback( | |
(e: KeyboardEvent) => { | |
if (!gameState.gameActive) return; | |
const newDirection: Record<string, Coordinate> = { | |
ArrowUp: { x: 0, y: -1 }, | |
ArrowDown: { x: 0, y: 1 }, | |
ArrowLeft: { x: -1, y: 0 }, | |
ArrowRight: { x: 1, y: 0 }, | |
}; | |
if (newDirection[e.key]) { | |
e.preventDefault(); // Prevent default behavior, including focus | |
changeDirection(newDirection[e.key]); | |
} | |
}, | |
[gameState.gameActive, changeDirection] | |
); | |
useEffect(() => { | |
window.addEventListener("keydown", handleKeyDown); | |
return () => window.removeEventListener("keydown", handleKeyDown); | |
}, [handleKeyDown]); | |
return ( | |
<div className="w-[510px] h-[475px] from-[#43D9AD] to-[#011627] bg-gradient-to-br rounded-xl overflow-hidden"> | |
<CardContent className="p-5 flex h-full"> | |
{/* Bolt SVGs */} | |
<div className="absolute top-0.5 left-0.5"> | |
<Bolt /> | |
</div> | |
<div className="absolute top-0.5 -right-16"> | |
<Bolt /> | |
</div> | |
<div className="absolute bottom-0.5 left-0.5"> | |
<Bolt /> | |
</div> | |
<div className="absolute bottom-0.5 -right-16"> | |
<Bolt /> | |
</div> | |
<div className="flex-grow relative mr-4"> | |
<div className="w-full h-full bg-[#011627] rounded-lg overflow-hidden shadow-inner"> | |
<canvas | |
ref={canvasRef} | |
width={GRID_SIZE.x * CELL_WIDTH} | |
height={GRID_SIZE.y * CELL_HEIGHT} | |
className="w-full h-full" | |
/> | |
</div> | |
{!gameState.gameActive && | |
(gameState.gameOver || gameState.foodCount === 0) && ( | |
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-[#011627]"> | |
<div className="flex items-center justify-center text-2xl font-semibold h-12 w-full bg-[#ffffff0f] opacity-95 shadow-inner text-[#43D9AD] tracking-wider"> | |
{gameState.gameOver ? "GAME OVER!" : "WELL DONE!"} | |
</div> | |
</div> | |
)} | |
<div className="absolute bottom-4 left-0 right-0 flex justify-center"> | |
<Button onMouseDown={startGame} variant="ghost" size="sm"> | |
{gameState.gameActive ? "restart" : "start-game"} | |
</Button> | |
</div> | |
</div> | |
<div className="flex flex-col justify-between w-1/3"> | |
<div className="bg-[#0114233e] rounded-lg p-3"> | |
<div className="text-xs text-white mb-2"> | |
// use the arrows | |
<br /> | |
// to play | |
</div> | |
<div className="grid grid-cols-3 gap-2 justify-items-center mb-2"> | |
<div /> | |
<button | |
onClick={() => changeDirection({ x: 0, y: -1 })} | |
className="h-8 w-8 p-0 flex items-center justify-center text-white bg-[#011627] rounded-md hover:bg-gray-800" | |
> | |
<RxTriangleUp className="w-4 h-4" /> | |
</button> | |
<div /> | |
<button | |
onClick={() => changeDirection({ x: -1, y: 0 })} | |
className="h-8 w-8 p-0 flex items-center justify-center text-white bg-[#011627] rounded-md hover:bg-gray-800" | |
> | |
<RxTriangleLeft className="w-4 h-4" /> | |
</button> | |
<button | |
onClick={() => changeDirection({ x: 0, y: 1 })} | |
className="h-8 w-8 p-0 flex items-center justify-center text-white bg-[#011627] rounded-md hover:bg-gray-800" | |
> | |
<RxTriangleDown className="w-4 h-4" /> | |
</button> | |
<button | |
onClick={() => changeDirection({ x: 1, y: 0 })} | |
className="h-8 w-8 p-0 flex items-center justify-center text-white bg-[#011627] rounded-md hover:bg-gray-800" | |
> | |
<RxTriangleRight className="w-4 h-4" /> | |
</button> | |
</div> | |
<div className="text-xs text-white mt-4"> | |
// food left: {gameState.foodCount} | |
</div> | |
<div className="flex justify-between mt-2"> | |
{Array.from({ length: 10 }).map((_, index) => ( | |
<div | |
key={index} | |
className={`rounded-full w-2 h-2 ${ | |
index < gameState.foodCount | |
? "bg-[#43D9AD] shadow-[0_0_8px_3px_rgba(67,217,173,0.6)]" | |
: "bg-[#1E2D3D]" | |
} animate-pulse`} | |
/> | |
))} | |
</div> | |
<div className="text-xs text-white mt-4"> | |
// score: {gameState.score} | |
</div> | |
</div> | |
<div className="flex justify-end pb-4 pr-3"> | |
<Button | |
asChild | |
className="bg-transparent text-white dark:bg-transparent dark:text-white border-white dark:border-white hover:bg-white hover:text-black dark:hover:bg-black dark:hover:text-white" | |
variant="outline" | |
> | |
<Link href="/about-me">skip</Link> | |
</Button> | |
</div> | |
</div> | |
</CardContent> | |
</div> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment