import { useState, useEffect, useCallback, useRef } from 'react'; import { motion } from 'framer-motion'; import { useSettings } from '@/contexts/SettingsContext'; import { useAchievements } from '@/contexts/AchievementsContext'; import { Link } from 'react-router-dom'; import GlitchCrash from '@/components/GlitchCrash'; import { Maximize2, Minimize2, Users, User } from 'lucide-react'; import { useBrowserFullscreen } from '@/hooks/useGameDimensions'; import GameTouchButton from '@/components/GameTouchButton'; import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock'; 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 }; type GameMode = '1p' | '2p'; interface Player { snake: Position[]; direction: Direction; score: number; alive: boolean; color: string; name: string; } const Snake = () => { const { playSound } = useSettings(); const { checkGameScoreAchievements, unlockMaxScore } = useAchievements(); // Mode selection const [gameMode, setGameMode] = useState(null); // Single player state const [snake, setSnake] = useState([{ x: 10, y: 10 }]); const [direction, setDirection] = useState('right'); const [food, setFood] = useState({ x: 15, y: 10 }); const [score, setScore] = useState(0); const [highScore, setHighScore] = useState(0); // Multiplayer state const [players, setPlayers] = useState<[Player, Player]>([ { snake: [{ x: 5, y: 10 }], direction: 'right', score: 0, alive: true, color: 'hsl(var(--primary))', name: 'P1' }, { snake: [{ x: 15, y: 10 }], direction: 'left', score: 0, alive: true, color: 'hsl(280 70% 50%)', name: 'P2' }, ]); const [food2P, setFood2P] = useState({ x: 10, y: 10 }); const [winner, setWinner] = useState(null); // Common state 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(null); const directionQueueRef = useRef([]); const currentDirectionRef = useRef('right'); // 2P direction refs const directionQueues2P = useRef<[Direction[], Direction[]]>([[], []]); const currentDirections2P = useRef<[Direction, Direction]>(['right', 'left']); const { enterFullscreen, exitFullscreen } = useBrowserFullscreen(); const getCellSize = useCallback(() => { if (typeof window === 'undefined') return 24; const isMobile = window.innerWidth < 768; if (isMobile) { const maxWidth = window.innerWidth - 40; const maxHeight = window.innerHeight - 420; return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE), 18); } if (gameMode === '2p') { return isFullscreen ? 20 : 16; } return isFullscreen ? 28 : 24; }, [isFullscreen, gameMode]); 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, gameMode]); 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)); }, []); useEffect(() => { if (score > 0 && gameMode === '1p') { checkGameScoreAchievements('snake', score); if (score >= MAX_SCORE) unlockMaxScore(); } }, [score, checkGameScoreAchievements, unlockMaxScore, gameMode]); 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 spawnFood2P = useCallback((snakes: Position[][]): Position => { const occupied = new Set(snakes.flat().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 { x: 10, y: 10 }; 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 = (mode: GameMode) => { setGameMode(mode); if (mode === '1p') { const initialSnake = [{ x: 10, y: 10 }]; setSnake(initialSnake); setDirection('right'); currentDirectionRef.current = 'right'; directionQueueRef.current = []; setFood(spawnFood(initialSnake)!); setScore(0); } else { const p1Start = [{ x: 5, y: 10 }]; const p2Start = [{ x: 15, y: 10 }]; setPlayers([ { snake: p1Start, direction: 'right', score: 0, alive: true, color: 'hsl(var(--primary))', name: 'P1' }, { snake: p2Start, direction: 'left', score: 0, alive: true, color: 'hsl(280 70% 50%)', name: 'P2' }, ]); currentDirections2P.current = ['right', 'left']; directionQueues2P.current = [[], []]; setFood2P(spawnFood2P([p1Start, p2Start])); setWinner(null); } setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); playSound('success'); gameRef.current?.focus(); }; // 1P Game loop useEffect(() => { if (!gameStarted || gameOver || gameComplete || isPaused || gameMode !== '1p') return; const interval = setInterval(() => { const opposite: Record = { 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, gameMode]); // 2P Game loop useEffect(() => { if (!gameStarted || gameOver || isPaused || gameMode !== '2p') return; const interval = setInterval(() => { const opposite: Record = { up: 'down', down: 'up', left: 'right', right: 'left' }; setPlayers(prev => { const newPlayers: [Player, Player] = [{ ...prev[0] }, { ...prev[1] }]; for (let i = 0; i < 2; i++) { if (!newPlayers[i].alive) continue; let nextDir = currentDirections2P.current[i]; while (directionQueues2P.current[i].length > 0) { const queuedDir = directionQueues2P.current[i].shift()!; if (queuedDir !== opposite[currentDirections2P.current[i]]) { nextDir = queuedDir; break; } } currentDirections2P.current[i] = nextDir; newPlayers[i].direction = nextDir; const head = newPlayers[i].snake[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) { newPlayers[i].alive = false; playSound('error'); continue; } if (newPlayers[i].snake.some(s => s.x === newHead.x && s.y === newHead.y)) { newPlayers[i].alive = false; playSound('error'); continue; } const otherIndex = i === 0 ? 1 : 0; if (newPlayers[otherIndex].snake.some(s => s.x === newHead.x && s.y === newHead.y)) { newPlayers[i].alive = false; playSound('error'); continue; } const newSnake = [newHead, ...newPlayers[i].snake]; if (newHead.x === food2P.x && newHead.y === food2P.y) { playSound('success'); newPlayers[i].score += 10; setFood2P(spawnFood2P([newPlayers[0].snake, newPlayers[1].snake])); } else { newSnake.pop(); } newPlayers[i].snake = newSnake; } const alive = newPlayers.filter(p => p.alive); if (alive.length === 0) { setGameOver(true); setWinner('Draw!'); } else if (alive.length === 1) { setGameOver(true); setWinner(`${alive[0].name} Wins!`); } return newPlayers; }); }, TICK_SPEED); return () => clearInterval(interval); }, [gameStarted, gameOver, isPaused, food2P, playSound, spawnFood2P, gameMode]); // Keyboard handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!gameStarted) return; if (gameMode === '1p') { 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); } else { // P1: WASD if (e.key === 'w' || e.key === 'W') { e.preventDefault(); if (directionQueues2P.current[0].length < 3) directionQueues2P.current[0].push('up'); } if (e.key === 's' || e.key === 'S') { e.preventDefault(); if (directionQueues2P.current[0].length < 3) directionQueues2P.current[0].push('down'); } if (e.key === 'a' || e.key === 'A') { e.preventDefault(); if (directionQueues2P.current[0].length < 3) directionQueues2P.current[0].push('left'); } if (e.key === 'd' || e.key === 'D') { e.preventDefault(); if (directionQueues2P.current[0].length < 3) directionQueues2P.current[0].push('right'); } // P2: Arrows if (e.key === 'ArrowUp') { e.preventDefault(); if (directionQueues2P.current[1].length < 3) directionQueues2P.current[1].push('up'); } if (e.key === 'ArrowDown') { e.preventDefault(); if (directionQueues2P.current[1].length < 3) directionQueues2P.current[1].push('down'); } if (e.key === 'ArrowLeft') { e.preventDefault(); if (directionQueues2P.current[1].length < 3) directionQueues2P.current[1].push('left'); } if (e.key === 'ArrowRight') { e.preventDefault(); if (directionQueues2P.current[1].length < 3) directionQueues2P.current[1].push('right'); } if (e.key === 'p' || e.key === 'P') { e.preventDefault(); if (!gameOver) setIsPaused(p => !p); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [gameStarted, gameOver, gameComplete, gameMode]); const renderGrid1P = () => { 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(
{isFood &&
}
); } } return cells; }; const renderGrid2P = () => { const cells = []; const p1Set = new Set(players[0].snake.map(s => `${s.x},${s.y}`)); const p2Set = new Set(players[1].snake.map(s => `${s.x},${s.y}`)); const p1Head = players[0].snake[0]; const p2Head = players[1].snake[0]; for (let y = 0; y < GRID_SIZE; y++) { for (let x = 0; x < GRID_SIZE; x++) { const isP1 = p1Set.has(`${x},${y}`); const isP2 = p2Set.has(`${x},${y}`); const isP1Head = p1Head.x === x && p1Head.y === y && players[0].alive; const isP2Head = p2Head.x === x && p2Head.y === y && players[1].alive; const isFood = food2P.x === x && food2P.y === y; let bgClass = 'bg-background/50 border-primary/10'; let style: React.CSSProperties = { width: cellSize, height: cellSize }; if (isP1Head) { bgClass = 'border-primary'; style.backgroundColor = players[0].color; style.boxShadow = `0 0 10px ${players[0].color}`; } else if (isP1 && players[0].alive) { bgClass = 'border-primary/60'; style.backgroundColor = players[0].color; style.opacity = 0.8; } else if (isP2Head) { bgClass = 'border-purple-500'; style.backgroundColor = players[1].color; style.boxShadow = `0 0 10px ${players[1].color}`; } else if (isP2 && players[1].alive) { bgClass = 'border-purple-500/60'; style.backgroundColor = players[1].color; style.opacity = 0.8; } else if (isFood) { bgClass = 'bg-destructive/80 border-destructive/60'; } cells.push(
{isFood &&
}
); } } return cells; }; if (showGlitchCrash) return window.location.reload()} />; // Mode selection screen if (!gameMode) { return (
{'<'} Back

