From 75946c9feb76f5841f419535d98052e26db077a9 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:50:06 +0000 Subject: [PATCH] Changes --- src/App.tsx | 2 - src/pages/Breakout.tsx | 3 +- src/pages/Games.tsx | 58 +--- src/pages/Snake.tsx | 406 ++++++++++++++++++++++++--- src/pages/SnakeMultiplayer.tsx | 397 --------------------------- src/pages/Tetris.tsx | 486 ++++++++++++++++++++++++++++----- 6 files changed, 795 insertions(+), 557 deletions(-) delete mode 100644 src/pages/SnakeMultiplayer.tsx diff --git a/src/App.tsx b/src/App.tsx index 1ad53d7..8db75ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/pages/Breakout.tsx b/src/pages/Breakout.tsx index 85d0580..2faa72a 100644 --- a/src/pages/Breakout.tsx +++ b/src/pages/Breakout.tsx @@ -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]); diff --git a/src/pages/Games.tsx b/src/pages/Games.tsx index 779f3f3..956270d 100644 --- a/src/pages/Games.tsx +++ b/src/pages/Games.tsx @@ -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 ( { transition={{ delay: index * 0.1 }} > -
+
                   {game.ascii}
                 
@@ -110,43 +100,17 @@ const Games = () => {

{game.description}

+ {game.hasMultiplayer && ( + + 2P + + )}
))}
- {/* Multiplayer Section */} -
-

- -

-
- {multiplayerGames.map((game, index) => ( - - -
-
-                    {game.ascii}
-                  
-

- -

-

- {game.description} -

-
- -
- ))} -
-
-

{'>'} Max score: 4,294,967,296 @@ -157,4 +121,4 @@ const Games = () => { ); }; -export default Games; +export default Games; \ No newline at end of file diff --git a/src/pages/Snake.tsx b/src/pages/Snake.tsx index aea09f8..234c556 100644 --- a/src/pages/Snake.tsx +++ b/src/pages/Snake.tsx @@ -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(null); + + // Single player state const [snake, setSnake] = useState([{ x: 10, y: 10 }]); const [direction, setDirection] = useState('right'); const [food, setFood] = useState({ x: 15, y: 10 }); const [score, setScore] = useState(0); const [highScore, setHighScore] = useState(0); + + // Multiplayer state + const [players, setPlayers] = useState<[Player, Player]>([ + { snake: [{ x: 5, y: 10 }], direction: 'right', score: 0, alive: true, color: 'hsl(var(--primary))', name: 'P1' }, + { snake: [{ x: 15, y: 10 }], direction: 'left', score: 0, alive: true, color: 'hsl(280 70% 50%)', name: 'P2' }, + ]); + const [food2P, setFood2P] = useState({ x: 10, y: 10 }); + const [winner, setWinner] = useState(null); + + // Common state const [gameOver, setGameOver] = useState(false); const [gameComplete, setGameComplete] = useState(false); const [gameStarted, setGameStarted] = useState(false); @@ -34,21 +60,26 @@ const Snake = () => { const gameRef = useRef(null); const directionQueueRef = useRef([]); const currentDirectionRef = useRef('right'); + + // 2P direction refs + const directionQueues2P = useRef<[Direction[], Direction[]]>([[], []]); + const currentDirections2P = useRef<[Direction, Direction]>(['right', 'left']); const { enterFullscreen, exitFullscreen } = useBrowserFullscreen(); - // 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 = { 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 = { 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( +

+ {isFood &&
} +
+ ); + } + } + return cells; + }; + if (showGlitchCrash) return window.location.reload()} />; + // Mode selection screen + if (!gameMode) { + return ( + +
+ {'<'} Back +

Snake

+
+ +
+

Select game mode

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

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

+ )} + +
+

HIGH SCORE: {highScore.toLocaleString()}

