713 lines
31 KiB
TypeScript
713 lines
31 KiB
TypeScript
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<GameMode | null>(null);
|
|
|
|
// Single player state
|
|
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);
|
|
|
|
// 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<Position>({ x: 10, y: 10 });
|
|
const [winner, setWinner] = useState<string | null>(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<HTMLDivElement>(null);
|
|
const directionQueueRef = useRef<Direction[]>([]);
|
|
const currentDirectionRef = useRef<Direction>('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<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, gameMode]);
|
|
|
|
// 2P Game loop
|
|
useEffect(() => {
|
|
if (!gameStarted || gameOver || isPaused || gameMode !== '2p') return;
|
|
|
|
const interval = setInterval(() => {
|
|
const opposite: Record<Direction, Direction> = { 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(
|
|
<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;
|
|
};
|
|
|
|
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(
|
|
<div
|
|
key={`${x}-${y}`}
|
|
className={`flex items-center justify-center border transition-colors duration-75 ${bgClass}`}
|
|
style={style}
|
|
>
|
|
{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()} />;
|
|
|
|
// Mode selection screen
|
|
if (!gameMode) {
|
|
return (
|
|
<motion.div
|
|
ref={gameRef}
|
|
tabIndex={0}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.5 }}
|
|
className="flex flex-col h-full"
|
|
>
|
|
<div className="flex items-center gap-4 mb-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>
|
|
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-6">
|
|
<p className="font-pixel text-sm text-foreground/70">Select game mode</p>
|
|
|
|
<div className="flex gap-4">
|
|
<button
|
|
onClick={() => startGame('1p')}
|
|
className="flex flex-col items-center gap-2 p-6 border-2 border-primary/50 hover:border-primary bg-background/50 hover:bg-primary/10 transition-all"
|
|
>
|
|
<User size={32} className="text-primary" />
|
|
<span className="font-minecraft text-lg text-primary">1 Player</span>
|
|
<span className="font-pixel text-xs text-foreground/60">Solo mode</span>
|
|
</button>
|
|
|
|
{!isMobile && (
|
|
<button
|
|
onClick={() => startGame('2p')}
|
|
className="flex flex-col items-center gap-2 p-6 border-2 border-purple-500/50 hover:border-purple-500 bg-background/50 hover:bg-purple-500/10 transition-all"
|
|
>
|
|
<Users size={32} className="text-purple-400" />
|
|
<span className="font-minecraft text-lg text-purple-400">2 Players</span>
|
|
<span className="font-pixel text-xs text-foreground/60">Local versus</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isMobile && (
|
|
<p className="font-pixel text-xs text-foreground/40 text-center max-w-xs">
|
|
2 Player mode requires a keyboard and is not available on mobile devices
|
|
</p>
|
|
)}
|
|
|
|
<div className="border border-primary/30 p-3 bg-background/30 mt-4">
|
|
<p className="font-pixel text-xs text-foreground/60">HIGH SCORE: <span className="text-primary">{highScore.toLocaleString()}</span></p>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
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'}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-4">
|
|
<button onClick={() => { setGameMode(null); setGameStarted(false); }} className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</button>
|
|
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">
|
|
Snake {gameMode === '2p' && <span className="text-purple-400">2P</span>}
|
|
</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>
|
|
|
|
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4'} flex-1 ${isFullscreen ? 'justify-center items-center' : ''}`}>
|
|
<div className="border-2 border-primary box-glow p-1 bg-background/80">
|
|
<div className="grid" style={{ gridTemplateColumns: `repeat(${GRID_SIZE}, ${cellSize}px)` }}>
|
|
{gameMode === '1p' ? renderGrid1P() : renderGrid2P()}
|
|
</div>
|
|
</div>
|
|
|
|
{!isMobile && gameMode === '1p' && (
|
|
<div className="flex flex-col gap-3 min-w-[160px]">
|
|
{/* Score Panel */}
|
|
<div className="border-2 border-primary/50 p-4 bg-background/60 space-y-1">
|
|
<p className="font-pixel text-[10px] text-foreground/50 uppercase tracking-wider">Score</p>
|
|
<p className="font-minecraft text-2xl text-primary text-glow-strong">{score.toLocaleString()}</p>
|
|
</div>
|
|
|
|
{/* High Score Panel */}
|
|
<div className="border border-primary/30 p-3 bg-background/40">
|
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider">High Score</p>
|
|
<p className="font-minecraft text-lg text-primary/80">{highScore.toLocaleString()}</p>
|
|
<p className="font-pixel text-[8px] text-foreground/30 mt-1">max: 4,294,967,296</p>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="border border-primary/30 p-3 bg-background/40">
|
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider">Length</p>
|
|
<p className="font-minecraft text-xl text-primary">{snake.length}</p>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="border border-primary/20 p-3 bg-background/30">
|
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider mb-2">Controls</p>
|
|
<div className="space-y-1">
|
|
<p className="font-pixel text-[10px] text-foreground/60">← → ↑ ↓ / WASD</p>
|
|
<p className="font-pixel text-[10px] text-foreground/60">P: Pause</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Button */}
|
|
{!gameStarted || gameOver || gameComplete ? (
|
|
<button onClick={() => startGame('1p')} className="font-minecraft text-sm py-3 px-4 border-2 border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
|
{gameComplete ? 'PERFECT!' : gameOver ? 'RETRY' : 'START GAME'}
|
|
</button>
|
|
) : (
|
|
<button onClick={() => setIsPaused(p => !p)} className="font-minecraft text-sm py-3 px-4 border-2 border-primary/60 bg-background/50 text-primary hover:bg-primary/20 transition-all">
|
|
{isPaused ? 'RESUME' : 'PAUSE'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isMobile && gameMode === '2p' && (
|
|
<div className="flex flex-col gap-3 min-w-[180px]">
|
|
{/* Player 1 */}
|
|
<div className="border-2 border-primary/60 p-4 bg-background/60">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<div className="w-4 h-4 rounded-sm" style={{ backgroundColor: players[0].color, boxShadow: '0 0 8px ' + players[0].color }} />
|
|
<p className="font-pixel text-xs text-primary">Player 1</p>
|
|
<span className="font-pixel text-[8px] text-foreground/40 ml-auto">WASD</span>
|
|
</div>
|
|
<p className="font-minecraft text-2xl text-primary text-glow-strong">{players[0].score}</p>
|
|
{!players[0].alive && <p className="font-pixel text-[10px] text-destructive mt-1 animate-pulse">☠ ELIMINATED</p>}
|
|
</div>
|
|
|
|
{/* Player 2 */}
|
|
<div className="border-2 border-purple-500/60 p-4 bg-background/60">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<div className="w-4 h-4 rounded-sm" style={{ backgroundColor: players[1].color, boxShadow: '0 0 8px ' + players[1].color }} />
|
|
<p className="font-pixel text-xs text-purple-400">Player 2</p>
|
|
<span className="font-pixel text-[8px] text-foreground/40 ml-auto">Arrows</span>
|
|
</div>
|
|
<p className="font-minecraft text-2xl text-purple-400" style={{ textShadow: '0 0 10px hsl(280 70% 50%)' }}>{players[1].score}</p>
|
|
{!players[1].alive && <p className="font-pixel text-[10px] text-destructive mt-1 animate-pulse">☠ ELIMINATED</p>}
|
|
</div>
|
|
|
|
{/* Controls Summary */}
|
|
<div className="border border-primary/20 p-3 bg-background/30">
|
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider mb-2">Controls</p>
|
|
<div className="grid grid-cols-2 gap-2 text-center">
|
|
<div>
|
|
<p className="font-pixel text-[8px] text-primary">P1</p>
|
|
<p className="font-pixel text-[10px] text-foreground/60">W A S D</p>
|
|
</div>
|
|
<div>
|
|
<p className="font-pixel text-[8px] text-purple-400">P2</p>
|
|
<p className="font-pixel text-[10px] text-foreground/60">↑ ↓ ← →</p>
|
|
</div>
|
|
</div>
|
|
<p className="font-pixel text-[9px] text-foreground/40 text-center mt-2">P: Pause</p>
|
|
</div>
|
|
|
|
{/* Action Button */}
|
|
{!gameStarted || gameOver ? (
|
|
<button onClick={() => startGame('2p')} className="font-minecraft text-sm py-3 px-4 border-2 border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
|
{gameOver ? 'PLAY AGAIN' : 'START GAME'}
|
|
</button>
|
|
) : (
|
|
<button onClick={() => setIsPaused(p => !p)} className="font-minecraft text-sm py-3 px-4 border-2 border-primary/60 bg-background/50 text-primary hover:bg-primary/20 transition-all">
|
|
{isPaused ? 'RESUME' : 'PAUSE'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{isMobile && (
|
|
<>
|
|
<MobileControlsSpacer />
|
|
<MobileControlsDock>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<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-2">
|
|
<div />
|
|
<GameTouchButton onAction={() => directionQueueRef.current.push('up')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↑</GameTouchButton>
|
|
<div />
|
|
<GameTouchButton onAction={() => directionQueueRef.current.push('left')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">←</GameTouchButton>
|
|
<button onClick={() => setIsPaused(p => !p)} className="p-4 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-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">→</GameTouchButton>
|
|
<div />
|
|
<GameTouchButton onAction={() => directionQueueRef.current.push('down')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↓</GameTouchButton>
|
|
<div />
|
|
</div>
|
|
) : (
|
|
<button onClick={() => startGame('1p')} 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>
|
|
</MobileControlsDock>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Overlays */}
|
|
{!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">
|
|
{gameMode === '2p' && gameOver ? winner : gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}
|
|
</h2>
|
|
{gameMode === '1p' && (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>
|
|
</>
|
|
)}
|
|
{gameMode === '2p' && gameOver && (
|
|
<div className="flex gap-4 justify-center mb-3">
|
|
<div>
|
|
<p className="font-pixel text-[10px] text-foreground/60">P1 Score</p>
|
|
<p className="font-minecraft text-lg text-primary">{players[0].score}</p>
|
|
</div>
|
|
<div>
|
|
<p className="font-pixel text-[10px] text-foreground/60">P2 Score</p>
|
|
<p className="font-minecraft text-lg text-purple-400">{players[1].score}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<button onClick={(gameOver || gameComplete) ? () => startGame(gameMode!) : () => 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; |