Skip to content

Instantly share code, notes, and snippets.

@vale-c
Last active September 29, 2024 19:30
Show Gist options
  • Save vale-c/ddc883f1f9a13793d1d49197294780ff to your computer and use it in GitHub Desktop.
Save vale-c/ddc883f1f9a13793d1d49197294780ff to your computer and use it in GitHub Desktop.
Snake Game
/* 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