Score: 0
|
Lives: 3
|
Level: 1
Labyrinth of Echoes
Arrow keys or WASD to move
PRESS SPACE TO START
// 0=empty, 1=wall, 2=dot, 3=pellet, 4=gate const CANVAS_SIZE = Math.min(window.innerWidth, window.innerHeight); const CELL = Math.floor(CANVAS_SIZE / 23); const COLS = 21; const ROWS = 23; const MID_X = 10; const MID_Y = 10; // Initialize canvas const canvas = document.getElementById('game'); canvas.width = CANVAS_SIZE; canvas.height = CANVAS_SIZE; const ctx = canvas.getContext('2d'); const LEVELS = [ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], [1,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,1], [1,2,1,1,2,1,1,1,2,2,1,2,2,1,1,1,2,1,1,2,1], [1,3,1,1,2,1,1,1,2,2,2,2,2,1,1,1,2,1,1,3,1], [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1], [1,2,1,1,2,1,2,1,1,1,1,1,1,1,2,1,2,1,1,2,1], [1,2,2,2,2,1,2,2,2,2,1,2,2,2,2,1,2,2,2,2,1], [1,1,1,1,2,1,1,1,0,0,1,0,0,1,1,1,2,1,1,1,1], [0,0,0,1,2,1,0,0,0,0,0,0,0,0,0,1,2,1,0,0,0], [1,1,1,1,2,1,0,1,1,4,4,4,1,1,0,1,2,1,1,1,1], [0,0,0,0,2,0,0,1,0,0,0,0,0,1,0,0,2,0,0,0,0], [1,1,1,1,2,1,0,1,1,1,1,1,1,1,0,1,2,1,1,1,1], [0,0,0,1,2,1,0,0,0,0,0,0,0,0,0,1,2,1,0,0,0], [1,1,1,1,2,1,0,1,1,1,1,1,1,1,0,1,2,1,1,1,1], [1,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,1], [1,2,1,1,2,1,1,1,2,2,1,2,2,1,1,1,2,1,1,2,1], [1,3,2,1,2,2,2,2,2,2,0,2,2,2,2,2,2,1,2,3,1], [1,1,2,1,2,1,2,1,1,1,1,1,1,1,2,1,2,1,2,1,1], [1,2,2,2,2,1,2,2,2,2,1,2,2,2,2,1,2,2,2,2,1], [1,2,1,1,1,1,1,1,2,2,1,2,2,1,1,1,1,1,1,2,1], [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1], [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1], [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] ]; let maze, score, lives, level, gameState; let player, ghosts; let pellets, powerPellets; const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}]; const OPP = [2,3,0,1]; function createGhosts() { return [ {x:9,y:9,c:'pink',dir:2,mode:'scatter',active:false,frightened:false,eaten:false}, {x:10,y:9,c:'cyan',dir:0,mode:'scatter',active:false,frightened:false,eaten:false}, {x:11,y:9,c:'orange',dir:3,mode:'scatter',active:false,frightened:false,eaten:false} ]; } function canMove(x, y) { let col = Math.round(x); let row = Math.round(y); if(row < 0 || row >= ROWS) return false; // Wrap tunnel if(col < 0 || col >= COLS) return true; return maze[row][col] !== 1 && maze[row][col] !== 4; } function canEnterGate(x, y) { let col = Math.round(x); let row = Math.round(y); if(row < 0 || row >= ROWS) return false; if(col < 0 || col >= COLS) return false; return maze[row][col] === 4; } function isAligned(e) { return Math.abs(e.x - Math.round(e.x)) < 0.05 && Math.abs(e.y - Math.round(e.y)) < 0.05; } function movePlayer() { if(!isAligned(player)) return; let r = Math.round(player.y), c = Math.round(player.x); if(player.nd >= 0) { let nc = c + DIRS[player.nd].x, nr = r + DIRS[player.nd].y; if(nc>=0 && nc
=0 && nr
COLS) nx = -1; if(canMove(nx, ny)) { player.x = nx; player.y = ny; } } let ghost, gx, gy;\n for (let g of ghosts) {\n // Simple chase AI: move toward player\n if (!g.scared) {\n // Choose direction that minimizes distance to player\n let bestDir = 0, bestDist = Infinity;\n for (let d = 0; d < DIRS.length; d++) {\n if (d === (g.d + 2) % 4) continue; // can't reverse\n gx = g.x + DIRS[d].x;\n gy = g.y + DIRS[d].y;\n if (gx >= 0 && gx < COLS && gy >= 0 && gy < ROWS && maze[gy][gx] !== 1) {\n let dist = Math.abs(gx - player.x) + Math.abs(gy - player.y);\n if (dist < bestDist) { bestDist = dist; bestDir = d; }\n }\n }\n g.d = bestDir;\n } else {\n // Scared: flee from player\n let bestDir = 0, bestDist = -1;\n for (let d = 0; d < DIRS.length; d++) {\n if (d === (g.d + 2) % 4) continue;\n gx = g.x + DIRS[d].x;\n gy = g.y + DIRS[d].y;\n if (gx >= 0 && gx < COLS && gy >= 0 && gy < ROWS && maze[gy][gx] !== 1) {\n let dist = Math.abs(gx - player.x) + Math.abs(gy - player.y);\n if (dist > bestDist) { bestDist = dist; bestDir = d; }\n }\n }\n g.d = bestDir;\n }\n let nx = g.x + DIRS[g.d].x * g.speed;\n let ny = g.y + DIRS[g.d].y * g.speed;\n if (nx < -1) nx = COLS;\n if (nx > COLS) nx = -1;\n // Check wall collision for ghost\n let gx2 = Math.round(nx), gy2 = Math.round(ny);\n if (gx2 >= 0 && gx2 < COLS && gy2 >= 0 && gy2 < ROWS && maze[gy2][gx2] !== 1) {\n g.x = nx; g.y = ny;\n } else {\n // Pick new direction randomly\n let dirs = [0,1,2,3].filter(dd => dd !== (g.d+2)%4);\n g.d = dirs[Math.floor(Math.random()*dirs.length)];\n }\n // Check ghost-player collision\n let dx = g.x - player.x, dy = g.y - player.y;\n if (Math.sqrt(dx*dx + dy*dy) < 1.5) {\n if (g.scared) {\n g.scared = false;\n g.eaten = true;\n score += 200;\n let ex = g.x, ey = g.y;\n setTimeout(() => { g.eaten = false; g.x = MID_X; g.y = MID_Y; }, 3000);\n } else {\n lives--;\n resetPositions();\n if (lives <= 0) { gameState = 'gameover'; }\n }\n }\n } // Check if all dots eaten let dotsLeft = false; for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { if (maze[r][c] === 2 || maze[r][c] === 3) { dotsLeft = true; break; } } if (dotsLeft) break; } if (!dotsLeft) { gameState = 'win'; }\n}\n\nfunction draw() {\n ctx.fillStyle = '#000';\n ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);\n // Draw maze\n for (let r = 0; r < ROWS; r++) {\n for (let c = 0; c < COLS; c++) {\n if (maze[r][c] === 1) {\n ctx.fillStyle = '#1919A6';\n ctx.fillRect(c*CELL, r*CELL, CELL, CELL);\n } else if (maze[r][c] === 2) {\n ctx.fillStyle = '#FFB8AE';\n ctx.beginPath();\n ctx.arc(c*CELL+CELL/2, r*CELL+CELL/2, 3, 0, Math.PI*2);\n ctx.fill();\n } else if (maze[r][c] === 3) {\n ctx.fillStyle = '#FFB8AE';\n ctx.beginPath();\n ctx.arc(c*CELL+CELL/2, r*CELL+CELL/2, 6, 0, Math.PI*2);\n ctx.fill();\n }\n }\n }\n // Draw player\n ctx.fillStyle = '#FFFF00';\n ctx.beginPath();\n ctx.arc(player.x*CELL+CELL/2, player.y*CELL+CELL/2, CELL/2-1, 0.2*Math.PI, 1.8*Math.PI);\n ctx.lineTo(player.x*CELL+CELL/2, player.y*CELL+CELL/2);\n ctx.fill();\n // Draw ghosts\n for (let g of ghosts) {\n ctx.fillStyle = g.scared ? '#0000FF' : (g.eaten ? 'transparent' : getGhostColor(g));\n if (!g.eaten) {\n ctx.beginPath();\n ctx.arc(g.x*CELL+CELL/2, g.y*CELL+CELL/3, CELL/2-1, Math.PI, 0);\n ctx.lineTo(g.x*CELL+CELL-1, g.y*CELL+CELL-1);\n for (let i = 0; i < 3; i++) {\n let fx = g.x*CELL+CELL-1 - (i+1)*CELL/3;\n ctx.lineTo(fx, g.y*CELL+CELL-1 + (i%2)*4);\n }\n ctx.fill();\n if (!g.scared) {\n ctx.fillStyle = '#FFF';\n ctx.beginPath();\n ctx.arc(g.x*CELL+CELL*0.35, g.y*CELL+CELL*0.3, 3, 0, Math.PI*2);\n ctx.arc(g.x*CELL+CELL*0.65, g.y*CELL+CELL*0.3, 3, 0, Math.PI*2);\n ctx.fill();\n } else {\n ctx.fillStyle = '#FFB8AE';\n ctx.fillRect(g.x*CELL+CELL*0.3, g.y*CELL+CELL*0.4, 3, 3);\n ctx.fillRect(g.x*CELL+CELL*0.6, g.y*CELL+CELL*0.4, 3, 3);\n }\n }\n }\n // UI\n ctx.fillStyle = '#FFF';\n ctx.font = '14px monospace';\n ctx.fillText('SCORE: '+score, 5, 20);\n ctx.fillText('LIVES: '+lives, CANVAS_SIZE-100, 20);\n if (gameState === 'pause') {\n ctx.fillStyle = '#FFF';\n ctx.font = 'bold 24px monospace';\n ctx.fillText('PAUSED', CANVAS_SIZE/2-50, CANVAS_SIZE/2);\n } else if (gameState === 'gameover') {\n ctx.fillStyle = '#F00';\n ctx.font = 'bold 28px monospace';\n ctx.fillText('GAME OVER', CANVAS_SIZE/2-70, CANVAS_SIZE/2);\n ctx.fillStyle = '#FFF';\n ctx.font = '16px monospace';\n ctx.fillText('Press ENTER to restart', CANVAS_SIZE/2-90, CANVAS_SIZE/2+30);\n } else if (gameState === 'win') {\n ctx.fillStyle = '#0F0';\n ctx.font = 'bold 28px monospace';\n ctx.fillText('YOU WIN!', CANVAS_SIZE/2-60, CANVAS_SIZE/2);\n ctx.fillStyle = '#FFF';\n ctx.font = '16px monospace';\n ctx.fillText('Press ENTER to restart', CANVAS_SIZE/2-90, CANVAS_SIZE/2+30);\n }\n}\n\nfunction getGhostColor(g) {\n const colors = ['#FF0000','#FFB8FF','#00FFFF','#FFB852'];\n return colors[g.idx % 4];\n}\n\nfunction resetPositions() {\n player.x = MID_X; player.y = ROWS-3; player.d = 0; player.nd = 0;\n ghosts[0].x = MID_X-1; ghosts[0].y = MID_Y; ghosts[0].d = 2; ghosts[0].scared = false; ghosts[0].eaten = false;\n ghosts[1].x = MID_X+1; ghosts[1].y = MID_Y; ghosts[1].d = 2; ghosts[1].scared = false; ghosts[1].eaten = false;\n ghosts[2].x = MID_X; ghosts[2].y = MID_Y-1; ghosts[2].d = 0; ghosts[2].scared = false; ghosts[2].eaten = false;\n ghosts[3].x = MID_X; ghosts[3].y = MID_Y+1; ghosts[3].d = 0; ghosts[3].scared = false; ghosts[3].eaten = false;\n}\n\ndocument.addEventListener('keydown', function(e) {\n if (e.key === 'ArrowUp' || e.key === 'w') player.nd = 0;\n if (e.key === 'ArrowRight' || e.key === 'd') player.nd = 1;\n if (e.key === 'ArrowDown' || e.key === 's') player.nd = 2;\n if (e.key === 'ArrowLeft' || e.key === 'a') player.nd = 3;\n if (e.key === 'p') { gameState = (gameState === 'play') ? 'pause' : (gameState === 'pause') ? 'play' : gameState; }\n if (e.key === 'Enter' && (gameState === 'gameover' || gameState === 'win')) { initGame(); }\n e.preventDefault();\n}); // Ghost AI - each ghost has unique behavior const ghostColors = ['#FF0000', '#FFB8FF', '#00FFFF', '#FFB852']; const ghostNames = ['Blinky', 'Pinky', 'Inky', 'Clyde']; function getGhostTarget(ghost) { if (ghost.mode === 'scatter') return {x: ghost.scatterX, y: ghost.scatterY}; switch(ghost.name) { case 'Blinky': // targets pacman directly return {x: player.x, y: player.y}; case 'Pinky': // targets 4 tiles ahead of pacman let tx = player.x; let ty = player.y; if (player.nd === 1) ty -= 4; else if (player.nd === 2) ty += 4; else if (player.nd === 3) tx -= 4; else if (player.nd === 0) tx += 4; return {x: tx, y: ty}; case 'Inky': // complex targeting using blinky let blinky = ghosts[0]; let ax = player.x + (player.nd === 0 ? 2 : player.nd === 1 ? 0 : player.nd === 2 ? -2 : 0); let ay = player.y + (player.nd === 0 ? -2 : player.nd === 1 ? 2 : player.nd === 2 ? 0 : 0); return {x: ax + (ax - blinky.x), y: ay + (ay - blinky.y)}; case 'Clyde': // targets pacman when far, scatter when close let dist = Math.hypot(ghost.x - player.x, ghost.y - player.y); return dist > 8 ? {x: player.x, y: player.y} : {x: ghost.scatterX, y: ghost.scatterY}; } return {x: player.x, y: player.y}; } function moveGhost(ghost) { if (!ghost.active || ghost.eaten) return; let target = getGhostTarget(ghost); let bestDir = -1; let bestDist = Infinity; let opposite = (ghost.dir + 2) % 4; for (let d = 0; d < 4; d++) { if (d === opposite && !ghost.canReverse) continue; let nx = ghost.x + dirs[d][0]; let ny = ghost.y + dirs[d][1]; if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) continue; if (maze[ny][nx] === '#') continue; let dist = Math.hypot(nx - target.x, ny - target.y); if (dist < bestDist) { bestDist = dist; bestDir = d; } } if (bestDir >= 0) { ghost.dir = bestDir; ghost.x += dirs[bestDir][0]; ghost.y += dirs[bestDir][1]; } } function checkCollisions() { // Player collision with ghosts for (let g of ghosts) { if (!g.active || g.eaten) continue; if (g.x === player.x && g.y === player.y) { if (powerPellets > 0) { g.eaten = true; score += 200; setTimeout(() => { g.eaten = false; g.active = true; }, 5000); } else { lives--; if (lives <= 0) { gameState = 'gameover'; } else { resetPositions(); } } } } // Eating pellets if (maze[player.y] && maze[player.y][player.x] === '.') { maze[player.y][player.x] = ' '; // empty after eating dotsRemaining--; score += 10; if (dotsRemaining <= 0) { gameState = 'win'; } } // Power pellet if (maze[player.y] && maze[player.y][player.x] === 'o') { maze[player.y][player.x] = ' '; dotsRemaining--; score += 50; powerPellets = 10; // ~10 seconds worth of frames ghosts.forEach(g => { if (!g.eaten && g.active) g.canReverse = true; }); if (dotsRemaining <= 0) gameState = 'win'; } } function resetPositions() { player.x = 14; player.y = 23; player.nd = 2; ghosts[0].x = 14; ghosts[0].y = 9; ghosts[1].x = 14; ghosts[1].y = 11; ghosts[2].x = 12; ghosts[2].y = 11; ghosts[3].x = 16; ghosts[3].y = 11; ghosts.forEach(g => { g.eaten = false; g.active = true; g.canReverse = false; }); } // Main game loop let lastTime = 0; function gameLoop(timestamp) { if (!lastTime) lastTime = timestamp; const delta = timestamp - lastTime; if (delta > 150) { // ~6.6 fps for maze movement lastTime = timestamp; movePlayer(); ghosts.forEach(m => moveGhost(m)); checkCollisions(); if (powerPellets > 0) powerPellets--; if (gameState === 'play') { switchMode(); } } drawGame(); requestAnimationFrame(gameLoop); } function switchMode() { modeTimer++; if (modeTimer >= 600) { // ~90 seconds per mode cycle modeTimer = 0; gameMode = (gameMode === 'scatter') ? 'chase' : 'scatter'; ghosts.forEach(g => { g.mode = gameMode; if (!g.eaten && g.active) { g.dir = (g.dir + 2) % 4; // reverse on mode change } }); } } function drawGame() { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw maze for (let y = 0; y < ROWS; y++) { for (let x = 0; x < COLS; x++) { if (maze[y][x] === '#') { ctx.fillStyle = '#1a1aff'; ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); } else if (maze[y][x] === '.') { ctx.fillStyle = '#ffb8ae'; ctx.beginPath(); ctx.arc(x * TILE_SIZE + TILE_SIZE/2, y * TILE_SIZE + TILE_SIZE/2, 2, 0, Math.PI * 2); ctx.fill(); } else if (maze[y][x] === 'o') { ctx.fillStyle = '#ffb8ae'; ctx.beginPath(); ctx.arc(x * TILE_SIZE + TILE_SIZE/2, y * TILE_SIZE + TILE_SIZE/2, 6, 0, Math.PI * 2); ctx.fill(); } } } // Draw power pellet border if (powerPellets > 0) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; for (let y = 0; y < ROWS; y++) { for (let x = 0; x < COLS; x++) { if (maze[y][x] === 'o') { let px = x * TILE_SIZE + TILE_SIZE/2; let py = y * TILE_SIZE + TILE_SIZE/2; let pulse = Math.sin(Date.now() / 100) * 2; ctx.beginPath(); ctx.arc(px, py, 8 + pulse, 0, Math.PI * 2); ctx.stroke(); } } } } // Draw player (pacman) let mouthAngle = (Math.sin(Date.now() / 100) + 1) * 0.3; let px = player.x * TILE_SIZE + TILE_SIZE/2; let py = player.y * TILE_SIZE + TILE_SIZE/2; let startAngle, endAngle; switch(player.nd) { case 0: startAngle = mouthAngle; endAngle = Math.PI * 2 - mouthAngle; break; case 1: startAngle = Math.PI + mouthAngle; endAngle = Math.PI - mouthAngle; break; case 2: startAngle = Math.PI/2 + mouthAngle; endAngle = Math.PI/2 - mouthAngle; break; case 3: default: startAngle = -Math.PI/2 + mouthAngle; endAngle = -Math.PI/2 - mouthAngle; break; } ctx.fillStyle = '#FFFF00'; ctx.beginPath(); ctx.arc(px, py, TILE_SIZE/2 - 1, startAngle, endAngle); ctx.lineTo(px, py); ctx.fill(); // Draw ghosts for (let g of ghosts) { if (!g.active) continue; let gx = g.x * TILE_SIZE + TILE_SIZE/2; let gy = g.y * TILE_SIZE + TILE_SIZE/2; if (g.eaten) { // Just eyes ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(gx - 4, gy - 2, 3, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(gx + 4, gy - 2, 3, 0, Math.PI * 2); ctx.fill(); continue; } if (powerPellets > 0 && !g.eaten) { // Scared ghost ctx.fillStyle = (Math.floor(Date.now() / 200) % 2 === 0) ? '#2121DE' : '#fff'; } else { let idx = ghostNames.indexOf(g.name); ctx.fillStyle = ghostColors[idx]; } // Ghost body ctx.beginPath(); ctx.arc(gx, gy - 2, TILE_SIZE/2 - 1, Math.PI, 0); ctx.lineTo(gx + TILE_SIZE/2 - 1, gy + TILE_SIZE/2 - 1); for (let i = 0; i < 3; i++) { let bx = gx + TILE_SIZE/2 - 1 - (i * ((TILE_SIZE - 2) / 3)); ctx.quadraticCurveTo(bx - ((TILE_SIZE - 2) / 6), gy + TILE_SIZE/2 - 5, bx - (TILE_SIZE - 2)/3, gy + TILE_SIZE/2 - 1); } ctx.fill(); // Eyes ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(gx - 4, gy - 3, 3, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(gx + 4, gy - 3, 3, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#00f'; ctx.beginPath(); ctx.arc(gx - 4 + (player.x > gx ? 1 : player.x < gx ? -1 : 0), gy - 3, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(gx + 4 + (player.x > gx ? 1 : player.x < gx ? -1 : 0), gy - 3, 1.5, 0, Math.PI * 2); ctx.fill(); } // HUD ctx.fillStyle = '#fff'; ctx.font = '14px monospace'; ctx.fillText(`Score: ${score}`, 10, 20); ctx.fillText(`Lives: ${lives}`, canvas.width - 100, 20); ctx.fillText(`Level: ${level}`, canvas.width / 2 - 30, 20); // Game state overlays if (gameState === 'gameover') { ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(0, canvas.height/2 - 60, canvas.width, 120); ctx.fillStyle = '#f00'; ctx.font = 'bold 36px monospace'; ctx.textAlign = 'center'; ctx.fillText('GAME OVER', canvas.width/2, canvas.height/2); ctx.fillStyle = '#fff'; ctx.font = '16px monospace'; ctx.fillText(`Final Score: ${score}`, canvas.width/2, canvas.height/2 + 30); ctx.fillText('Press Enter to restart', canvas.width/2, canvas.height/2 + 55); ctx.textAlign = 'left'; } else if (gameState === 'win') { ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(0, canvas.height/2 - 60, canvas.width, 120); ctx.fillStyle = '#0f0'; ctx.font = 'bold 36px monospace'; ctx.textAlign = 'center'; ctx.fillText('YOU WIN!', canvas.width/2, canvas.height/2); ctx.fillStyle = '#fff'; ctx.font = '16px monospace'; ctx.fillText(`Final Score: ${score}`, canvas.width/2, canvas.height/2 + 30); ctx.fillText('Press Enter to restart', canvas.width/2, canvas.height/2 + 55); ctx.textAlign = 'left'; } else if (gameState === 'intro') { ctx.fillStyle = '#FF0'; ctx.font = 'bold 32px monospace'; ctx.textAlign = 'center'; ctx.fillText('LABYRINTH OF ECHOES', canvas.width/2, canvas.height/3); ctx.fillStyle = '#fff'; ctx.font = '14px monospace'; ctx.fillText('Arrow keys or WASD to move', canvas.width/2, canvas.height/2); ctx.fillText('P to pause', canvas.width/2, canvas.height/2 + 20); ctx.fillText('Press any key to start', canvas.width/2, canvas.height/2 + 50); ctx.textAlign = 'left'; } } // Start the game