Consolidate multiplayer UI
- Remove separate /games multiplayer entries; integrate 1P/2P mode into Snake and Tetris views with on-screen toggle - Display 2P side-by-side boards on desktop; disable 2P on mobile - Improve Breakout by slowing ball speed - Remove SnakeMultiplayer route and related files - Update App routing to drop snake-2p path X-Lovable-Edit-ID: edt-619f10f3-377b-4f5e-92d8-c9a92a608368
This commit is contained in:
@@ -27,7 +27,6 @@ import Leaderboard from "./pages/Leaderboard";
|
||||
import Tetris from "./pages/Tetris";
|
||||
import Pacman from "./pages/Pacman";
|
||||
import Snake from "./pages/Snake";
|
||||
import SnakeMultiplayer from "./pages/SnakeMultiplayer";
|
||||
import Breakout from "./pages/Breakout";
|
||||
import Music from "./pages/Music";
|
||||
import Oscilloscope from "./pages/Oscilloscope";
|
||||
@@ -115,7 +114,6 @@ const AppContent = () => {
|
||||
<Route path="games/tetris" element={<Tetris />} />
|
||||
<Route path="games/pacman" element={<Pacman />} />
|
||||
<Route path="games/snake" element={<Snake />} />
|
||||
<Route path="games/snake-2p" element={<SnakeMultiplayer />} />
|
||||
<Route path="games/breakout" element={<Breakout />} />
|
||||
<Route path="faq" element={<FAQ />} />
|
||||
<Route path="music" element={<Music />} />
|
||||
|
||||
@@ -121,7 +121,8 @@ const Breakout = () => {
|
||||
}, [canvasSize.width]);
|
||||
|
||||
const resetBall = useCallback(() => {
|
||||
const speed = 4 + level * 0.5;
|
||||
// Slower base speed (2.5 instead of 4), with gentler level scaling
|
||||
const speed = 2.5 + level * 0.3;
|
||||
ballRef.current = { x: canvasSize.width / 2, y: canvasSize.height - 60, dx: (Math.random() > 0.5 ? 1 : -1) * speed, dy: -speed };
|
||||
paddleXRef.current = canvasSize.width / 2 - paddleWidth / 2;
|
||||
}, [canvasSize.width, canvasSize.height, paddleWidth, level]);
|
||||
|
||||
+11
-47
@@ -8,6 +8,7 @@ const games = [
|
||||
id: 'tetris',
|
||||
name: 'Tetris',
|
||||
description: 'Classic block-stacking puzzle game',
|
||||
hasMultiplayer: true,
|
||||
ascii: `┌────────┐
|
||||
│ ▓▓ │
|
||||
│ ▓▓ ██ │
|
||||
@@ -19,6 +20,7 @@ const games = [
|
||||
id: 'pacman',
|
||||
name: 'Pac-Man',
|
||||
description: 'Navigate the maze, eat dots, avoid ghosts',
|
||||
hasMultiplayer: false,
|
||||
ascii: `┌────────┐
|
||||
│· · ᗣ · │
|
||||
│ ┌─┐ ┌─┐│
|
||||
@@ -30,6 +32,7 @@ const games = [
|
||||
id: 'snake',
|
||||
name: 'Snake',
|
||||
description: 'Eat food, grow longer, dont hit yourself',
|
||||
hasMultiplayer: true,
|
||||
ascii: `┌────────┐
|
||||
│ │
|
||||
│ ●■■■ │
|
||||
@@ -41,6 +44,7 @@ const games = [
|
||||
id: 'breakout',
|
||||
name: 'Breakout',
|
||||
description: 'Break bricks with a bouncing ball',
|
||||
hasMultiplayer: false,
|
||||
ascii: `┌────────┐
|
||||
│████████│
|
||||
│▓▓▓▓▓▓▓▓│
|
||||
@@ -51,20 +55,6 @@ const games = [
|
||||
},
|
||||
];
|
||||
|
||||
const multiplayerGames = [
|
||||
{
|
||||
id: 'snake-2p',
|
||||
name: 'Snake 2P',
|
||||
description: '2 players on same keyboard - last one standing wins!',
|
||||
ascii: `┌────────┐
|
||||
│ ●■■ │
|
||||
│ │
|
||||
│ ▓▓● │
|
||||
│ │
|
||||
└────────┘`,
|
||||
},
|
||||
];
|
||||
|
||||
const Games = () => {
|
||||
return (
|
||||
<motion.div
|
||||
@@ -100,7 +90,7 @@ const Games = () => {
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Link to={`/games/${game.id}`} className="block">
|
||||
<div className="border border-primary/50 hover:border-primary bg-background/50 hover:bg-primary/10 p-4 transition-all duration-300 group cursor-pointer">
|
||||
<div className="border border-primary/50 hover:border-primary bg-background/50 hover:bg-primary/10 p-4 transition-all duration-300 group cursor-pointer relative">
|
||||
<pre className="font-mono text-sm text-primary/70 group-hover:text-primary transition-colors mb-3 leading-tight">
|
||||
{game.ascii}
|
||||
</pre>
|
||||
@@ -110,43 +100,17 @@ const Games = () => {
|
||||
<p className="font-pixel text-xs text-foreground/60 mt-2">
|
||||
{game.description}
|
||||
</p>
|
||||
{game.hasMultiplayer && (
|
||||
<span className="absolute top-2 right-2 font-pixel text-[8px] text-purple-400 border border-purple-500/50 px-1 py-0.5">
|
||||
2P
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Multiplayer Section */}
|
||||
<div className="mt-6">
|
||||
<h2 className="font-minecraft text-xl text-primary text-glow mb-3">
|
||||
<GlitchText text="Multiplayer" />
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{multiplayerGames.map((game, index) => (
|
||||
<motion.div
|
||||
key={game.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 + index * 0.1 }}
|
||||
>
|
||||
<Link to={`/games/${game.id}`} className="block">
|
||||
<div className="border border-purple-500/50 hover:border-purple-500 bg-background/50 hover:bg-purple-500/10 p-4 transition-all duration-300 group cursor-pointer">
|
||||
<pre className="font-mono text-sm text-purple-400/70 group-hover:text-purple-400 transition-colors mb-3 leading-tight">
|
||||
{game.ascii}
|
||||
</pre>
|
||||
<h2 className="font-minecraft text-xl text-purple-400 group-hover:text-glow-strong">
|
||||
<GlitchText text={game.name} />
|
||||
</h2>
|
||||
<p className="font-pixel text-xs text-foreground/60 mt-2">
|
||||
{game.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-primary/30 p-2 bg-background/30 mt-4">
|
||||
<p className="font-pixel text-xs text-foreground/50">
|
||||
<span className="text-primary">{'>'}</span> Max score: 4,294,967,296
|
||||
@@ -157,4 +121,4 @@ const Games = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Games;
|
||||
export default Games;
|
||||
+361
-45
@@ -4,7 +4,7 @@ import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { useAchievements } from '@/contexts/AchievementsContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import GlitchCrash from '@/components/GlitchCrash';
|
||||
import { Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Maximize2, Minimize2, Users, User } from 'lucide-react';
|
||||
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
|
||||
import GameTouchButton from '@/components/GameTouchButton';
|
||||
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
||||
@@ -17,14 +17,40 @@ 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);
|
||||
@@ -34,21 +60,26 @@ const Snake = () => {
|
||||
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();
|
||||
|
||||
// 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 - 40;
|
||||
// Reserve space for header + fixed controls dock on mobile
|
||||
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]);
|
||||
}, [isFullscreen, gameMode]);
|
||||
|
||||
const [cellSize, setCellSize] = useState(getCellSize);
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
@@ -61,9 +92,8 @@ const Snake = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setCellSize(getCellSize());
|
||||
}, [isFullscreen, getCellSize]);
|
||||
}, [isFullscreen, getCellSize, gameMode]);
|
||||
|
||||
// Auto-fullscreen on mobile
|
||||
useEffect(() => {
|
||||
if (gameStarted && isMobile && !isFullscreen) {
|
||||
setIsFullscreen(true);
|
||||
@@ -98,13 +128,12 @@ const Snake = () => {
|
||||
if (saved) setHighScore(Math.min(parseInt(saved, 10), MAX_SCORE));
|
||||
}, []);
|
||||
|
||||
// Check score achievements
|
||||
useEffect(() => {
|
||||
if (score > 0) {
|
||||
if (score > 0 && gameMode === '1p') {
|
||||
checkGameScoreAchievements('snake', score);
|
||||
if (score >= MAX_SCORE) unlockMaxScore();
|
||||
}
|
||||
}, [score, checkGameScoreAchievements, unlockMaxScore]);
|
||||
}, [score, checkGameScoreAchievements, unlockMaxScore, gameMode]);
|
||||
|
||||
const spawnFood = useCallback((currentSnake: Position[]): Position | null => {
|
||||
const occupied = new Set(currentSnake.map(s => `${s.x},${s.y}`));
|
||||
@@ -118,6 +147,18 @@ const Snake = () => {
|
||||
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);
|
||||
@@ -128,14 +169,30 @@ const Snake = () => {
|
||||
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);
|
||||
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);
|
||||
@@ -144,8 +201,9 @@ const Snake = () => {
|
||||
gameRef.current?.focus();
|
||||
};
|
||||
|
||||
// 1P Game loop
|
||||
useEffect(() => {
|
||||
if (!gameStarted || gameOver || gameComplete || isPaused) return;
|
||||
if (!gameStarted || gameOver || gameComplete || isPaused || gameMode !== '1p') return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const opposite: Record<Direction, Direction> = { up: 'down', down: 'up', left: 'right', right: 'left' };
|
||||
@@ -204,26 +262,125 @@ const Snake = () => {
|
||||
}, TICK_SPEED);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [gameStarted, gameOver, gameComplete, isPaused, food, highScore, playSound, spawnFood, resetSnakeKeepScore]);
|
||||
}, [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;
|
||||
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 (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); }
|
||||
}
|
||||
if (newDir && directionQueueRef.current.length < 3) directionQueueRef.current.push(newDir);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [gameStarted, gameOver, gameComplete]);
|
||||
}, [gameStarted, gameOver, gameComplete, gameMode]);
|
||||
|
||||
const renderGrid = () => {
|
||||
const renderGrid1P = () => {
|
||||
const cells = [];
|
||||
const snakeSet = new Set(snake.map(s => `${s.x},${s.y}`));
|
||||
const head = snake[0];
|
||||
@@ -251,8 +408,115 @@ const Snake = () => {
|
||||
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}
|
||||
@@ -262,26 +526,26 @@ const Snake = () => {
|
||||
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>
|
||||
<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>
|
||||
|
||||
{/* 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 className="grid" style={{ gridTemplateColumns: `repeat(${GRID_SIZE}, ${cellSize}px)` }}>
|
||||
{gameMode === '1p' ? renderGrid1P() : renderGrid2P()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side Panel */}
|
||||
{!isMobile && (
|
||||
{!isMobile && gameMode === '1p' && (
|
||||
<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>
|
||||
@@ -302,7 +566,7 @@ const Snake = () => {
|
||||
<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">
|
||||
<button onClick={() => startGame('1p')} 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>
|
||||
) : (
|
||||
@@ -313,7 +577,45 @@ const Snake = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Controls */}
|
||||
{!isMobile && gameMode === '2p' && (
|
||||
<div className="flex flex-col gap-2 min-w-[160px]">
|
||||
<div className="border border-primary/50 p-3 bg-background/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: players[0].color }} />
|
||||
<p className="font-pixel text-[10px] text-foreground/60">P1 (WASD)</p>
|
||||
</div>
|
||||
<p className="font-minecraft text-lg text-primary text-glow">{players[0].score}</p>
|
||||
{!players[0].alive && <p className="font-pixel text-[8px] text-destructive">DEAD</p>}
|
||||
</div>
|
||||
|
||||
<div className="border border-purple-500/50 p-3 bg-background/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: players[1].color }} />
|
||||
<p className="font-pixel text-[10px] text-foreground/60">P2 (↑↓←→)</p>
|
||||
</div>
|
||||
<p className="font-minecraft text-lg text-purple-400">{players[1].score}</p>
|
||||
{!players[1].alive && <p className="font-pixel text-[8px] text-destructive">DEAD</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-[9px] text-primary">P1: W A S D</p>
|
||||
<p className="font-pixel text-[9px] text-purple-400">P2: ↑ ↓ ← →</p>
|
||||
<p className="font-pixel text-[9px] text-foreground/80 mt-1">P: Pause</p>
|
||||
</div>
|
||||
|
||||
{!gameStarted || gameOver ? (
|
||||
<button onClick={() => startGame('2p')} 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">
|
||||
{gameOver ? 'PLAY AGAIN' : '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>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<>
|
||||
<MobileControlsSpacer />
|
||||
@@ -338,7 +640,7 @@ const Snake = () => {
|
||||
<div />
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
@@ -348,18 +650,32 @@ const Snake = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop Overlay */}
|
||||
{/* 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">{gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}</h2>
|
||||
{(gameOver || gameComplete) && (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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">
|
||||
{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>
|
||||
@@ -369,4 +685,4 @@ const Snake = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Snake;
|
||||
export default Snake;
|
||||
@@ -1,397 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
|
||||
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
||||
|
||||
const GRID_SIZE = 20;
|
||||
const TICK_SPEED = 120;
|
||||
|
||||
type Direction = 'up' | 'down' | 'left' | 'right';
|
||||
type Position = { x: number; y: number };
|
||||
|
||||
interface Player {
|
||||
snake: Position[];
|
||||
direction: Direction;
|
||||
score: number;
|
||||
alive: boolean;
|
||||
color: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const SnakeMultiplayer = () => {
|
||||
const { playSound } = useSettings();
|
||||
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 [food, setFood] = useState<Position>({ x: 10, y: 10 });
|
||||
const [gameOver, setGameOver] = useState(false);
|
||||
const [winner, setWinner] = useState<string | null>(null);
|
||||
const [gameStarted, setGameStarted] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const gameRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const directionQueues = useRef<[Direction[], Direction[]]>([[], []]);
|
||||
const currentDirections = useRef<[Direction, Direction]>(['right', 'left']);
|
||||
|
||||
const { enterFullscreen, exitFullscreen } = useBrowserFullscreen();
|
||||
|
||||
const getCellSize = useCallback(() => {
|
||||
if (typeof window === 'undefined') return 20;
|
||||
const isMobile = window.innerWidth < 768;
|
||||
if (isMobile) {
|
||||
const maxWidth = window.innerWidth - 40;
|
||||
// Reserve space for header + fixed controls dock on mobile
|
||||
const maxHeight = window.innerHeight - 420;
|
||||
return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE), 16);
|
||||
}
|
||||
return isFullscreen ? 24 : 20;
|
||||
}, [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]);
|
||||
|
||||
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]);
|
||||
|
||||
const spawnFood = 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 startGame = () => {
|
||||
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' },
|
||||
]);
|
||||
currentDirections.current = ['right', 'left'];
|
||||
directionQueues.current = [[], []];
|
||||
setFood(spawnFood([p1Start, p2Start]));
|
||||
setGameOver(false);
|
||||
setWinner(null);
|
||||
setIsPaused(false);
|
||||
setGameStarted(true);
|
||||
playSound('success');
|
||||
gameRef.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameStarted || gameOver || isPaused) 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;
|
||||
|
||||
// Process direction queue
|
||||
let nextDir = currentDirections.current[i];
|
||||
while (directionQueues.current[i].length > 0) {
|
||||
const queuedDir = directionQueues.current[i].shift()!;
|
||||
if (queuedDir !== opposite[currentDirections.current[i]]) {
|
||||
nextDir = queuedDir;
|
||||
break;
|
||||
}
|
||||
}
|
||||
currentDirections.current[i] = nextDir;
|
||||
newPlayers[i].direction = nextDir;
|
||||
|
||||
// Move snake
|
||||
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;
|
||||
}
|
||||
|
||||
// Check wall collision
|
||||
if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) {
|
||||
newPlayers[i].alive = false;
|
||||
playSound('error');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check self collision
|
||||
if (newPlayers[i].snake.some(s => s.x === newHead.x && s.y === newHead.y)) {
|
||||
newPlayers[i].alive = false;
|
||||
playSound('error');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check collision with other snake
|
||||
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];
|
||||
|
||||
// Check food
|
||||
if (newHead.x === food.x && newHead.y === food.y) {
|
||||
playSound('success');
|
||||
newPlayers[i].score += 10;
|
||||
setFood(spawnFood([newPlayers[0].snake, newPlayers[1].snake]));
|
||||
} else {
|
||||
newSnake.pop();
|
||||
}
|
||||
|
||||
newPlayers[i].snake = newSnake;
|
||||
}
|
||||
|
||||
// Check win condition
|
||||
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, food, playSound, spawnFood]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!gameStarted) return;
|
||||
|
||||
// Player 1: WASD
|
||||
if (e.key === 'w' || e.key === 'W') { e.preventDefault(); if (directionQueues.current[0].length < 3) directionQueues.current[0].push('up'); }
|
||||
if (e.key === 's' || e.key === 'S') { e.preventDefault(); if (directionQueues.current[0].length < 3) directionQueues.current[0].push('down'); }
|
||||
if (e.key === 'a' || e.key === 'A') { e.preventDefault(); if (directionQueues.current[0].length < 3) directionQueues.current[0].push('left'); }
|
||||
if (e.key === 'd' || e.key === 'D') { e.preventDefault(); if (directionQueues.current[0].length < 3) directionQueues.current[0].push('right'); }
|
||||
|
||||
// Player 2: Arrow keys
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); if (directionQueues.current[1].length < 3) directionQueues.current[1].push('up'); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); if (directionQueues.current[1].length < 3) directionQueues.current[1].push('down'); }
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); if (directionQueues.current[1].length < 3) directionQueues.current[1].push('left'); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); if (directionQueues.current[1].length < 3) directionQueues.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]);
|
||||
|
||||
const renderGrid = () => {
|
||||
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 = food.x === x && food.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;
|
||||
};
|
||||
|
||||
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">
|
||||
<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 2P</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)` }}>{renderGrid()}</div>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div className="flex flex-col gap-2 min-w-[160px]">
|
||||
{/* Player 1 Stats */}
|
||||
<div className="border border-primary/50 p-3 bg-background/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: players[0].color }} />
|
||||
<p className="font-pixel text-[10px] text-foreground/60">PLAYER 1 (WASD)</p>
|
||||
</div>
|
||||
<p className="font-minecraft text-lg text-primary text-glow">{players[0].score}</p>
|
||||
{!players[0].alive && <p className="font-pixel text-[8px] text-destructive">DEAD</p>}
|
||||
</div>
|
||||
|
||||
{/* Player 2 Stats */}
|
||||
<div className="border border-purple-500/50 p-3 bg-background/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: players[1].color }} />
|
||||
<p className="font-pixel text-[10px] text-foreground/60">PLAYER 2 (↑↓←→)</p>
|
||||
</div>
|
||||
<p className="font-minecraft text-lg text-purple-400">{players[1].score}</p>
|
||||
{!players[1].alive && <p className="font-pixel text-[8px] text-destructive">DEAD</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-[9px] text-primary">P1: W A S D</p>
|
||||
<p className="font-pixel text-[9px] text-purple-400">P2: ↑ ↓ ← →</p>
|
||||
<p className="font-pixel text-[9px] text-foreground/80 mt-1">P: Pause</p>
|
||||
</div>
|
||||
|
||||
{!gameStarted || gameOver ? (
|
||||
<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">
|
||||
{gameOver ? 'PLAY AGAIN' : '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>
|
||||
)}
|
||||
|
||||
{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">P1</p><p className="font-minecraft text-sm text-primary">{players[0].score}</p></div>
|
||||
<div><p className="font-pixel text-[8px] text-foreground/60">P2</p><p className="font-minecraft text-sm text-purple-400">{players[1].score}</p></div>
|
||||
</div>
|
||||
|
||||
{!gameStarted || gameOver ? (
|
||||
<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">
|
||||
{gameOver ? 'PLAY AGAIN' : 'START'}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => setIsPaused(p => !p)} 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">
|
||||
{isPaused ? 'RESUME' : 'PAUSE'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 font-pixel text-[10px] text-foreground/70">
|
||||
<span className="text-primary">P1: WASD</span>
|
||||
<span className="text-purple-400">P2: ↑↓←→</span>
|
||||
</div>
|
||||
</div>
|
||||
</MobileControlsDock>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Game Over / Pause Overlay */}
|
||||
{(gameOver || isPaused) && 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">
|
||||
{gameOver ? winner : 'PAUSED'}
|
||||
</h2>
|
||||
{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 ? 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 ? 'PLAY AGAIN' : 'RESUME'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SnakeMultiplayer;
|
||||
+421
-65
@@ -4,7 +4,7 @@ import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { useAchievements } from '@/contexts/AchievementsContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import GlitchCrash from '@/components/GlitchCrash';
|
||||
import { Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Maximize2, Minimize2, Users, User } from 'lucide-react';
|
||||
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
|
||||
import GameTouchButton from '@/components/GameTouchButton';
|
||||
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
||||
@@ -16,6 +16,7 @@ const HIGHSCORE_KEY = 'tetris-highscore';
|
||||
const MAX_SCORE = 4294967296;
|
||||
|
||||
type Board = (string | null)[][];
|
||||
type GameMode = '1p' | '2p';
|
||||
|
||||
const TETROMINOS = {
|
||||
I: { shape: [[1, 1, 1, 1]], color: 'hsl(var(--primary))' },
|
||||
@@ -27,6 +28,16 @@ const TETROMINOS = {
|
||||
L: { shape: [[0, 0, 1], [1, 1, 1]], color: 'hsl(var(--primary))' },
|
||||
};
|
||||
|
||||
const TETROMINOS_P2 = {
|
||||
I: { shape: [[1, 1, 1, 1]], color: 'hsl(280 70% 50%)' },
|
||||
O: { shape: [[1, 1], [1, 1]], color: 'hsl(280 70% 50%)' },
|
||||
T: { shape: [[0, 1, 0], [1, 1, 1]], color: 'hsl(280 70% 50%)' },
|
||||
S: { shape: [[0, 1, 1], [1, 1, 0]], color: 'hsl(280 70% 50%)' },
|
||||
Z: { shape: [[1, 1, 0], [0, 1, 1]], color: 'hsl(280 70% 50%)' },
|
||||
J: { shape: [[1, 0, 0], [1, 1, 1]], color: 'hsl(280 70% 50%)' },
|
||||
L: { shape: [[0, 0, 1], [1, 1, 1]], color: 'hsl(280 70% 50%)' },
|
||||
};
|
||||
|
||||
type TetrominoKey = keyof typeof TETROMINOS;
|
||||
|
||||
interface Piece {
|
||||
@@ -36,12 +47,21 @@ interface Piece {
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
board: Board;
|
||||
piece: Piece;
|
||||
nextPiece: Piece;
|
||||
score: number;
|
||||
lines: number;
|
||||
gameOver: boolean;
|
||||
}
|
||||
|
||||
const createBoard = (): Board => Array.from({ length: BOARD_HEIGHT }, () => Array(BOARD_WIDTH).fill(null));
|
||||
|
||||
const randomTetromino = (): Piece => {
|
||||
const randomTetromino = (isP2 = false): Piece => {
|
||||
const keys = Object.keys(TETROMINOS) as TetrominoKey[];
|
||||
const key = keys[Math.floor(Math.random() * keys.length)];
|
||||
const tetromino = TETROMINOS[key];
|
||||
const tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key];
|
||||
return { shape: tetromino.shape, color: tetromino.color, x: Math.floor(BOARD_WIDTH / 2) - Math.floor(tetromino.shape[0].length / 2), y: 0 };
|
||||
};
|
||||
|
||||
@@ -60,12 +80,38 @@ const rotate = (matrix: number[][]): number[][] => {
|
||||
const Tetris = () => {
|
||||
const { playSound } = useSettings();
|
||||
const { checkGameScoreAchievements, unlockMaxScore } = useAchievements();
|
||||
|
||||
// Mode selection
|
||||
const [gameMode, setGameMode] = useState<GameMode | null>(null);
|
||||
|
||||
// 1P state
|
||||
const [board, setBoard] = useState<Board>(createBoard);
|
||||
const [piece, setPiece] = useState<Piece>(randomTetromino);
|
||||
const [nextPiece, setNextPiece] = useState<Piece>(randomTetromino);
|
||||
const [score, setScore] = useState(0);
|
||||
const [highScore, setHighScore] = useState(0);
|
||||
const [lines, setLines] = useState(0);
|
||||
|
||||
// 2P state
|
||||
const [player1, setPlayer1] = useState<PlayerState>({
|
||||
board: createBoard(),
|
||||
piece: randomTetromino(),
|
||||
nextPiece: randomTetromino(),
|
||||
score: 0,
|
||||
lines: 0,
|
||||
gameOver: false,
|
||||
});
|
||||
const [player2, setPlayer2] = useState<PlayerState>({
|
||||
board: createBoard(),
|
||||
piece: randomTetromino(true),
|
||||
nextPiece: randomTetromino(true),
|
||||
score: 0,
|
||||
lines: 0,
|
||||
gameOver: false,
|
||||
});
|
||||
const [winner, setWinner] = useState<string | null>(null);
|
||||
|
||||
// Common state
|
||||
const [gameOver, setGameOver] = useState(false);
|
||||
const [gameComplete, setGameComplete] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
@@ -80,12 +126,14 @@ const Tetris = () => {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
if (isMobile) {
|
||||
const maxWidth = window.innerWidth - 40;
|
||||
// Reserve space for header + fixed controls dock on mobile
|
||||
const maxHeight = window.innerHeight - 440;
|
||||
return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 22);
|
||||
}
|
||||
if (gameMode === '2p') {
|
||||
return isFullscreen ? 22 : 18;
|
||||
}
|
||||
return isFullscreen ? 30 : 24;
|
||||
}, [isFullscreen]);
|
||||
}, [isFullscreen, gameMode]);
|
||||
|
||||
const [cellSize, setCellSize] = useState(getCellSize);
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
@@ -98,7 +146,7 @@ const Tetris = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setCellSize(getCellSize());
|
||||
}, [isFullscreen, getCellSize]);
|
||||
}, [isFullscreen, getCellSize, gameMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameStarted && isMobile && !isFullscreen) {
|
||||
@@ -126,13 +174,12 @@ const Tetris = () => {
|
||||
if (saved) setHighScore(Math.min(parseInt(saved, 10), MAX_SCORE));
|
||||
}, []);
|
||||
|
||||
// Check score achievements
|
||||
useEffect(() => {
|
||||
if (score > 0) {
|
||||
if (score > 0 && gameMode === '1p') {
|
||||
checkGameScoreAchievements('tetris', score);
|
||||
if (score >= MAX_SCORE) unlockMaxScore();
|
||||
}
|
||||
}, [score, checkGameScoreAchievements, unlockMaxScore]);
|
||||
}, [score, checkGameScoreAchievements, unlockMaxScore, gameMode]);
|
||||
|
||||
const isValidMove = useCallback((newPiece: Piece, currentBoard: Board): boolean => {
|
||||
for (let y = 0; y < newPiece.shape.length; y++) {
|
||||
@@ -169,8 +216,9 @@ const Tetris = () => {
|
||||
return { board: newBoard, cleared };
|
||||
}, []);
|
||||
|
||||
// 1P movement functions
|
||||
const moveDown = useCallback(() => {
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted) return;
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return;
|
||||
const newPiece = { ...piece, y: piece.y + 1 };
|
||||
if (isValidMove(newPiece, board)) {
|
||||
setPiece(newPiece);
|
||||
@@ -193,41 +241,64 @@ const Tetris = () => {
|
||||
if (!isValidMove(newTetromino, clearedBoard)) { setGameOver(true); playSound('error'); }
|
||||
else { setPiece(newTetromino); }
|
||||
}
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, mergePiece, clearLines, playSound, highScore, nextPiece]);
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, mergePiece, clearLines, playSound, highScore, nextPiece, gameMode]);
|
||||
|
||||
const moveLeft = useCallback(() => {
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted) return;
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return;
|
||||
const newPiece = { ...piece, x: piece.x - 1 };
|
||||
if (isValidMove(newPiece, board)) { setPiece(newPiece); playSound('hover'); }
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound]);
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound, gameMode]);
|
||||
|
||||
const moveRight = useCallback(() => {
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted) return;
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return;
|
||||
const newPiece = { ...piece, x: piece.x + 1 };
|
||||
if (isValidMove(newPiece, board)) { setPiece(newPiece); playSound('hover'); }
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound]);
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound, gameMode]);
|
||||
|
||||
const rotatePiece = useCallback(() => {
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted) return;
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return;
|
||||
const rotatedShape = rotate(piece.shape);
|
||||
const newPiece = { ...piece, shape: rotatedShape };
|
||||
if (isValidMove(newPiece, board)) { setPiece(newPiece); playSound('hover'); }
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound]);
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound, gameMode]);
|
||||
|
||||
const hardDrop = useCallback(() => {
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted) return;
|
||||
if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return;
|
||||
let newPiece = { ...piece };
|
||||
while (isValidMove({ ...newPiece, y: newPiece.y + 1 }, board)) newPiece.y++;
|
||||
setPiece(newPiece);
|
||||
playSound('click');
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound]);
|
||||
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound, gameMode]);
|
||||
|
||||
const startGame = () => {
|
||||
setBoard(createBoard());
|
||||
setPiece(randomTetromino());
|
||||
setNextPiece(randomTetromino());
|
||||
setScore(0);
|
||||
setLines(0);
|
||||
const startGame = (mode: GameMode) => {
|
||||
setGameMode(mode);
|
||||
|
||||
if (mode === '1p') {
|
||||
setBoard(createBoard());
|
||||
setPiece(randomTetromino());
|
||||
setNextPiece(randomTetromino());
|
||||
setScore(0);
|
||||
setLines(0);
|
||||
} else {
|
||||
setPlayer1({
|
||||
board: createBoard(),
|
||||
piece: randomTetromino(),
|
||||
nextPiece: randomTetromino(),
|
||||
score: 0,
|
||||
lines: 0,
|
||||
gameOver: false,
|
||||
});
|
||||
setPlayer2({
|
||||
board: createBoard(),
|
||||
piece: randomTetromino(true),
|
||||
nextPiece: randomTetromino(true),
|
||||
score: 0,
|
||||
lines: 0,
|
||||
gameOver: false,
|
||||
});
|
||||
setWinner(null);
|
||||
}
|
||||
|
||||
setGameOver(false);
|
||||
setGameComplete(false);
|
||||
setIsPaused(false);
|
||||
@@ -242,29 +313,148 @@ const Tetris = () => {
|
||||
playSound('click');
|
||||
};
|
||||
|
||||
// 2P movement helpers
|
||||
const movePlayer = useCallback((playerNum: 1 | 2, action: 'left' | 'right' | 'down' | 'rotate' | 'drop') => {
|
||||
if (gameOver || isPaused || !gameStarted || gameMode !== '2p') return;
|
||||
|
||||
const setPlayer = playerNum === 1 ? setPlayer1 : setPlayer2;
|
||||
const player = playerNum === 1 ? player1 : player2;
|
||||
const isP2 = playerNum === 2;
|
||||
|
||||
if (player.gameOver) return;
|
||||
|
||||
setPlayer(prev => {
|
||||
let newPiece = { ...prev.piece };
|
||||
|
||||
switch (action) {
|
||||
case 'left':
|
||||
newPiece.x--;
|
||||
if (!isValidMove(newPiece, prev.board)) return prev;
|
||||
playSound('hover');
|
||||
return { ...prev, piece: newPiece };
|
||||
|
||||
case 'right':
|
||||
newPiece.x++;
|
||||
if (!isValidMove(newPiece, prev.board)) return prev;
|
||||
playSound('hover');
|
||||
return { ...prev, piece: newPiece };
|
||||
|
||||
case 'rotate':
|
||||
newPiece.shape = rotate(prev.piece.shape);
|
||||
if (!isValidMove(newPiece, prev.board)) return prev;
|
||||
playSound('hover');
|
||||
return { ...prev, piece: newPiece };
|
||||
|
||||
case 'drop':
|
||||
while (isValidMove({ ...newPiece, y: newPiece.y + 1 }, prev.board)) newPiece.y++;
|
||||
playSound('click');
|
||||
return { ...prev, piece: newPiece };
|
||||
|
||||
case 'down':
|
||||
newPiece.y++;
|
||||
if (isValidMove(newPiece, prev.board)) {
|
||||
return { ...prev, piece: newPiece };
|
||||
} else {
|
||||
const mergedBoard = mergePiece(prev.board, prev.piece);
|
||||
const { board: clearedBoard, cleared } = clearLines(mergedBoard);
|
||||
const newScore = prev.score + (cleared > 0 ? cleared * 100 * cleared : 0);
|
||||
const newLines = prev.lines + cleared;
|
||||
|
||||
if (cleared > 0) playSound('success');
|
||||
else playSound('click');
|
||||
|
||||
const newTetromino = prev.nextPiece;
|
||||
const nextNext = randomTetromino(isP2);
|
||||
|
||||
if (!isValidMove(newTetromino, clearedBoard)) {
|
||||
playSound('error');
|
||||
return { ...prev, board: clearedBoard, score: newScore, lines: newLines, gameOver: true };
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
board: clearedBoard,
|
||||
piece: newTetromino,
|
||||
nextPiece: nextNext,
|
||||
score: newScore,
|
||||
lines: newLines,
|
||||
};
|
||||
}
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [gameOver, isPaused, gameStarted, gameMode, player1, player2, isValidMove, mergePiece, clearLines, playSound]);
|
||||
|
||||
// Check 2P win condition
|
||||
useEffect(() => {
|
||||
if (gameMode !== '2p' || !gameStarted) return;
|
||||
|
||||
if (player1.gameOver && player2.gameOver) {
|
||||
setGameOver(true);
|
||||
setWinner(player1.score > player2.score ? 'P1 Wins!' : player2.score > player1.score ? 'P2 Wins!' : 'Draw!');
|
||||
} else if (player1.gameOver) {
|
||||
setGameOver(true);
|
||||
setWinner('P2 Wins!');
|
||||
} else if (player2.gameOver) {
|
||||
setGameOver(true);
|
||||
setWinner('P1 Wins!');
|
||||
}
|
||||
}, [player1.gameOver, player2.gameOver, player1.score, player2.score, gameMode, gameStarted]);
|
||||
|
||||
// Keyboard handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!gameStarted) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft': case 'a': e.preventDefault(); moveLeft(); break;
|
||||
case 'ArrowRight': case 'd': e.preventDefault(); moveRight(); break;
|
||||
case 'ArrowDown': case 's': e.preventDefault(); moveDown(); break;
|
||||
case 'ArrowUp': case 'w': e.preventDefault(); rotatePiece(); break;
|
||||
case ' ': e.preventDefault(); hardDrop(); break;
|
||||
case 'p': e.preventDefault(); togglePause(); break;
|
||||
|
||||
if (gameMode === '1p') {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft': case 'a': e.preventDefault(); moveLeft(); break;
|
||||
case 'ArrowRight': case 'd': e.preventDefault(); moveRight(); break;
|
||||
case 'ArrowDown': case 's': e.preventDefault(); moveDown(); break;
|
||||
case 'ArrowUp': case 'w': e.preventDefault(); rotatePiece(); break;
|
||||
case ' ': e.preventDefault(); hardDrop(); break;
|
||||
case 'p': e.preventDefault(); togglePause(); break;
|
||||
}
|
||||
} else {
|
||||
// P1: WASD + Q for drop
|
||||
if (e.key === 'w' || e.key === 'W') { e.preventDefault(); movePlayer(1, 'rotate'); }
|
||||
if (e.key === 'a' || e.key === 'A') { e.preventDefault(); movePlayer(1, 'left'); }
|
||||
if (e.key === 's' || e.key === 'S') { e.preventDefault(); movePlayer(1, 'down'); }
|
||||
if (e.key === 'd' || e.key === 'D') { e.preventDefault(); movePlayer(1, 'right'); }
|
||||
if (e.key === 'q' || e.key === 'Q') { e.preventDefault(); movePlayer(1, 'drop'); }
|
||||
|
||||
// P2: Arrows + / for drop
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); movePlayer(2, 'rotate'); }
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); movePlayer(2, 'left'); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); movePlayer(2, 'down'); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); movePlayer(2, 'right'); }
|
||||
if (e.key === '/') { e.preventDefault(); movePlayer(2, 'drop'); }
|
||||
|
||||
if (e.key === 'p' || e.key === 'P') { e.preventDefault(); togglePause(); }
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [moveLeft, moveRight, moveDown, rotatePiece, hardDrop, gameStarted]);
|
||||
}, [moveLeft, moveRight, moveDown, rotatePiece, hardDrop, gameStarted, gameMode, movePlayer]);
|
||||
|
||||
// 1P tick
|
||||
useEffect(() => {
|
||||
if (!gameStarted || gameOver || gameComplete || isPaused) return;
|
||||
if (!gameStarted || gameOver || gameComplete || isPaused || gameMode !== '1p') return;
|
||||
const interval = setInterval(moveDown, TICK_SPEED);
|
||||
return () => clearInterval(interval);
|
||||
}, [moveDown, gameStarted, gameOver, gameComplete, isPaused]);
|
||||
}, [moveDown, gameStarted, gameOver, gameComplete, isPaused, gameMode]);
|
||||
|
||||
const renderBoard = () => {
|
||||
// 2P tick
|
||||
useEffect(() => {
|
||||
if (!gameStarted || gameOver || isPaused || gameMode !== '2p') return;
|
||||
const interval = setInterval(() => {
|
||||
if (!player1.gameOver) movePlayer(1, 'down');
|
||||
if (!player2.gameOver) movePlayer(2, 'down');
|
||||
}, TICK_SPEED);
|
||||
return () => clearInterval(interval);
|
||||
}, [gameStarted, gameOver, isPaused, gameMode, movePlayer, player1.gameOver, player2.gameOver]);
|
||||
|
||||
const renderBoard1P = () => {
|
||||
const displayBoard = board.map(row => [...row]);
|
||||
if (gameStarted && !gameOver && !gameComplete) {
|
||||
for (let y = 0; y < piece.shape.length; y++) {
|
||||
@@ -286,48 +476,191 @@ const Tetris = () => {
|
||||
));
|
||||
};
|
||||
|
||||
const renderBoard2P = (player: PlayerState, borderColor: string) => {
|
||||
const displayBoard = player.board.map(row => [...row]);
|
||||
if (gameStarted && !player.gameOver) {
|
||||
for (let y = 0; y < player.piece.shape.length; y++) {
|
||||
for (let x = 0; x < player.piece.shape[y].length; x++) {
|
||||
if (player.piece.shape[y][x]) {
|
||||
const boardY = player.piece.y + y;
|
||||
const boardX = player.piece.x + x;
|
||||
if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) displayBoard[boardY][boardX] = player.piece.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return displayBoard.map((row, y) => (
|
||||
<div key={y} className="flex">
|
||||
{row.map((cell, x) => (
|
||||
<div
|
||||
key={`${y}-${x}`}
|
||||
className={`border transition-colors duration-100 ${borderColor}`}
|
||||
style={{
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
backgroundColor: cell || 'transparent',
|
||||
boxShadow: cell ? `0 0 5px ${cell}` : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
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">Tetris</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">
|
||||
<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">Tetris</h1>
|
||||
<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">
|
||||
Tetris {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">{renderBoard()}</div>
|
||||
{!isMobile && (
|
||||
<div className="flex flex-col gap-2 min-w-[140px]">
|
||||
<div className="border border-primary/50 p-3 bg-background/50 flex flex-col items-center justify-center min-h-[80px]">
|
||||
<p className="font-pixel text-[10px] text-foreground/60 w-full mb-2">NEXT</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{nextPiece.shape.map((row, y) => (
|
||||
<div key={y} className="flex gap-1">
|
||||
{row.map((val, x) => (
|
||||
<div key={`${y}-${x}`} className={`w-3 h-3 ${val ? 'bg-primary box-glow' : 'bg-transparent'}`} />
|
||||
{gameMode === '1p' ? (
|
||||
<>
|
||||
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard1P()}</div>
|
||||
{!isMobile && (
|
||||
<div className="flex flex-col gap-2 min-w-[140px]">
|
||||
<div className="border border-primary/50 p-3 bg-background/50 flex flex-col items-center justify-center min-h-[80px]">
|
||||
<p className="font-pixel text-[10px] text-foreground/60 w-full mb-2">NEXT</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{nextPiece.shape.map((row, y) => (
|
||||
<div key={y} className="flex gap-1">
|
||||
{row.map((val, x) => (
|
||||
<div key={`${y}-${x}`} className={`w-3 h-3 ${val ? 'bg-primary box-glow' : 'bg-transparent'}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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">LINES</p><p className="font-minecraft text-lg text-primary text-glow">{lines}</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">Space: Drop</p><p className="font-pixel text-[10px] text-foreground/80">P: Pause</p></div>
|
||||
{!gameStarted || gameOver || gameComplete ? (
|
||||
<button onClick={() => startGame('1p')} 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={togglePause} 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>
|
||||
</div>
|
||||
<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">LINES</p><p className="font-minecraft text-lg text-primary text-glow">{lines}</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">Space: Drop</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={togglePause} 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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* P1 Board */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-sm bg-primary" />
|
||||
<span className="font-pixel text-xs text-primary">P1 (WASD/Q)</span>
|
||||
</div>
|
||||
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard2P(player1, 'border-primary/20')}</div>
|
||||
<div className="flex gap-2 text-center">
|
||||
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-primary">{player1.score}</p></div>
|
||||
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-primary">{player1.lines}</p></div>
|
||||
</div>
|
||||
{player1.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
||||
</div>
|
||||
|
||||
{/* Center controls */}
|
||||
<div className="flex flex-col gap-2 min-w-[100px] items-center">
|
||||
<div className="border border-primary/50 p-2 bg-background/50 text-center">
|
||||
<p className="font-pixel text-[8px] text-foreground/60 mb-1">CONTROLS</p>
|
||||
<p className="font-pixel text-[8px] text-primary">P1: WASD Q</p>
|
||||
<p className="font-pixel text-[8px] text-purple-400">P2: ↑↓←→ /</p>
|
||||
<p className="font-pixel text-[8px] text-foreground/60 mt-1">P: Pause</p>
|
||||
</div>
|
||||
{!gameStarted || gameOver ? (
|
||||
<button onClick={() => startGame('2p')} className="font-minecraft text-xs py-2 px-3 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
||||
{gameOver ? 'AGAIN' : 'START'}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={togglePause} className="font-minecraft text-xs py-2 px-3 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all">
|
||||
{isPaused ? 'RESUME' : 'PAUSE'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* P2 Board */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: 'hsl(280 70% 50%)' }} />
|
||||
<span className="font-pixel text-xs text-purple-400">P2 (↑↓←→/)</span>
|
||||
</div>
|
||||
<div className="border-2 border-purple-500 p-1 bg-background/80" style={{ boxShadow: '0 0 10px hsl(280 70% 50% / 0.5)' }}>{renderBoard2P(player2, 'border-purple-500/20')}</div>
|
||||
<div className="flex gap-2 text-center">
|
||||
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-purple-400">{player2.score}</p></div>
|
||||
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-purple-400">{player2.lines}</p></div>
|
||||
</div>
|
||||
{player2.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isMobile && (
|
||||
|
||||
{isMobile && gameMode === '1p' && (
|
||||
<>
|
||||
<MobileControlsSpacer />
|
||||
<MobileControlsDock>
|
||||
@@ -351,19 +684,42 @@ const Tetris = () => {
|
||||
<div />
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<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">{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">Lines: {lines}</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>
|
||||
<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">Lines: {lines}</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">{player1.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">{player2.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>
|
||||
)}
|
||||
@@ -371,4 +727,4 @@ const Tetris = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Tetris;
|
||||
export default Tetris;
|
||||
Reference in New Issue
Block a user