diff --git a/src/pages/Pacman.tsx b/src/pages/Pacman.tsx index 6417222..129b557 100644 --- a/src/pages/Pacman.tsx +++ b/src/pages/Pacman.tsx @@ -46,20 +46,38 @@ const MAZE_TEMPLATE = [ const Pacman = () => { const { playSound } = useSettings(); const { checkGameScoreAchievements, unlockMaxScore } = useAchievements(); + + // Game mode selection + const [gameMode, setGameMode] = useState<'1p' | '2p'>('1p'); + const [showModeSelection, setShowModeSelection] = useState(true); + + // Player 1 (always active) const [pacman, setPacman] = useState({ x: 10, y: 15 }); const [direction, setDirection] = useState('right'); const [nextDirection, setNextDirection] = useState('right'); const [mouthOpen, setMouthOpen] = useState(true); + const [lives, setLives] = useState(3); + + // Player 2 (for 2P mode) + const [pacman2, setPacman2] = useState({ x: 10, y: 15 }); + const [direction2, setDirection2] = useState('right'); + const [nextDirection2, setNextDirection2] = useState('right'); + const [mouthOpen2, setMouthOpen2] = useState(true); + const [lives2, setLives2] = useState(3); + const [player1Eliminated, setPlayer1Eliminated] = useState(false); + const [player2Eliminated, setPlayer2Eliminated] = useState(false); + const [showEliminationPrompt, setShowEliminationPrompt] = useState(false); const [ghosts, setGhosts] = useState<{ pos: Position; dir: Direction; eaten: boolean }[]>([ { pos: { x: 9, y: 9 }, dir: 'left', eaten: false }, { pos: { x: 10, y: 9 }, dir: 'up', eaten: false }, { pos: { x: 11, y: 9 }, dir: 'right', eaten: false }, ]); - const [dots, setDots] = useState>(new Set()); - const [powerPellets, setPowerPellets] = useState>(new Set()); - const [isPowered, setIsPowered] = useState(false); - const [score, setScore] = useState(0); - const [highScore, setHighScore] = useState(0); + const [dots, setDots] = useState>(new Set()); + const [powerPellets, setPowerPellets] = useState>(new Set()); + const [isPowered, setIsPowered] = useState(false); + const [score, setScore] = useState(0); + const [score2, setScore2] = useState(0); + const [highScore, setHighScore] = useState(0); const [gameOver, setGameOver] = useState(false); const [gameComplete, setGameComplete] = useState(false); const [gameStarted, setGameStarted] = useState(false); @@ -163,46 +181,174 @@ const Pacman = () => { const startGame = () => { if (powerTimerRef.current) clearTimeout(powerTimerRef.current); setPacman({ x: 10, y: 15 }); setDirection('right'); setNextDirection('right'); setMouthOpen(true); + setPacman2({ x: 10, y: 15 }); setDirection2('right'); setNextDirection2('right'); setMouthOpen2(true); + setLives(3); setLives2(3); setGhosts([{ pos: { x: 9, y: 9 }, dir: 'left', eaten: false }, { pos: { x: 10, y: 9 }, dir: 'up', eaten: false }, { pos: { x: 11, y: 9 }, dir: 'right', eaten: false }]); const { dots: newDots, powerPellets: newPowerPellets } = initDots(); - setDots(newDots); setPowerPellets(newPowerPellets); setIsPowered(false); setScore(0); setLevel(1); - setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); + setDots(newDots); setPowerPellets(newPowerPellets); setIsPowered(false); setScore(0); setScore2(0); setLevel(1); + setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); setShowModeSelection(false); + setPlayer1Eliminated(false); setPlayer2Eliminated(false); setShowEliminationPrompt(false); playSound('success'); gameRef.current?.focus(); }; + const continueIn1PMode = () => { + setGameMode('1p'); + setShowEliminationPrompt(false); + setIsPaused(false); + playSound('success'); + }; + useEffect(() => { return () => { if (powerTimerRef.current) clearTimeout(powerTimerRef.current); }; }, []); useEffect(() => { if (!gameStarted || gameOver || gameComplete || isPaused) return; const interval = setInterval(() => { - setMouthOpen(prev => !prev); - if (canMove(pacman, nextDirection)) setDirection(nextDirection); - const actualDir = canMove(pacman, nextDirection) ? nextDirection : direction; - if (canMove(pacman, actualDir)) { - const newPos = moveEntity(pacman, actualDir); - setPacman(newPos); - const posKey = `${newPos.x},${newPos.y}`; - if (powerPellets.has(posKey)) { - setPowerPellets(prev => { const np = new Set(prev); np.delete(posKey); return np; }); - activatePowerMode(); - setScore(prev => { - const ns = Math.min(prev + 50, MAX_SCORE); - if (ns >= MAX_SCORE) setShowGlitchCrash(true); - if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } - return ns; - }); - playSound('success'); - } else if (dots.has(posKey)) { - setDots(prev => { const nd = new Set(prev); nd.delete(posKey); return nd; }); - setScore(prev => { - const ns = Math.min(prev + 10, MAX_SCORE); - if (ns >= MAX_SCORE) setShowGlitchCrash(true); - if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } - return ns; - }); - playSound('hover'); + // Handle both players + const handlePlayerMovement = ( + player: Position, playerDir: Direction, playerNextDir: Direction, + setPlayerDir: (dir: Direction) => void, setPlayer: (pos: Position) => void, + setPlayerScore: (fn: (prev: number) => number) => void, + setPlayerMouth?: (fn: (prev: boolean) => boolean) => void + ) => { + // Animate mouth every tick for smoother appearance + if (setPlayerMouth) setPlayerMouth(prev => !prev); + + if (canMove(player, playerNextDir)) setPlayerDir(playerNextDir); + const actualDir = canMove(player, playerNextDir) ? playerNextDir : playerDir; + + if (canMove(player, actualDir)) { + const newPos = moveEntity(player, actualDir); + + // Check for ghosts at target position BEFORE moving + let ghostAtTarget = null; + let ghostIndex = -1; + for (let i = 0; i < ghosts.length; i++) { + if (ghosts[i].pos.x === newPos.x && ghosts[i].pos.y === newPos.y) { + ghostAtTarget = ghosts[i]; + ghostIndex = i; + break; + } + } + + if (ghostAtTarget) { + // There's a ghost at the target position + if (isPowered && !ghostAtTarget.eaten) { + // Eat the ghost - move to position and score + setPlayer(newPos); + setGhosts(prev => prev.map((g, idx) => idx === ghostIndex ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g)); + setPlayerScore(prev => { + const ns = Math.min(prev + 200, MAX_SCORE); + if (ns >= MAX_SCORE) setShowGlitchCrash(true); + if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } + return ns; + }); + playSound('success'); + } else if (!ghostAtTarget.eaten) { + // Hit by ghost - lose life without moving + const isPlayer1 = player === pacman; + const currentLives = isPlayer1 ? lives : lives2; + const setLivesFunc = isPlayer1 ? setLives : setLives2; + + const newLives = currentLives - 1; + setLivesFunc(newLives); + + if (newLives > 0) { + // Respawn at start + setPlayer({ x: 10, y: 15 }); + playSound('error'); + } else { + // Player eliminated + if (isPlayer1) { + setPlayer1Eliminated(true); + setPacman({ x: -1, y: -1 }); + } else { + setPlayer2Eliminated(true); + setPacman2({ x: -1, y: -1 }); + } + playSound('error'); + + if (gameMode === '2p' && ((isPlayer1 && !player2Eliminated) || (!isPlayer1 && !player1Eliminated))) { + setShowEliminationPrompt(true); + setIsPaused(true); + } else { + setGameOver(true); + } + } + } + } else { + // No ghost at target, safe to move + setPlayer(newPos); + + const posKey = `${newPos.x},${newPos.y}`; + if (powerPellets.has(posKey)) { + setPowerPellets(prev => { const np = new Set(prev); np.delete(posKey); return np; }); + activatePowerMode(); + setPlayerScore(prev => { + const ns = Math.min(prev + 50, MAX_SCORE); + if (ns >= MAX_SCORE) setShowGlitchCrash(true); + if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } + return ns; + }); + playSound('success'); + } else if (dots.has(posKey)) { + setDots(prev => { const nd = new Set(prev); nd.delete(posKey); return nd; }); + setPlayerScore(prev => { + const ns = Math.min(prev + 10, MAX_SCORE); + if (ns >= MAX_SCORE) setShowGlitchCrash(true); + if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } + return ns; + }); + playSound('hover'); + } + } + } + }; + + // Player 1 (only if not eliminated) + if (!player1Eliminated) { + handlePlayerMovement(pacman, direction, nextDirection, setDirection, setPacman, setScore, setMouthOpen); + + // Check collisions immediately after movement + for (let i = 0; i < ghosts.length; i++) { + const ghost = ghosts[i]; + if (ghost.pos.x === pacman.x && ghost.pos.y === pacman.y) { + if (isPowered && !ghost.eaten) { + setGhosts(prev => prev.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g)); + setScore(prev => { + const ns = Math.min(prev + 200, MAX_SCORE); + if (ns >= MAX_SCORE) setShowGlitchCrash(true); + if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } + return ns; + }); + playSound('success'); + } else if (!ghost.eaten) { + // Player 1 dies + const newLives = lives - 1; + setLives(newLives); + if (newLives > 0) { + setPacman({ x: 10, y: 15 }); + playSound('error'); + } else { + setPlayer1Eliminated(true); + setPacman({ x: -1, y: -1 }); + playSound('error'); + if (gameMode === '2p' && !player2Eliminated) { + setShowEliminationPrompt(true); + setIsPaused(true); + } else { + setGameOver(true); + } + } + } + } } } + + // Player 2 (only in 2P mode and not eliminated) + if (gameMode === '2p' && !player2Eliminated) { + handlePlayerMovement(pacman2, direction2, nextDirection2, setDirection2, setPacman2, setScore2, setMouthOpen2); + } + setGhosts(prev => prev.map(ghost => { if (ghost.eaten) return ghost; const directions: Direction[] = ['up', 'down', 'left', 'right']; @@ -222,63 +368,178 @@ const Pacman = () => { const dir = validDirs[Math.floor(Math.random() * validDirs.length)]; return { ...ghost, pos: moveEntity(ghost.pos, dir), dir }; })); + + // Now check for collisions after all movement is complete + // Check Player 1 collisions (from both player movement and ghost movement) + if (!player1Eliminated) { + setGhosts(currentGhosts => { + for (let i = 0; i < currentGhosts.length; i++) { + const ghost = currentGhosts[i]; + if (ghost.pos.x === pacman.x && ghost.pos.y === pacman.y) { + if (isPowered && !ghost.eaten) { + // Player eats ghost + setScore(prev => { + const ns = Math.min(prev + 200, MAX_SCORE); + if (ns >= MAX_SCORE) setShowGlitchCrash(true); + if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } + return ns; + }); + playSound('success'); + return currentGhosts.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g); + } else if (!ghost.eaten) { + // Ghost kills player + const newLives = lives - 1; + setLives(newLives); + if (newLives > 0) { + setPacman({ x: 10, y: 15 }); + playSound('error'); + } else { + setPlayer1Eliminated(true); + setPacman({ x: -1, y: -1 }); + playSound('error'); + if (gameMode === '2p' && !player2Eliminated) { + setShowEliminationPrompt(true); + setIsPaused(true); + } else { + setGameOver(true); + } + } + } + } + } + return currentGhosts; + }); + } + + // Check Player 2 collisions + if (gameMode === '2p' && !player2Eliminated) { + setGhosts(currentGhosts => { + for (let i = 0; i < currentGhosts.length; i++) { + const ghost = currentGhosts[i]; + if (ghost.pos.x === pacman2.x && ghost.pos.y === pacman2.y) { + if (isPowered && !ghost.eaten) { + // Player eats ghost + setScore2(prev => { + const ns = Math.min(prev + 200, MAX_SCORE); + if (ns >= MAX_SCORE) setShowGlitchCrash(true); + if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } + return ns; + }); + playSound('success'); + return currentGhosts.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g); + } else if (!ghost.eaten) { + // Ghost kills player + const newLives = lives2 - 1; + setLives2(newLives); + if (newLives > 0) { + setPacman2({ x: 10, y: 15 }); + playSound('error'); + } else { + setPlayer2Eliminated(true); + setPacman2({ x: -1, y: -1 }); + playSound('error'); + if (!player1Eliminated) { + setShowEliminationPrompt(true); + setIsPaused(true); + } else { + setGameOver(true); + } + } + } + } + } + return currentGhosts; + }); + } }, TICK_SPEED); return () => clearInterval(interval); - }, [gameStarted, gameOver, gameComplete, isPaused, pacman, direction, nextDirection, dots, powerPellets, highScore, isPowered, playSound]); + }, [gameStarted, gameOver, gameComplete, isPaused, pacman, direction, nextDirection, pacman2, direction2, nextDirection2, dots, powerPellets, highScore, isPowered, playSound, gameMode, player1Eliminated, player2Eliminated]); - useEffect(() => { - if (!gameStarted || gameOver || gameComplete) return; - for (let i = 0; i < ghosts.length; i++) { - const ghost = ghosts[i]; - if (ghost.pos.x === pacman.x && ghost.pos.y === pacman.y) { - if (isPowered && !ghost.eaten) { - setGhosts(prev => prev.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g)); - setScore(prev => { - const ns = Math.min(prev + 200, MAX_SCORE); - if (ns >= MAX_SCORE) setShowGlitchCrash(true); - if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } - return ns; - }); - playSound('success'); - } else if (!ghost.eaten) { setGameOver(true); playSound('error'); return; } - } - } - if (dots.size === 0 && powerPellets.size === 0) { - setScore(prev => { - const ns = Math.min(prev + 500, MAX_SCORE); - if (ns >= MAX_SCORE) setShowGlitchCrash(true); - if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } - return ns; - }); - setLevel(prev => prev + 1); - const { dots: newDots, powerPellets: newPowerPellets } = initDots(); - setDots(newDots); setPowerPellets(newPowerPellets); - playSound('success'); - } - }, [pacman, ghosts, dots, powerPellets, gameStarted, gameOver, gameComplete, highScore, isPowered, playSound, initDots]); + + // More responsive input - update direction immediately when pressed useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (!gameStarted) return; + if (!gameStarted || gameOver || gameComplete || isPaused) return; + + // Player 1 controls (WASD and Arrow keys) - immediate response switch (e.key) { - case 'ArrowUp': case 'w': e.preventDefault(); setNextDirection('up'); break; - case 'ArrowDown': case 's': e.preventDefault(); setNextDirection('down'); break; - case 'ArrowLeft': case 'a': e.preventDefault(); setNextDirection('left'); break; - case 'ArrowRight': case 'd': e.preventDefault(); setNextDirection('right'); break; - case 'p': e.preventDefault(); if (!gameOver && !gameComplete) setIsPaused(prev => !prev); break; + case 'ArrowUp': case 'w': case 'W': + e.preventDefault(); + if (!player1Eliminated) setNextDirection('up'); + break; + case 'ArrowDown': case 's': case 'S': + e.preventDefault(); + if (!player1Eliminated) setNextDirection('down'); + break; + case 'ArrowLeft': case 'a': case 'A': + e.preventDefault(); + if (!player1Eliminated) setNextDirection('left'); + break; + case 'ArrowRight': case 'd': case 'D': + e.preventDefault(); + if (!player1Eliminated) setNextDirection('right'); + break; + } + + // Player 2 controls (IJKL keys) - only in 2P mode + if (gameMode === '2p') { + switch (e.key) { + case 'i': case 'I': + e.preventDefault(); + if (!player2Eliminated) setNextDirection2('up'); + break; + case 'k': case 'K': + e.preventDefault(); + if (!player2Eliminated) setNextDirection2('down'); + break; + case 'j': case 'J': + e.preventDefault(); + if (!player2Eliminated) setNextDirection2('left'); + break; + case 'l': case 'L': + e.preventDefault(); + if (!player2Eliminated) setNextDirection2('right'); + break; + } } }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (!gameStarted || gameOver || gameComplete || isPaused) return; + + // Pause (works for both modes) + if (e.key === 'p' || e.key === 'P') { + e.preventDefault(); + if (!gameOver && !gameComplete) setIsPaused(prev => !prev); + } + }; + window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [gameStarted, gameOver, gameComplete]); + window.addEventListener('keyup', handleKeyUp); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, [gameStarted, gameOver, gameComplete, isPaused, gameMode, player1Eliminated, player2Eliminated]); const getPacmanRotation = () => { switch (direction) { case 'right': return 0; case 'down': return 90; case 'left': return 180; case 'up': return 270; } }; + const getPacmanRotation2 = () => { switch (direction2) { case 'right': return 0; case 'down': return 90; case 'left': return 180; case 'up': return 270; } }; + const renderPacman = () => ( - - - {mouthOpen && } - + + + {mouthOpen && } + + + ); + + const renderPacman2 = () => ( + + + {mouthOpen2 && } + ); @@ -303,15 +564,17 @@ const Pacman = () => { const isWall = MAZE_TEMPLATE[y][x] === 1; const isTunnel = MAZE_TEMPLATE[y][x] === 2; const isPacmanHere = pacman.x === x && pacman.y === y; + const isPacman2Here = gameMode === '2p' && pacman2.x === x && pacman2.y === y; const ghostIndex = ghosts.findIndex(g => g.pos.x === x && g.pos.y === y && !g.eaten); const isDot = dots.has(`${x},${y}`); const isPowerPellet = powerPellets.has(`${x},${y}`); cells.push( -
- {isPacmanHere &&
{renderPacman()}
} - {ghostIndex !== -1 && !isPacmanHere &&
{renderGhost(ghostIndex, ghosts[ghostIndex].eaten)}
} - {isDot && !isPacmanHere && ghostIndex === -1 &&
} - {isPowerPellet && !isPacmanHere && ghostIndex === -1 &&
} +
+ {isPacmanHere &&
{renderPacman()}
} + {isPacman2Here &&
{renderPacman2()}
} + {ghostIndex !== -1 && !isPacmanHere && !isPacman2Here &&
{renderGhost(ghostIndex, ghosts[ghostIndex].eaten)}
} + {isDot && !isPacmanHere && !isPacman2Here && ghostIndex === -1 &&
} + {isPowerPellet && !isPacmanHere && !isPacman2Here && ghostIndex === -1 &&
}
); } @@ -335,17 +598,60 @@ const Pacman = () => {
-
-
{renderGrid()}
+
+
+
{renderGrid()}
{!isMobile && (
-

SCORE

{score.toLocaleString()}

+ {gameMode === '2p' ? ( +
+

SCORES

+
+

P1

{score.toLocaleString()}

+

P2

{score2.toLocaleString()}

+
+
+ ) : ( +

SCORE

{score.toLocaleString()}

+ )}

HIGH SCORE

{highScore.toLocaleString()}

max: 4,294,967,296

LEVEL

{level}

-

CONTROLS

← → ↑ ↓ / WASD

P: Pause

- {!gameStarted || gameOver || gameComplete ? ( + {gameMode === '2p' && ( +
+

LIVES

+
+

P1

{lives}

+

P2

{lives2}

+
+
+ )} + {gameMode === '1p' && ( +

LIVES

{lives}

+ )} +
+

CONTROLS

+ {gameMode === '2p' ? ( + <> +

P1: WASD / ←→↑↓

+

P2: I J K L

+

P: Pause

+ + ) : ( + <> +

WASD / ←→↑↓

+

P: Pause

+ + )} +
+ {showModeSelection ? ( +
+

SELECT MODE

+ + +
+ ) : !gameStarted || gameOver || gameComplete ? ( ) : ( @@ -358,40 +664,100 @@ const Pacman = () => {
-
-

SCORE

{score.toLocaleString()}

-

HIGH

{highScore.toLocaleString()}

-

LVL

{level}

-
+
+ {gameMode === '2p' ? ( + <> +

P1

{score.toLocaleString()}

+

P2

{score2.toLocaleString()}

+

LVL

{level}

+ + ) : ( + <> +

SCORE

{score.toLocaleString()}

+

HIGH

{highScore.toLocaleString()}

+

LVL

{level}

+ + )} + {gameMode === '2p' && ( + <> +

P1

{lives}

+

P2

{lives2}

+ + )} + {gameMode === '1p' && ( +

LIVES

{lives}

+ )} +
- {gameStarted && !gameOver && !gameComplete ? ( -
-
- setNextDirection('up')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↑ -
- setNextDirection('left')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">← - - setNextDirection('right')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">→ -
- setNextDirection('down')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↓ -
-
- ) : ( - - )} + {showModeSelection ? ( +
+

SELECT MODE

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

{player1Eliminated ? 'PLAYER 2 WON!' : 'PLAYER 1 WON!'}

+

+ {player1Eliminated ? 'Player 1' : 'Player 2'} has run out of lives.
+ Continue with the remaining player? +

+
+ + +
+
+
+ )} + + {!isMobile && (gameOver || isPaused || gameComplete) && gameStarted && !showEliminationPrompt && (

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

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

Final Score: {score.toLocaleString()}

Level: {level}

)} + {(gameOver || gameComplete) && ( + <> + {gameMode === '2p' ? ( + <> +

Final Scores

+
+

P1

{score.toLocaleString()}

+

P2

{score2.toLocaleString()}

+
+ + ) : ( +

Final Score: {score.toLocaleString()}

+ )} +

Level: {level}

+ + )}