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:
2026-01-07 00:24:56 +01:00
parent a06d538170
commit 49abc06046
+76 -119
View File
@@ -67,6 +67,8 @@ const Pacman = () => {
const [player1Eliminated, setPlayer1Eliminated] = useState(false);
const [player2Eliminated, setPlayer2Eliminated] = 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 }[]>([
{ pos: { x: 9, y: 9 }, dir: 'left', 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);
setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); setShowModeSelection(false);
setPlayer1Eliminated(false); setPlayer2Eliminated(false); setShowEliminationPrompt(false);
setPlayer1Invincible(false); setPlayer2Invincible(false);
playSound('success'); gameRef.current?.focus();
};
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');
setShowEliminationPrompt(false);
setIsPaused(false);
@@ -321,14 +357,16 @@ const Pacman = () => {
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 {
} else if (!ghost.eaten && !player1Invincible) {
// Player 1 dies (only if not invincible)
const newLives = lives - 1;
setLives(newLives);
if (newLives > 0) {
setPacman({ x: 10, y: 15 });
setPlayer1Invincible(true);
setTimeout(() => setPlayer1Invincible(false), 3000); // 3 seconds of invincibility
playSound('error');
} else {
setPlayer1Eliminated(true);
setPacman({ x: -1, y: -1 });
playSound('error');
@@ -369,135 +407,54 @@ const Pacman = () => {
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, pacman2, direction2, nextDirection2, dots, powerPellets, highScore, isPowered, playSound, gameMode, player1Eliminated, player2Eliminated]);
// More responsive input - update direction immediately when pressed
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!gameStarted || gameOver || gameComplete || isPaused) return;
// Player 1 controls (WASD and Arrow keys) - immediate response
switch (e.key) {
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 1 controls - WASD always, arrows only in 1P mode
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' ||
(gameMode === '1p' && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight'))) {
switch (e.key) {
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
// Player 2 controls (Arrow keys) - only in 2P mode
if (gameMode === '2p') {
switch (e.key) {
case 'i': case 'I':
case 'ArrowUp':
e.preventDefault();
if (!player2Eliminated) setNextDirection2('up');
break;
case 'k': case 'K':
case 'ArrowDown':
e.preventDefault();
if (!player2Eliminated) setNextDirection2('down');
break;
case 'j': case 'J':
case 'ArrowLeft':
e.preventDefault();
if (!player2Eliminated) setNextDirection2('left');
break;
case 'l': case 'L':
case 'ArrowRight':
e.preventDefault();
if (!player2Eliminated) setNextDirection2('right');
break;
@@ -570,8 +527,8 @@ const Pacman = () => {
const isPowerPellet = powerPellets.has(`${x},${y}`);
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 }}>
{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>}
{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>}
{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 ${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>}
{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" />}
@@ -634,8 +591,8 @@ const Pacman = () => {
<p className="font-pixel text-[10px] text-foreground/60 mb-1">CONTROLS</p>
{gameMode === '2p' ? (
<>
<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">P1: WASD</p>
<p className="font-pixel text-[8px] text-foreground/80">P2: </p>
<p className="font-pixel text-[8px] text-foreground/80">P: Pause</p>
</>
) : (