Snake

Select game mode

{!isMobile && ( )}
{isMobile && (

2 Player mode requires a keyboard and is not available on mobile devices

)}

HIGH SCORE: {highScore.toLocaleString()}

); } return (

Snake {gameMode === '2p' && 2P}

{gameMode === '1p' ? renderGrid1P() : renderGrid2P()}
{!isMobile && gameMode === '1p' && (
{/* Score Panel */}

Score

{score.toLocaleString()}

{/* High Score Panel */}

High Score

{highScore.toLocaleString()}

max: 4,294,967,296

{/* Stats */}

Length

{snake.length}

{/* Controls */}

Controls

← → ↑ ↓ / WASD

P: Pause

{/* Action Button */} {!gameStarted || gameOver || gameComplete ? ( ) : ( )}
)} {!isMobile && gameMode === '2p' && (
{/* Player 1 */}

Player 1

WASD

{players[0].score}

{!players[0].alive &&

☠ ELIMINATED

}
{/* Player 2 */}

Player 2

Arrows

{players[1].score}

{!players[1].alive &&

☠ ELIMINATED

}
{/* Controls Summary */}

Controls

P1

W A S D

P2

↑ ↓ ← →

P: Pause

{/* Action Button */} {!gameStarted || gameOver ? ( ) : ( )}
)} {isMobile && ( <>

SCORE

{score.toLocaleString()}

HIGH

{highScore.toLocaleString()}

LEN

{snake.length}

{gameStarted && !gameOver && !gameComplete ? (
directionQueueRef.current.push('up')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↑
directionQueueRef.current.push('left')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">← directionQueueRef.current.push('right')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">→
directionQueueRef.current.push('down')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↓
) : ( )}
)}
{/* Overlays */} {!isMobile && (gameOver || isPaused || gameComplete) && gameStarted && (

{gameMode === '2p' && gameOver ? winner : gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}

{gameMode === '1p' && (gameOver || gameComplete) && ( <>

Final Score: {score.toLocaleString()}

Length: {snake.length}

)} {gameMode === '2p' && gameOver && (

P1 Score

{players[0].score}

P2 Score

{players[1].score}

)}
)} ); }; export default Snake;