Files
personal_website/src/pages/Pacman.tsx
T
gpt-engineer-app[bot] d13fa4f454 Changes
2026-01-06 23:55:01 +00:00

731 lines
39 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import { motion } from 'framer-motion';
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 { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import GameTouchButton from '@/components/GameTouchButton';
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
const GRID_WIDTH = 21;
const GRID_HEIGHT = 21;
const TICK_SPEED = 180;
const POWER_DURATION = 8000;
const MAX_SCORE = 4294967296;
const HIGHSCORE_KEY = 'pacman-highscore';
type Direction = 'up' | 'down' | 'left' | 'right';
type Position = { x: number; y: number };
const MAZE_TEMPLATE = [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,3,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,3,1],
[1,0,1,1,0,1,1,1,1,0,1,0,1,1,1,1,0,1,1,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,1,1,0,1,0,1,1,1,1,1,1,1,0,1,0,1,1,0,1],
[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],
[1,1,1,1,0,1,1,1,1,0,1,0,1,1,1,1,0,1,1,1,1],
[1,1,1,1,0,1,0,0,0,0,0,0,0,0,0,1,0,1,1,1,1],
[1,1,1,1,0,1,0,1,1,0,0,0,1,1,0,1,0,1,1,1,1],
[2,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,2],
[1,1,1,1,0,1,0,1,1,1,1,1,1,1,0,1,0,1,1,1,1],
[1,1,1,1,0,1,0,0,0,0,0,0,0,0,0,1,0,1,1,1,1],
[1,1,1,1,0,1,0,1,1,1,1,1,1,1,0,1,0,1,1,1,1],
[1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1],
[1,0,1,1,0,1,1,1,1,0,1,0,1,1,1,1,0,1,1,0,1],
[1,3,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,3,1],
[1,1,0,1,0,1,0,1,1,1,1,1,1,1,0,1,0,1,0,1,1],
[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],
[1,0,1,1,1,1,1,1,1,0,1,0,1,1,1,1,1,1,1,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
];
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<Position>({ x: 10, y: 15 });
const [direction, setDirection] = useState<Direction>('right');
const [nextDirection, setNextDirection] = useState<Direction>('right');
const [mouthOpen, setMouthOpen] = useState(true);
const [lives, setLives] = useState(3);
// Player 2 (for 2P mode)
const [pacman2, setPacman2] = useState<Position>({ x: 10, y: 15 });
const [direction2, setDirection2] = useState<Direction>('right');
const [nextDirection2, setNextDirection2] = useState<Direction>('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 [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 },
{ pos: { x: 11, y: 9 }, dir: 'right', eaten: false },
]);
const [dots, setDots] = useState<Set<string>>(new Set());
const [powerPellets, setPowerPellets] = useState<Set<string>>(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);
const [isPaused, setIsPaused] = useState(false);
const [showGlitchCrash, setShowGlitchCrash] = useState(false);
const [level, setLevel] = useState(1);
const [isFullscreen, setIsFullscreen] = useState(false);
const gameRef = useRef<HTMLDivElement>(null);
const powerTimerRef = useRef<NodeJS.Timeout | null>(null);
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_WIDTH), Math.floor(maxHeight / GRID_HEIGHT), 16);
}
// Desktop: calculate based on available height (subtract header ~60px, margins ~40px)
const availableHeight = window.innerHeight - 100;
const maxCellFromHeight = Math.floor(availableHeight / GRID_HEIGHT);
// Clamp to reasonable max
return Math.min(maxCellFromHeight, 28);
}, [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]);
useEffect(() => {
if (gameStarted && isMobile && !isFullscreen) { setIsFullscreen(true); enterFullscreen(); }
}, [gameStarted, isMobile, isFullscreen, enterFullscreen]);
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]);
useEffect(() => {
const saved = localStorage.getItem(HIGHSCORE_KEY);
if (saved) setHighScore(Math.min(parseInt(saved, 10), MAX_SCORE));
}, []);
// Check score achievements
useEffect(() => {
if (score > 0) {
checkGameScoreAchievements('pacman', score);
if (score >= MAX_SCORE) unlockMaxScore();
}
}, [score, checkGameScoreAchievements, unlockMaxScore]);
const initDots = useCallback(() => {
const newDots = new Set<string>();
const newPowerPellets = new Set<string>();
for (let y = 0; y < GRID_HEIGHT; y++) {
for (let x = 0; x < GRID_WIDTH; x++) {
if (MAZE_TEMPLATE[y][x] === 0) newDots.add(`${x},${y}`);
else if (MAZE_TEMPLATE[y][x] === 3) newPowerPellets.add(`${x},${y}`);
}
}
newDots.delete('10,15'); newDots.delete('9,9'); newDots.delete('10,9'); newDots.delete('11,9');
return { dots: newDots, powerPellets: newPowerPellets };
}, []);
const canMove = (pos: Position, dir: Direction): boolean => {
let newX = pos.x, newY = pos.y;
switch (dir) { case 'up': newY--; break; case 'down': newY++; break; case 'left': newX--; break; case 'right': newX++; break; }
if (newX < 0) return MAZE_TEMPLATE[newY]?.[GRID_WIDTH - 1] !== 1;
if (newX >= GRID_WIDTH) return MAZE_TEMPLATE[newY]?.[0] !== 1;
if (newY < 0 || newY >= GRID_HEIGHT) return false;
return MAZE_TEMPLATE[newY][newX] !== 1;
};
const moveEntity = (pos: Position, dir: Direction): Position => {
let newX = pos.x, newY = pos.y;
switch (dir) { case 'up': newY--; break; case 'down': newY++; break; case 'left': newX--; break; case 'right': newX++; break; }
if (newX < 0) newX = GRID_WIDTH - 1;
if (newX >= GRID_WIDTH) newX = 0;
return { x: newX, y: newY };
};
const activatePowerMode = () => {
if (powerTimerRef.current) clearTimeout(powerTimerRef.current);
setIsPowered(true);
powerTimerRef.current = setTimeout(() => { setIsPowered(false); setGhosts(prev => prev.map(g => ({ ...g, eaten: false }))); }, POWER_DURATION);
};
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); 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);
playSound('success');
};
useEffect(() => { return () => { if (powerTimerRef.current) clearTimeout(powerTimerRef.current); }; }, []);
useEffect(() => {
if (!gameStarted || gameOver || gameComplete || isPaused) return;
const interval = setInterval(() => {
// 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 && !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');
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'];
const opposite: Record<Direction, Direction> = { up: 'down', down: 'up', left: 'right', right: 'left' };
const validDirs = directions.filter(d => d !== opposite[ghost.dir] && canMove(ghost.pos, d));
if (validDirs.length === 0) {
const anyValid = directions.filter(d => canMove(ghost.pos, d));
if (anyValid.length === 0) return ghost;
const dir = anyValid[Math.floor(Math.random() * anyValid.length)];
return { ...ghost, pos: moveEntity(ghost.pos, dir), dir };
}
const dx = pacman.x - ghost.pos.x, dy = pacman.y - ghost.pos.y;
let preferredDirs: Direction[] = [];
if (isPowered) { preferredDirs = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? ['left', 'up', 'down', 'right'] : ['right', 'down', 'up', 'left']) : (dy > 0 ? ['up', 'left', 'right', 'down'] : ['down', 'right', 'left', 'up']); }
else { preferredDirs = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? ['right', 'down', 'up', 'left'] : ['left', 'up', 'down', 'right']) : (dy > 0 ? ['down', 'right', 'left', 'up'] : ['up', 'left', 'right', 'down']); }
if (Math.random() < 0.6) { for (const dir of preferredDirs) { if (validDirs.includes(dir)) return { ...ghost, pos: moveEntity(ghost.pos, dir), dir }; } }
const dir = validDirs[Math.floor(Math.random() * validDirs.length)];
return { ...ghost, pos: moveEntity(ghost.pos, dir), dir };
}));
}, 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 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 (Arrow keys) - only in 2P mode
if (gameMode === '2p') {
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
if (!player2Eliminated) setNextDirection2('up');
break;
case 'ArrowDown':
e.preventDefault();
if (!player2Eliminated) setNextDirection2('down');
break;
case 'ArrowLeft':
e.preventDefault();
if (!player2Eliminated) setNextDirection2('left');
break;
case 'ArrowRight':
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);
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 = () => (
<svg viewBox="0 0 100 100" className="w-full h-full transition-transform duration-100 ease-out" style={{ transform: `rotate(${getPacmanRotation()}deg)` }}>
<circle cx="50" cy="50" r="45" fill="hsl(var(--primary))" className="transition-all duration-100 ease-out" />
{mouthOpen && <path d="M 50 50 L 95 25 L 95 75 Z" fill="hsl(var(--background))" className="transition-all duration-100 ease-out" />}
<circle cx="50" cy="25" r="6" fill="hsl(var(--background))" className="transition-all duration-100 ease-out" />
</svg>
);
const renderPacman2 = () => (
<svg viewBox="0 0 100 100" className="w-full h-full transition-transform duration-100 ease-out" style={{ transform: `rotate(${getPacmanRotation2()}deg)` }}>
<circle cx="50" cy="50" r="45" fill="hsl(120 70% 50%)" className="transition-all duration-100 ease-out" />
{mouthOpen2 && <path d="M 50 50 L 95 25 L 95 75 Z" fill="hsl(var(--background))" className="transition-all duration-100 ease-out" />}
<circle cx="50" cy="25" r="6" fill="hsl(var(--background))" className="transition-all duration-100 ease-out" />
</svg>
);
const renderGhost = (index: number, eaten: boolean) => {
const colors = ['hsl(0 70% 50%)', 'hsl(300 70% 50%)', 'hsl(180 70% 50%)'];
const scaredColor = 'hsl(220 70% 50%)';
if (eaten) return null;
return (
<svg viewBox="0 0 100 100" className="w-full h-full">
<path d={`M 10 95 L 10 45 Q 10 5 50 5 Q 90 5 90 45 L 90 95 L 75 80 L 60 95 L 50 80 L 40 95 L 25 80 L 10 95 Z`} fill={isPowered ? scaredColor : colors[index % colors.length]} className={isPowered ? 'animate-pulse' : ''} />
<ellipse cx="35" cy="45" rx="12" ry="15" fill="white" /><ellipse cx="65" cy="45" rx="12" ry="15" fill="white" />
<circle cx="38" cy="48" r="6" fill="hsl(var(--background))" /><circle cx="68" cy="48" r="6" fill="hsl(var(--background))" />
</svg>
);
};
const renderGrid = () => {
const cells = [];
const spriteSize = Math.max(cellSize * 0.7, 12);
for (let y = 0; y < GRID_HEIGHT; y++) {
for (let x = 0; x < GRID_WIDTH; x++) {
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(
<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 ${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" />}
</div>
);
}
}
return cells;
};
if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />;
return (
<motion.div ref={gameRef} tabIndex={0} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }}
className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-50 bg-background p-4' : 'h-full'}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4">
<Link to="/games" className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</Link>
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">Pac-Man</h1>
</div>
<button onClick={toggleFullscreen} className="p-2 border border-primary/50 hover:bg-primary/20 transition-colors" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
{isFullscreen ? <Minimize2 size={16} className="text-primary" /> : <Maximize2 size={16} className="text-primary" />}
</button>
</div>
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4 items-start justify-center'} flex-1`}>
<div className="border-2 border-primary box-glow p-1 bg-background/80 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 animate-pulse" style={{ animationDuration: '4s' }}></div>
<div className="grid relative z-10" style={{ gridTemplateColumns: `repeat(${GRID_WIDTH}, ${cellSize}px)` }}>{renderGrid()}</div>
</div>
{!isMobile && (
<div className="flex flex-col gap-2 min-w-[140px]">
{gameMode === '2p' ? (
<div className="border border-primary/50 p-3 bg-background/50">
<p className="font-pixel text-[10px] text-foreground/60 mb-1">SCORES</p>
<div className="flex gap-2 justify-center">
<div className="text-center"><p className="font-pixel text-[8px] text-foreground/80">P1</p><p className="font-minecraft text-lg text-primary text-glow">{score.toLocaleString()}</p></div>
<div className="text-center"><p className="font-pixel text-[8px] text-foreground/80">P2</p><p className="font-minecraft text-lg text-primary text-glow">{score2.toLocaleString()}</p></div>
</div>
</div>
) : (
<div className="border border-primary/50 p-3 bg-background/50"><p className="font-pixel text-[10px] text-foreground/60">SCORE</p><p className="font-minecraft text-xl text-primary text-glow">{score.toLocaleString()}</p></div>
)}
<div className="border border-primary/50 p-3 bg-background/50"><p className="font-pixel text-[10px] text-foreground/60">HIGH SCORE</p><p className="font-minecraft text-lg text-primary text-glow">{highScore.toLocaleString()}</p><p className="font-pixel text-[8px] text-foreground/30">max: 4,294,967,296</p></div>
<div className="border border-primary/50 p-3 bg-background/50"><p className="font-pixel text-[10px] text-foreground/60">LEVEL</p><p className="font-minecraft text-lg text-primary text-glow">{level}</p></div>
{gameMode === '2p' && (
<div className="border border-primary/50 p-3 bg-background/50">
<p className="font-pixel text-[10px] text-foreground/60 mb-1">LIVES</p>
<div className="flex gap-2 justify-center">
<div className="text-center"><p className="font-pixel text-[8px] text-foreground/80">P1</p><p className="font-minecraft text-sm text-primary">{lives}</p></div>
<div className="text-center"><p className="font-pixel text-[8px] text-foreground/80">P2</p><p className="font-minecraft text-sm text-primary">{lives2}</p></div>
</div>
</div>
)}
{gameMode === '1p' && (
<div className="border border-primary/50 p-3 bg-background/50"><p className="font-pixel text-[10px] text-foreground/60">LIVES</p><p className="font-minecraft text-lg text-primary text-glow">{lives}</p></div>
)}
<div className="border border-primary/50 p-3 bg-background/50">
<p className="font-pixel text-[10px] text-foreground/60 mb-1">CONTROLS</p>
{gameMode === '2p' ? (
<>
<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>
</>
) : (
<>
<p className="font-pixel text-[10px] text-foreground/80">WASD / ←→↑↓</p>
<p className="font-pixel text-[10px] text-foreground/80">P: Pause</p>
</>
)}
</div>
{showModeSelection ? (
<div className="flex flex-col gap-2">
<p className="font-pixel text-[10px] text-foreground/60 text-center">SELECT MODE</p>
<button onClick={() => { setGameMode('1p'); setShowModeSelection(false); }} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">1 PLAYER</button>
<button onClick={() => { setGameMode('2p'); setShowModeSelection(false); }} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">2 PLAYERS</button>
</div>
) : !gameStarted || gameOver || gameComplete ? (
<button onClick={startGame} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">{gameComplete ? 'PERFECT!' : gameOver ? 'RETRY' : 'START'}</button>
) : (
<button onClick={() => setIsPaused(p => !p)} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all">{isPaused ? 'RESUME' : 'PAUSE'}</button>
)}
</div>
)}
{isMobile && (
<>
<MobileControlsSpacer />
<MobileControlsDock>
<div className="flex flex-col items-center gap-2">
<div className="flex gap-4 text-center">
{gameMode === '2p' ? (
<>
<div><p className="font-pixel text-[8px] text-foreground/60">P1</p><p className="font-minecraft text-sm text-primary">{score.toLocaleString()}</p></div>
<div><p className="font-pixel text-[8px] text-foreground/60">P2</p><p className="font-minecraft text-sm text-primary">{score2.toLocaleString()}</p></div>
<div><p className="font-pixel text-[8px] text-foreground/60">LVL</p><p className="font-minecraft text-sm text-primary">{level}</p></div>
</>
) : (
<>
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-primary">{score.toLocaleString()}</p></div>
<div><p className="font-pixel text-[8px] text-foreground/60">HIGH</p><p className="font-minecraft text-sm text-primary">{highScore.toLocaleString()}</p></div>
<div><p className="font-pixel text-[8px] text-foreground/60">LVL</p><p className="font-minecraft text-sm text-primary">{level}</p></div>
</>
)}
{gameMode === '2p' && (
<>
<div><p className="font-pixel text-[8px] text-foreground/60">P1</p><p className="font-minecraft text-sm text-primary">{lives}</p></div>
<div><p className="font-pixel text-[8px] text-foreground/60">P2</p><p className="font-minecraft text-sm text-primary">{lives2}</p></div>
</>
)}
{gameMode === '1p' && (
<div><p className="font-pixel text-[8px] text-foreground/60">LIVES</p><p className="font-minecraft text-sm text-primary">{lives}</p></div>
)}
</div>
{showModeSelection ? (
<div className="flex flex-col gap-2 mt-2">
<p className="font-pixel text-[10px] text-foreground/60 text-center">SELECT MODE</p>
<button onClick={() => { setGameMode('1p'); setShowModeSelection(false); }} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">1 PLAYER</button>
<button onClick={() => { setGameMode('2p'); setShowModeSelection(false); }} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">2 PLAYERS</button>
</div>
) : gameStarted && !gameOver && !gameComplete ? (
<div className="grid grid-cols-3 gap-1 mt-2">
<div />
<GameTouchButton onAction={() => setNextDirection('up')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg"></GameTouchButton>
<div />
<GameTouchButton onAction={() => setNextDirection('left')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg"></GameTouchButton>
<button onClick={() => setIsPaused(p => !p)} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-[10px] select-none">{isPaused ? '▶' : '❚❚'}</button>
<GameTouchButton onAction={() => setNextDirection('right')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg"></GameTouchButton>
<div />
<GameTouchButton onAction={() => setNextDirection('down')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg"></GameTouchButton>
<div />
</div>
) : (
<button onClick={startGame} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
{gameComplete ? 'PERFECT!' : gameOver ? 'RETRY' : 'START'}
</button>
)}
</div>
</MobileControlsDock>
</>
)}
</div>
{!isMobile && showEliminationPrompt && gameStarted && (
<div className="fixed inset-0 bg-background/80 flex items-center justify-center z-50">
<div className="border-2 border-primary box-glow-strong p-6 bg-background text-center">
<h2 className="font-minecraft text-2xl text-primary text-glow-strong mb-3">{player1Eliminated ? 'PLAYER 2 WON!' : 'PLAYER 1 WON!'}</h2>
<p className="font-pixel text-sm text-foreground/80 mb-3">
{player1Eliminated ? 'Player 1' : 'Player 2'} has run out of lives.<br/>
Continue with the remaining player?
</p>
<div className="flex gap-4 justify-center">
<button onClick={continueIn1PMode} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
CONTINUE (1P)
</button>
<button onClick={startGame} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
RESTART
</button>
</div>
</div>
</div>
)}
{!isMobile && (gameOver || isPaused || gameComplete) && gameStarted && !showEliminationPrompt && (
<div className="fixed inset-0 bg-background/80 flex items-center justify-center z-50">
<div className="border-2 border-primary box-glow-strong p-6 bg-background text-center">
<h2 className="font-minecraft text-2xl text-primary text-glow-strong mb-3">{gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}</h2>
{(gameOver || gameComplete) && (
<>
{gameMode === '2p' ? (
<>
<p className="font-pixel text-sm text-foreground/80 mb-1">Final Scores</p>
<div className="flex gap-4 justify-center mb-1">
<div className="text-center"><p className="font-pixel text-xs text-foreground/80">P1</p><p className="font-minecraft text-lg text-primary">{score.toLocaleString()}</p></div>
<div className="text-center"><p className="font-pixel text-xs text-foreground/80">P2</p><p className="font-minecraft text-lg text-primary">{score2.toLocaleString()}</p></div>
</div>
</>
) : (
<p className="font-pixel text-sm text-foreground/80 mb-1">Final Score: {score.toLocaleString()}</p>
)}
<p className="font-pixel text-xs text-foreground/60 mb-3">Level: {level}</p>
</>
)}
<button onClick={(gameOver || gameComplete) ? startGame : () => setIsPaused(false)} className="font-minecraft text-sm py-2 px-6 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">{(gameOver || gameComplete) ? 'PLAY AGAIN' : 'RESUME'}</button>
</div>
</div>
)}
</motion.div>
);
};
export default Pacman;