+
+
+
+ ); + } + return ( { transition={{ duration: 0.5 }} className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-50 bg-background p-4' : 'h-full'}`} > - {/* Header */}
- {'<'} Back -

Snake

+ +

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

- {/* Main layout */}
- {/* Game Grid */}
-
{renderGrid()}
+
+ {gameMode === '1p' ? renderGrid1P() : renderGrid2P()} +
- {/* Side Panel */} - {!isMobile && ( + {!isMobile && gameMode === '1p' && (

SCORE

@@ -302,7 +566,7 @@ const Snake = () => {

P: Pause

{!gameStarted || gameOver || gameComplete ? ( - ) : ( @@ -313,7 +577,45 @@ const Snake = () => {
)} - {/* Mobile Controls */} + {!isMobile && gameMode === '2p' && ( +
+
+
+
+

P1 (WASD)

+
+

{players[0].score}

+ {!players[0].alive &&

DEAD

} +
+ +
+
+
+

P2 (↑↓←→)

+
+

{players[1].score}

+ {!players[1].alive &&

DEAD

} +
+ +
+

CONTROLS

+

P1: W A S D

+

P2: ↑ ↓ ← →

+

P: Pause

+
+ + {!gameStarted || gameOver ? ( + + ) : ( + + )} +
+ )} + {isMobile && ( <> @@ -338,7 +640,7 @@ const Snake = () => {
) : ( - )} @@ -348,18 +650,32 @@ const Snake = () => { )}
- {/* Desktop Overlay */} + {/* Overlays */} {!isMobile && (gameOver || isPaused || gameComplete) && gameStarted && (
-

{gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}

- {(gameOver || gameComplete) && ( +

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

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

Final Score: {score.toLocaleString()}

Length: {snake.length}

)} -
@@ -369,4 +685,4 @@ const Snake = () => { ); }; -export default Snake; +export default Snake; \ No newline at end of file diff --git a/src/pages/SnakeMultiplayer.tsx b/src/pages/SnakeMultiplayer.tsx deleted file mode 100644 index cb4d68f..0000000 --- a/src/pages/SnakeMultiplayer.tsx +++ /dev/null @@ -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({ x: 10, y: 10 }); - const [gameOver, setGameOver] = useState(false); - const [winner, setWinner] = useState(null); - const [gameStarted, setGameStarted] = useState(false); - const [isPaused, setIsPaused] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - const gameRef = useRef(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 = { 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( -
- {isFood &&
} -
- ); - } - } - return cells; - }; - - return ( - -
-
- {'<'} Back -

Snake 2P

-
- -
- -
-
-
{renderGrid()}
-
- - {!isMobile && ( -
- {/* Player 1 Stats */} -
-
-
-

PLAYER 1 (WASD)

-
-

{players[0].score}

- {!players[0].alive &&

DEAD

} -
- - {/* Player 2 Stats */} -
-
-
-

PLAYER 2 (↑↓←→)

-
-

{players[1].score}

- {!players[1].alive &&

DEAD

} -
- -
-

CONTROLS

-

P1: W A S D

-

P2: ↑ ↓ ← →

-

P: Pause

-
- - {!gameStarted || gameOver ? ( - - ) : ( - - )} -
- )} - - {isMobile && ( - <> - - -
-
-

P1

{players[0].score}

-

P2

{players[1].score}

-
- - {!gameStarted || gameOver ? ( - - ) : ( - - )} - -
- P1: WASD - P2: ↑↓←→ -
-
-
- - )} -
- - {/* Game Over / Pause Overlay */} - {(gameOver || isPaused) && gameStarted && ( -
-
-

- {gameOver ? winner : 'PAUSED'} -

- {gameOver && ( -
-
-

P1 Score

-

{players[0].score}

-
-
-

P2 Score

-

{players[1].score}

-
-
- )} - -
-
- )} - - ); -}; - -export default SnakeMultiplayer; \ No newline at end of file diff --git a/src/pages/Tetris.tsx b/src/pages/Tetris.tsx index 2e8de09..e791848 100644 --- a/src/pages/Tetris.tsx +++ b/src/pages/Tetris.tsx @@ -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(null); + + // 1P state const [board, setBoard] = useState(createBoard); const [piece, setPiece] = useState(randomTetromino); const [nextPiece, setNextPiece] = useState(randomTetromino); const [score, setScore] = useState(0); const [highScore, setHighScore] = useState(0); const [lines, setLines] = useState(0); + + // 2P state + const [player1, setPlayer1] = useState({ + board: createBoard(), + piece: randomTetromino(), + nextPiece: randomTetromino(), + score: 0, + lines: 0, + gameOver: false, + }); + const [player2, setPlayer2] = useState({ + board: createBoard(), + piece: randomTetromino(true), + nextPiece: randomTetromino(true), + score: 0, + lines: 0, + gameOver: false, + }); + const [winner, setWinner] = useState(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) => ( +
+ {row.map((cell, x) => ( +
+ ))} +
+ )); + }; + if (showGlitchCrash) return window.location.reload()} />; + // Mode selection screen + if (!gameMode) { + return ( + +
+ {'<'} Back +

Tetris

+
+ +
+

Select game mode

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

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

+ )} + +
+

HIGH SCORE: {highScore.toLocaleString()}

+
+
+
+ ); + } + return (
- {'<'} Back -

Tetris

+ +

+ Tetris {gameMode === '2p' && 2P} +

+
-
{renderBoard()}
- {!isMobile && ( -
-
-

NEXT

-
- {nextPiece.shape.map((row, y) => ( -
- {row.map((val, x) => ( -
+ {gameMode === '1p' ? ( + <> +
{renderBoard1P()}
+ {!isMobile && ( +
+
+

NEXT

+
+ {nextPiece.shape.map((row, y) => ( +
+ {row.map((val, x) => ( +
+ ))} +
))}
- ))} +
+

SCORE

{score.toLocaleString()}

+

HIGH SCORE

{highScore.toLocaleString()}

max: 4,294,967,296

+

LINES

{lines}

+

CONTROLS

← → ↑ ↓ / WASD

Space: Drop

P: Pause

+ {!gameStarted || gameOver || gameComplete ? ( + + ) : ( + + )}
-
-

SCORE

{score.toLocaleString()}

-

HIGH SCORE

{highScore.toLocaleString()}

max: 4,294,967,296

-

LINES

{lines}

-

CONTROLS

← → ↑ ↓ / WASD

Space: Drop

P: Pause

- {!gameStarted || gameOver || gameComplete ? ( - - ) : ( - )} -
+ + ) : ( + <> + {/* P1 Board */} +
+
+
+ P1 (WASD/Q) +
+
{renderBoard2P(player1, 'border-primary/20')}
+
+

SCORE

{player1.score}

+

LINES

{player1.lines}

+
+ {player1.gameOver && GAME OVER} +
+ + {/* Center controls */} +
+
+

CONTROLS

+

P1: WASD Q

+

P2: ↑↓←→ /

+

P: Pause

+
+ {!gameStarted || gameOver ? ( + + ) : ( + + )} +
+ + {/* P2 Board */} +
+
+
+ P2 (↑↓←→/) +
+
{renderBoard2P(player2, 'border-purple-500/20')}
+
+

SCORE

{player2.score}

+

LINES

{player2.lines}

+
+ {player2.gameOver && GAME OVER} +
+ )} - {isMobile && ( + + {isMobile && gameMode === '1p' && ( <> @@ -351,19 +684,42 @@ const Tetris = () => {
) : ( - + )}
)}
+ + {/* Overlays */} {!isMobile && (gameOver || isPaused || gameComplete) && gameStarted && (
-

{gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}

- {(gameOver || gameComplete) && (<>

Final Score: {score.toLocaleString()}

Lines: {lines}

)} - +

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

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

Final Score: {score.toLocaleString()}

+

Lines: {lines}

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

P1 Score

+

{player1.score}

+
+
+

P2 Score

+

{player2.score}

+
+
+ )} +
)} @@ -371,4 +727,4 @@ const Tetris = () => { ); }; -export default Tetris; +export default Tetris; \ No newline at end of file