Complete 2P Pacman implementation with invincibility and fixes
- Add 2P multiplayer mode with individual scoring and lives - Implement player elimination system with continue/restart options - Fix collision detection for moving into ghosts - Add 3-second respawn invincibility like original Pacman - Visual effects: pulsing/glow during invincibility, smoother animations - Controls: P1 uses WASD+arrows in 1P, WASD only in 2P; P2 uses arrows - Fix continue in 1P mode transferring surviving player state - Revert ghost AI to simple targeting for reliability
This commit is contained in:
+76
-119
@@ -67,6 +67,8 @@ const Pacman = () => {
|
|||||||
const [player1Eliminated, setPlayer1Eliminated] = useState(false);
|
const [player1Eliminated, setPlayer1Eliminated] = useState(false);
|
||||||
const [player2Eliminated, setPlayer2Eliminated] = useState(false);
|
const [player2Eliminated, setPlayer2Eliminated] = useState(false);
|
||||||
const [showEliminationPrompt, setShowEliminationPrompt] = useState(false);
|
const [showEliminationPrompt, setShowEliminationPrompt] = useState(false);
|
||||||
|
const [player1Invincible, setPlayer1Invincible] = useState(false);
|
||||||
|
const [player2Invincible, setPlayer2Invincible] = useState(false);
|
||||||
const [ghosts, setGhosts] = useState<{ pos: Position; dir: Direction; eaten: boolean }[]>([
|
const [ghosts, setGhosts] = useState<{ pos: Position; dir: Direction; eaten: boolean }[]>([
|
||||||
{ pos: { x: 9, y: 9 }, dir: 'left', eaten: false },
|
{ pos: { x: 9, y: 9 }, dir: 'left', eaten: false },
|
||||||
{ pos: { x: 10, y: 9 }, dir: 'up', eaten: false },
|
{ pos: { x: 10, y: 9 }, dir: 'up', eaten: false },
|
||||||
@@ -188,10 +190,44 @@ const Pacman = () => {
|
|||||||
setDots(newDots); setPowerPellets(newPowerPellets); setIsPowered(false); setScore(0); setScore2(0); setLevel(1);
|
setDots(newDots); setPowerPellets(newPowerPellets); setIsPowered(false); setScore(0); setScore2(0); setLevel(1);
|
||||||
setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); setShowModeSelection(false);
|
setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); setShowModeSelection(false);
|
||||||
setPlayer1Eliminated(false); setPlayer2Eliminated(false); setShowEliminationPrompt(false);
|
setPlayer1Eliminated(false); setPlayer2Eliminated(false); setShowEliminationPrompt(false);
|
||||||
|
setPlayer1Invincible(false); setPlayer2Invincible(false);
|
||||||
playSound('success'); gameRef.current?.focus();
|
playSound('success'); gameRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const continueIn1PMode = () => {
|
const continueIn1PMode = () => {
|
||||||
|
// Transfer the surviving player's state to Player 1
|
||||||
|
if (player1Eliminated && !player2Eliminated) {
|
||||||
|
// Player 1 died, Player 2 survives - transfer P2 to P1
|
||||||
|
setPacman(pacman2);
|
||||||
|
setDirection(direction2);
|
||||||
|
setNextDirection(nextDirection2);
|
||||||
|
setMouthOpen(mouthOpen2);
|
||||||
|
setLives(lives2);
|
||||||
|
setScore(score2);
|
||||||
|
setPlayer1Invincible(player2Invincible);
|
||||||
|
|
||||||
|
// Reset Player 2
|
||||||
|
setPacman2({ x: -1, y: -1 });
|
||||||
|
setDirection2('right');
|
||||||
|
setNextDirection2('right');
|
||||||
|
setMouthOpen2(true);
|
||||||
|
setLives2(3);
|
||||||
|
setScore2(0);
|
||||||
|
setPlayer2Invincible(false);
|
||||||
|
} else if (player2Eliminated && !player1Eliminated) {
|
||||||
|
// Player 2 died, Player 1 survives - P1 already active, just reset P2
|
||||||
|
setPacman2({ x: -1, y: -1 });
|
||||||
|
setDirection2('right');
|
||||||
|
setNextDirection2('right');
|
||||||
|
setMouthOpen2(true);
|
||||||
|
setLives2(3);
|
||||||
|
setScore2(0);
|
||||||
|
setPlayer2Invincible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset eliminated states and continue
|
||||||
|
setPlayer1Eliminated(false);
|
||||||
|
setPlayer2Eliminated(false);
|
||||||
setGameMode('1p');
|
setGameMode('1p');
|
||||||
setShowEliminationPrompt(false);
|
setShowEliminationPrompt(false);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
@@ -321,14 +357,16 @@ const Pacman = () => {
|
|||||||
return ns;
|
return ns;
|
||||||
});
|
});
|
||||||
playSound('success');
|
playSound('success');
|
||||||
} else if (!ghost.eaten) {
|
} else if (!ghost.eaten && !player1Invincible) {
|
||||||
// Player 1 dies
|
// Player 1 dies (only if not invincible)
|
||||||
const newLives = lives - 1;
|
const newLives = lives - 1;
|
||||||
setLives(newLives);
|
setLives(newLives);
|
||||||
if (newLives > 0) {
|
if (newLives > 0) {
|
||||||
setPacman({ x: 10, y: 15 });
|
setPacman({ x: 10, y: 15 });
|
||||||
playSound('error');
|
setPlayer1Invincible(true);
|
||||||
} else {
|
setTimeout(() => setPlayer1Invincible(false), 3000); // 3 seconds of invincibility
|
||||||
|
playSound('error');
|
||||||
|
} else {
|
||||||
setPlayer1Eliminated(true);
|
setPlayer1Eliminated(true);
|
||||||
setPacman({ x: -1, y: -1 });
|
setPacman({ x: -1, y: -1 });
|
||||||
playSound('error');
|
playSound('error');
|
||||||
@@ -369,135 +407,54 @@ const Pacman = () => {
|
|||||||
return { ...ghost, pos: moveEntity(ghost.pos, dir), dir };
|
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);
|
}, TICK_SPEED);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [gameStarted, gameOver, gameComplete, isPaused, pacman, direction, nextDirection, pacman2, direction2, nextDirection2, dots, powerPellets, highScore, isPowered, playSound, gameMode, player1Eliminated, player2Eliminated]);
|
}, [gameStarted, gameOver, gameComplete, isPaused, pacman, direction, nextDirection, pacman2, direction2, nextDirection2, dots, powerPellets, highScore, isPowered, playSound, gameMode, player1Eliminated, player2Eliminated]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// More responsive input - update direction immediately when pressed
|
// More responsive input - update direction immediately when pressed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!gameStarted || gameOver || gameComplete || isPaused) return;
|
if (!gameStarted || gameOver || gameComplete || isPaused) return;
|
||||||
|
|
||||||
// Player 1 controls (WASD and Arrow keys) - immediate response
|
// Player 1 controls - WASD always, arrows only in 1P mode
|
||||||
switch (e.key) {
|
if (e.key === 'w' || e.key === 'W' || e.key === 's' || e.key === 'S' || e.key === 'a' || e.key === 'A' || e.key === 'd' || e.key === 'D' ||
|
||||||
case 'ArrowUp': case 'w': case 'W':
|
(gameMode === '1p' && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight'))) {
|
||||||
e.preventDefault();
|
switch (e.key) {
|
||||||
if (!player1Eliminated) setNextDirection('up');
|
case 'ArrowUp': case 'w': case 'W':
|
||||||
break;
|
e.preventDefault();
|
||||||
case 'ArrowDown': case 's': case 'S':
|
if (!player1Eliminated) setNextDirection('up');
|
||||||
e.preventDefault();
|
break;
|
||||||
if (!player1Eliminated) setNextDirection('down');
|
case 'ArrowDown': case 's': case 'S':
|
||||||
break;
|
e.preventDefault();
|
||||||
case 'ArrowLeft': case 'a': case 'A':
|
if (!player1Eliminated) setNextDirection('down');
|
||||||
e.preventDefault();
|
break;
|
||||||
if (!player1Eliminated) setNextDirection('left');
|
case 'ArrowLeft': case 'a': case 'A':
|
||||||
break;
|
e.preventDefault();
|
||||||
case 'ArrowRight': case 'd': case 'D':
|
if (!player1Eliminated) setNextDirection('left');
|
||||||
e.preventDefault();
|
break;
|
||||||
if (!player1Eliminated) setNextDirection('right');
|
case 'ArrowRight': case 'd': case 'D':
|
||||||
break;
|
e.preventDefault();
|
||||||
|
if (!player1Eliminated) setNextDirection('right');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player 2 controls (IJKL keys) - only in 2P mode
|
// Player 2 controls (Arrow keys) - only in 2P mode
|
||||||
if (gameMode === '2p') {
|
if (gameMode === '2p') {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'i': case 'I':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!player2Eliminated) setNextDirection2('up');
|
if (!player2Eliminated) setNextDirection2('up');
|
||||||
break;
|
break;
|
||||||
case 'k': case 'K':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!player2Eliminated) setNextDirection2('down');
|
if (!player2Eliminated) setNextDirection2('down');
|
||||||
break;
|
break;
|
||||||
case 'j': case 'J':
|
case 'ArrowLeft':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!player2Eliminated) setNextDirection2('left');
|
if (!player2Eliminated) setNextDirection2('left');
|
||||||
break;
|
break;
|
||||||
case 'l': case 'L':
|
case 'ArrowRight':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!player2Eliminated) setNextDirection2('right');
|
if (!player2Eliminated) setNextDirection2('right');
|
||||||
break;
|
break;
|
||||||
@@ -570,8 +527,8 @@ const Pacman = () => {
|
|||||||
const isPowerPellet = powerPellets.has(`${x},${y}`);
|
const isPowerPellet = powerPellets.has(`${x},${y}`);
|
||||||
cells.push(
|
cells.push(
|
||||||
<div key={`${x}-${y}`} className={`flex items-center justify-center transition-all duration-200 ease-out ${isWall ? 'bg-primary/20 border border-primary/40' : isTunnel ? 'bg-background/30' : 'bg-background/50 border border-primary/5'}`} style={{ width: cellSize, height: cellSize }}>
|
<div key={`${x}-${y}`} className={`flex items-center justify-center transition-all duration-200 ease-out ${isWall ? 'bg-primary/20 border border-primary/40' : isTunnel ? 'bg-background/30' : 'bg-background/50 border border-primary/5'}`} style={{ width: cellSize, height: cellSize }}>
|
||||||
{isPacmanHere && <div className="drop-shadow-lg" style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(var(--primary)))' }}>{renderPacman()}</div>}
|
{isPacmanHere && <div className={`drop-shadow-lg ${player1Invincible ? 'animate-pulse' : ''}`} style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: player1Invincible ? 'drop-shadow(0 0 8px hsl(var(--primary))) brightness(1.5)' : 'drop-shadow(0 0 4px hsl(var(--primary)))', opacity: player1Invincible ? 0.8 : 1 }}>{renderPacman()}</div>}
|
||||||
{isPacman2Here && <div className="drop-shadow-lg" style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(120 70% 50%))' }}>{renderPacman2()}</div>}
|
{isPacman2Here && <div className={`drop-shadow-lg ${player2Invincible ? 'animate-pulse' : ''}`} style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: player2Invincible ? 'drop-shadow(0 0 8px hsl(120 70% 50%)) brightness(1.5)' : 'drop-shadow(0 0 4px hsl(120 70% 50%))', opacity: player2Invincible ? 0.8 : 1 }}>{renderPacman2()}</div>}
|
||||||
{ghostIndex !== -1 && !isPacmanHere && !isPacman2Here && <div style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out' }}>{renderGhost(ghostIndex, ghosts[ghostIndex].eaten)}</div>}
|
{ghostIndex !== -1 && !isPacmanHere && !isPacman2Here && <div style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out' }}>{renderGhost(ghostIndex, ghosts[ghostIndex].eaten)}</div>}
|
||||||
{isDot && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-1.5 h-1.5 bg-primary/80 rounded-full transition-all duration-200 ease-out animate-pulse" style={{ animationDuration: '2s' }} />}
|
{isDot && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-1.5 h-1.5 bg-primary/80 rounded-full transition-all duration-200 ease-out animate-pulse" style={{ animationDuration: '2s' }} />}
|
||||||
{isPowerPellet && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-3 h-3 bg-primary rounded-full animate-pulse box-glow transition-all duration-200 ease-out" />}
|
{isPowerPellet && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-3 h-3 bg-primary rounded-full animate-pulse box-glow transition-all duration-200 ease-out" />}
|
||||||
@@ -634,8 +591,8 @@ const Pacman = () => {
|
|||||||
<p className="font-pixel text-[10px] text-foreground/60 mb-1">CONTROLS</p>
|
<p className="font-pixel text-[10px] text-foreground/60 mb-1">CONTROLS</p>
|
||||||
{gameMode === '2p' ? (
|
{gameMode === '2p' ? (
|
||||||
<>
|
<>
|
||||||
<p className="font-pixel text-[8px] text-foreground/80">P1: WASD / ←→↑↓</p>
|
<p className="font-pixel text-[8px] text-foreground/80">P1: WASD</p>
|
||||||
<p className="font-pixel text-[8px] text-foreground/80">P2: I J K L</p>
|
<p className="font-pixel text-[8px] text-foreground/80">P2: ← → ↑ ↓</p>
|
||||||
<p className="font-pixel text-[8px] text-foreground/80">P: Pause</p>
|
<p className="font-pixel text-[8px] text-foreground/80">P: Pause</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user