personal_website/src/pages/Snake.tsx
gpt-engineer-app[bot] 1d011db6f8 Changes
2025-12-05 19:29:02 +00:00

356 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext';
import { Link } from 'react-router-dom';
import GlitchCrash from '@/components/GlitchCrash';
import { Maximize2, Minimize2 } from 'lucide-react';
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import GameTouchButton from '@/components/GameTouchButton';
const GRID_SIZE = 20;
const TICK_SPEED = 120;
const MAX_SCORE = 4294967296;
const HIGHSCORE_KEY = 'snake-highscore';
type Direction = 'up' | 'down' | 'left' | 'right';
type Position = { x: number; y: number };
const Snake = () => {
const { playSound } = useSettings();
const [snake, setSnake] = useState<Position[]>([{ x: 10, y: 10 }]);
const [direction, setDirection] = useState<Direction>('right');
const [food, setFood] = useState<Position>({ x: 15, y: 10 });
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [gameOver, setGameOver] = useState(false);
const [gameComplete, setGameComplete] = useState(false);
const [gameStarted, setGameStarted] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showGlitchCrash, setShowGlitchCrash] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const gameRef = useRef<HTMLDivElement>(null);
const directionQueueRef = useRef<Direction[]>([]);
const currentDirectionRef = useRef<Direction>('right');
const { enterFullscreen, exitFullscreen } = useBrowserFullscreen();
// Calculate cell size based on screen
const getCellSize = useCallback(() => {
if (typeof window === 'undefined') return 24;
const isMobile = window.innerWidth < 768;
if (isMobile) {
const maxWidth = window.innerWidth - 32;
const maxHeight = window.innerHeight - 260;
return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE), 16);
}
return isFullscreen ? 28 : 24;
}, [isFullscreen]);
const [cellSize, setCellSize] = useState(getCellSize);
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
useEffect(() => {
const handleResize = () => setCellSize(getCellSize());
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [getCellSize]);
useEffect(() => {
setCellSize(getCellSize());
}, [isFullscreen, getCellSize]);
// Auto-fullscreen on mobile
useEffect(() => {
if (gameStarted && isMobile && !isFullscreen) {
setIsFullscreen(true);
enterFullscreen();
}
}, [gameStarted, isMobile, isFullscreen, enterFullscreen]);
const toggleFullscreen = async () => {
if (!isFullscreen) {
setIsFullscreen(true);
await enterFullscreen();
} else {
setIsFullscreen(false);
await exitFullscreen();
}
playSound('click');
};
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) {
setIsFullscreen(false);
exitFullscreen();
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [isFullscreen, exitFullscreen]);
useEffect(() => {
const saved = localStorage.getItem(HIGHSCORE_KEY);
if (saved) setHighScore(Math.min(parseInt(saved, 10), MAX_SCORE));
}, []);
const spawnFood = useCallback((currentSnake: Position[]): Position | null => {
const occupied = new Set(currentSnake.map(s => `${s.x},${s.y}`));
const available: Position[] = [];
for (let x = 0; x < GRID_SIZE; x++) {
for (let y = 0; y < GRID_SIZE; y++) {
if (!occupied.has(`${x},${y}`)) available.push({ x, y });
}
}
if (available.length === 0) return null;
return available[Math.floor(Math.random() * available.length)];
}, []);
const resetSnakeKeepScore = useCallback(() => {
const initialSnake = [{ x: 10, y: 10 }];
setSnake(initialSnake);
setDirection('right');
currentDirectionRef.current = 'right';
directionQueueRef.current = [];
setFood(spawnFood(initialSnake)!);
playSound('success');
}, [spawnFood, playSound]);
const startGame = () => {
const initialSnake = [{ x: 10, y: 10 }];
setSnake(initialSnake);
setDirection('right');
currentDirectionRef.current = 'right';
directionQueueRef.current = [];
setFood(spawnFood(initialSnake)!);
setScore(0);
setGameOver(false);
setGameComplete(false);
setIsPaused(false);
setGameStarted(true);
playSound('success');
gameRef.current?.focus();
};
useEffect(() => {
if (!gameStarted || gameOver || gameComplete || isPaused) return;
const interval = setInterval(() => {
const opposite: Record<Direction, Direction> = { up: 'down', down: 'up', left: 'right', right: 'left' };
let nextDir = currentDirectionRef.current;
while (directionQueueRef.current.length > 0) {
const queuedDir = directionQueueRef.current.shift()!;
if (queuedDir !== opposite[currentDirectionRef.current]) {
nextDir = queuedDir;
break;
}
}
currentDirectionRef.current = nextDir;
setDirection(nextDir);
setSnake(prev => {
const head = prev[0];
let newHead: Position;
switch (nextDir) {
case 'up': newHead = { x: head.x, y: head.y - 1 }; break;
case 'down': newHead = { x: head.x, y: head.y + 1 }; break;
case 'left': newHead = { x: head.x - 1, y: head.y }; break;
case 'right': newHead = { x: head.x + 1, y: head.y }; break;
}
if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) {
setGameOver(true);
playSound('error');
return prev;
}
if (prev.some(s => s.x === newHead.x && s.y === newHead.y)) {
setGameOver(true);
playSound('error');
return prev;
}
const newSnake = [newHead, ...prev];
if (newHead.x === food.x && newHead.y === food.y) {
playSound('success');
setScore(s => {
const newScore = Math.min(s + 10, MAX_SCORE);
if (newScore >= MAX_SCORE) setShowGlitchCrash(true);
if (newScore > highScore) {
setHighScore(newScore);
localStorage.setItem(HIGHSCORE_KEY, newScore.toString());
}
return newScore;
});
const newFood = spawnFood(newSnake);
if (newFood === null) setTimeout(() => resetSnakeKeepScore(), 500);
else setFood(newFood);
return newSnake;
}
newSnake.pop();
return newSnake;
});
}, TICK_SPEED);
return () => clearInterval(interval);
}, [gameStarted, gameOver, gameComplete, isPaused, food, highScore, playSound, spawnFood, resetSnakeKeepScore]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!gameStarted) return;
let newDir: Direction | null = null;
switch (e.key) {
case 'ArrowUp': case 'w': e.preventDefault(); newDir = 'up'; break;
case 'ArrowDown': case 's': e.preventDefault(); newDir = 'down'; break;
case 'ArrowLeft': case 'a': e.preventDefault(); newDir = 'left'; break;
case 'ArrowRight': case 'd': e.preventDefault(); newDir = 'right'; break;
case 'p': e.preventDefault(); if (!gameOver && !gameComplete) setIsPaused(p => !p); return;
}
if (newDir && directionQueueRef.current.length < 3) directionQueueRef.current.push(newDir);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [gameStarted, gameOver, gameComplete]);
const renderGrid = () => {
const cells = [];
const snakeSet = new Set(snake.map(s => `${s.x},${s.y}`));
const head = snake[0];
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
const isSnake = snakeSet.has(`${x},${y}`);
const isHead = head.x === x && head.y === y;
const isFood = food.x === x && food.y === y;
cells.push(
<div
key={`${x}-${y}`}
className={`flex items-center justify-center border transition-colors duration-75 ${
isHead ? 'bg-primary box-glow border-primary'
: isSnake ? 'bg-primary/80 border-primary/60'
: isFood ? 'bg-destructive/80 border-destructive/60'
: 'bg-background/50 border-primary/10'
}`}
style={{ width: cellSize, height: cellSize }}
>
{isFood && <div className="bg-destructive rounded-sm animate-pulse" style={{ width: cellSize * 0.5, height: cellSize * 0.5 }} />}
</div>
);
}
}
return cells;
};
if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />;
return (
<motion.div
ref={gameRef}
tabIndex={0}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-50 bg-background p-4' : 'h-full'}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4">
<Link to="/games" className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</Link>
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">Snake</h1>
</div>
<button onClick={toggleFullscreen} className="p-2 border border-primary/50 hover:bg-primary/20 transition-colors" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
{isFullscreen ? <Minimize2 size={16} className="text-primary" /> : <Maximize2 size={16} className="text-primary" />}
</button>
</div>
{/* Main layout */}
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4'} flex-1 ${isFullscreen ? 'justify-center items-center' : ''}`}>
{/* Game Grid */}
<div className="border-2 border-primary box-glow p-1 bg-background/80">
<div className="grid" style={{ gridTemplateColumns: `repeat(${GRID_SIZE}, ${cellSize}px)` }}>{renderGrid()}</div>
</div>
{/* Side Panel */}
{!isMobile && (
<div className="flex flex-col gap-2 min-w-[140px]">
<div className="border border-primary/50 p-3 bg-background/50">
<p className="font-pixel text-[10px] text-foreground/60">SCORE</p>
<p className="font-minecraft text-xl text-primary text-glow">{score.toLocaleString()}</p>
</div>
<div className="border border-primary/50 p-3 bg-background/50">
<p className="font-pixel text-[10px] text-foreground/60">HIGH SCORE</p>
<p className="font-minecraft text-lg text-primary text-glow">{highScore.toLocaleString()}</p>
<p className="font-pixel text-[8px] text-foreground/30">max: 4,294,967,296</p>
</div>
<div className="border border-primary/50 p-3 bg-background/50">
<p className="font-pixel text-[10px] text-foreground/60">LENGTH</p>
<p className="font-minecraft text-lg text-primary text-glow">{snake.length}</p>
</div>
<div className="border border-primary/50 p-3 bg-background/50">
<p className="font-pixel text-[10px] text-foreground/60 mb-1">CONTROLS</p>
<p className="font-pixel text-[10px] text-foreground/80"> / WASD</p>
<p className="font-pixel text-[10px] text-foreground/80">P: Pause</p>
</div>
{!gameStarted || gameOver || gameComplete ? (
<button onClick={startGame} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
{gameComplete ? 'PERFECT!' : gameOver ? 'RETRY' : 'START'}
</button>
) : (
<button onClick={() => setIsPaused(p => !p)} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all">
{isPaused ? 'RESUME' : 'PAUSE'}
</button>
)}
</div>
)}
{/* Mobile Controls */}
{isMobile && (
<div className="mt-2 flex flex-col items-center gap-1 w-full flex-shrink-0">
<div className="flex gap-4 text-center">
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-primary">{score.toLocaleString()}</p></div>
<div><p className="font-pixel text-[8px] text-foreground/60">HIGH</p><p className="font-minecraft text-sm text-primary">{highScore.toLocaleString()}</p></div>
<div><p className="font-pixel text-[8px] text-foreground/60">LEN</p><p className="font-minecraft text-sm text-primary">{snake.length}</p></div>
</div>
{gameStarted && !gameOver && !gameComplete && (
<div className="grid grid-cols-3 gap-1 mt-1">
<div />
<GameTouchButton onAction={() => directionQueueRef.current.push('up')} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg"></GameTouchButton>
<div />
<GameTouchButton onAction={() => directionQueueRef.current.push('left')} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg"></GameTouchButton>
<button onClick={() => setIsPaused(p => !p)} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-[10px] select-none">{isPaused ? '▶' : '❚❚'}</button>
<GameTouchButton onAction={() => directionQueueRef.current.push('right')} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg"></GameTouchButton>
<div />
<GameTouchButton onAction={() => directionQueueRef.current.push('down')} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg"></GameTouchButton>
<div />
</div>
)}
{(!gameStarted || gameOver || gameComplete) && (
<button onClick={startGame} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
{gameComplete ? 'PERFECT!' : gameOver ? 'RETRY' : 'START'}
</button>
)}
</div>
)}
</div>
{/* Desktop Overlay */}
{!isMobile && (gameOver || isPaused || gameComplete) && gameStarted && (
<div className="fixed inset-0 bg-background/80 flex items-center justify-center z-50">
<div className="border-2 border-primary box-glow-strong p-6 bg-background text-center">
<h2 className="font-minecraft text-2xl text-primary text-glow-strong mb-3">{gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}</h2>
{(gameOver || gameComplete) && (
<>
<p className="font-pixel text-sm text-foreground/80 mb-1">Final Score: {score.toLocaleString()}</p>
<p className="font-pixel text-xs text-foreground/60 mb-3">Length: {snake.length}</p>
</>
)}
<button onClick={(gameOver || gameComplete) ? startGame : () => setIsPaused(false)} className="font-minecraft text-sm py-2 px-6 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
{(gameOver || gameComplete) ? 'PLAY AGAIN' : 'RESUME'}
</button>
</div>
</div>
)}
</motion.div>
);
};
export default Snake;