- Added missing getOppositeDirection function to prevent JavaScript errors - Enhanced initialization logging with detailed state tracking - Added comprehensive debug logging for space button interactions - Improved mobile control detection and setup logging - Added state transition logging for ATTRACT → START → READY → PLAYING - Fixed critical runtime errors preventing game from starting
1420 lines
60 KiB
HTML
1420 lines
60 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Pac-Man HTML5</title>
|
|
<style>
|
|
body { margin: 0; padding: 20px; background: #000; display: flex; flex-direction: column; align-items: center; font-family: 'Courier New', monospace; color: #fff; }
|
|
#gameContainer { position: relative; background: #000; border: 2px solid #00f; }
|
|
#gameCanvas { display: block; image-rendering: pixelated; }
|
|
#score, #lives { font-size: 20px; margin: 10px 0; color: #ff0; }
|
|
#gameOver, #startScreen { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; color: #ff0; text-align: center; }
|
|
.controls { margin-top: 20px; text-align: center; color: #0ff; }
|
|
.mobile-controls { display: none; position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; }
|
|
.mobile-controls.active { display: block; }
|
|
.dpad { position: relative; width: 150px; height: 150px; }
|
|
.dpad-btn { position: absolute; background: rgba(255, 255, 0, 0.3); border: 2px solid #ff0; border-radius: 10px; color: #ff0; font-size: 20px; font-weight: bold; display: flex; align-items: center; justify-content: center; cursor: pointer; user-select: none; -webkit-user-select: none; }
|
|
.dpad-btn:active { background: rgba(255, 255, 0, 0.6); }
|
|
.dpad-up { width: 50px; height: 50px; top: 0; left: 50px; }
|
|
.dpad-down { width: 50px; height: 50px; bottom: 0; left: 50px; }
|
|
.dpad-left { width: 50px; height: 50px; top: 50px; left: 0; }
|
|
.dpad-right { width: 50px; height: 50px; top: 50px; right: 0; }
|
|
.dpad-center { width: 50px; height: 50px; top: 50px; left: 50px; background: rgba(255, 255, 0, 0.1); }
|
|
.mobile-space-btn { position: fixed; bottom: 180px; left: 50%; transform: translateX(-50%); background: rgba(0, 255, 255, 0.3); border: 2px solid #0ff; border-radius: 10px; color: #0ff; font-size: 16px; font-weight: bold; padding: 10px 20px; cursor: pointer; user-select: none; -webkit-user-select: none; z-index: 1000; }
|
|
.mobile-space-btn:active { background: rgba(0, 255, 255, 0.6); }
|
|
.debug-info { position: fixed; top: 10px; right: 10px; background: rgba(0, 0, 0, 0.8); color: #0ff; font-size: 12px; padding: 5px; border: 1px solid #0ff; z-index: 1001; }
|
|
@media (max-width: 768px) {
|
|
body { padding: 10px; }
|
|
#gameCanvas { max-width: 100%; height: auto; }
|
|
.desktop-controls { display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>PAC-MAN</h1>
|
|
<div id="score">SCORE: 0</div>
|
|
<div id="lives">LIVES: 3</div>
|
|
<div id="gameContainer">
|
|
<canvas id="gameCanvas" width="448" height="560"></canvas>
|
|
<div id="startScreen">
|
|
<div>PRESS SPACE TO START</div>
|
|
<div style="font-size: 16px; margin-top: 10px;">USE ARROW KEYS TO MOVE</div>
|
|
</div>
|
|
<div id="gameOver" style="display: none;">
|
|
<div>GAME OVER</div>
|
|
<div style="font-size: 16px; margin-top: 10px;">PRESS SPACE TO RESTART</div>
|
|
</div>
|
|
</div>
|
|
<div class="controls desktop-controls">
|
|
<div>↑ ↓ ← → : MOVE PAC-MAN</div>
|
|
<div>SPACE : START/RESTART GAME</div>
|
|
</div>
|
|
|
|
<!-- Mobile Controls -->
|
|
<div id="mobileControls" class="mobile-controls">
|
|
<div class="dpad">
|
|
<div class="dpad-btn dpad-up" data-direction="UP">↑</div>
|
|
<div class="dpad-btn dpad-down" data-direction="DOWN">↓</div>
|
|
<div class="dpad-btn dpad-left" data-direction="LEFT">←</div>
|
|
<div class="dpad-btn dpad-right" data-direction="RIGHT">→</div>
|
|
<div class="dpad-btn dpad-center"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile Space Button -->
|
|
<button id="mobileSpaceBtn" class="mobile-space-btn" style="display: block; background: #0ff; color: #000; font-size: 16px; font-weight: bold; padding: 10px 20px;">START GAME</button>
|
|
|
|
<!-- Debug Info -->
|
|
<div id="debugInfo" class="debug-info">
|
|
<div>Mobile: <span id="isMobileStatus">No</span></div>
|
|
<div>State: <span id="gameStateStatus">START</span></div>
|
|
<div>Controls: <span id="controlsStatus">Hidden</span></div>
|
|
</div>
|
|
|
|
<script>
|
|
// Get DOM elements
|
|
const canvas = document.getElementById('gameCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const scoreElement = document.getElementById('score');
|
|
const livesElement = document.getElementById('lives');
|
|
const gameOverElement = document.getElementById('gameOver');
|
|
const startScreenElement = document.getElementById('startScreen');
|
|
const mobileControlsElement = document.getElementById('mobileControls');
|
|
const mobileSpaceBtnElement = document.getElementById('mobileSpaceBtn');
|
|
const debugInfoElement = document.getElementById('debugInfo');
|
|
const isMobileStatusElement = document.getElementById('isMobileStatus');
|
|
const gameStateStatusElement = document.getElementById('gameStateStatus');
|
|
const controlsStatusElement = document.getElementById('controlsStatus');
|
|
|
|
// Update debug info
|
|
function updateDebugInfo() {
|
|
if (isMobileStatusElement) isMobileStatusElement.textContent = isMobile ? 'Yes' : 'No';
|
|
if (gameStateStatusElement) gameStateStatusElement.textContent = gameState;
|
|
if (controlsStatusElement) {
|
|
const isVisible = mobileControlsElement.classList.contains('active');
|
|
controlsStatusElement.textContent = isVisible ? 'Visible' : 'Hidden';
|
|
}
|
|
}
|
|
|
|
// Mobile detection - more comprehensive for Chrome Android
|
|
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
|
(window.innerWidth <= 768 && 'ontouchstart' in window);
|
|
|
|
// Chrome Android specific detection
|
|
const isChromeAndroid = /Chrome/.test(navigator.userAgent) && /Android/.test(navigator.userAgent);
|
|
console.log('Mobile detected:', isMobile);
|
|
console.log('Chrome Android detected:', isChromeAndroid);
|
|
console.log('User agent:', navigator.userAgent);
|
|
|
|
// Show mobile controls if on mobile device
|
|
if (isMobile) {
|
|
mobileControlsElement.classList.add('active');
|
|
mobileSpaceBtnElement.style.display = 'block';
|
|
// Adjust canvas size for mobile
|
|
const scale = Math.min(window.innerWidth / 500, 1);
|
|
canvas.style.width = (448 * scale) + 'px';
|
|
canvas.style.height = (560 * scale) + 'px';
|
|
} else {
|
|
// Also show mobile controls on desktop for testing and smaller screens
|
|
if (window.innerWidth <= 768) {
|
|
mobileControlsElement.classList.add('active');
|
|
mobileSpaceBtnElement.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
updateDebugInfo();
|
|
|
|
const CELL_SIZE = 14;
|
|
const MAZE_WIDTH = 32;
|
|
const MAZE_HEIGHT = 36;
|
|
|
|
let gameState = 'START'; // START, PLAYING, GAME_OVER, ATTRACT, READY, INTERMISSION
|
|
let score = 0;
|
|
let lives = 3;
|
|
let powerPelletActive = false;
|
|
let powerPelletTimer = 0;
|
|
let animationFrame = 0;
|
|
let attractModeTimer = 0;
|
|
let attractColorIndex = 0;
|
|
let tunnelMask = 0xFF;
|
|
let readyTimer = 0;
|
|
let intermissionTimer = 0;
|
|
let currentLevel = 1;
|
|
|
|
// Assembly-based ghost state timing
|
|
let ghostStateTimer = 0;
|
|
let ghostStateIndex = 0;
|
|
const ghostStateSequence = [0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0]; // CHASE/SCATTER pattern
|
|
|
|
// Maze layout based on DATMAZ data from assembly code
|
|
// 0 = wall, 1 = dot, 2 = power pellet, 3 = empty
|
|
const mazeLayout = [
|
|
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,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,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
|
[0,3,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0],
|
|
[0,2,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,2,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,1,1,1,1,1,1,1,1,0],
|
|
[0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0],
|
|
[0,1,1,1,1,1,1,0,0,1,1,1,1,0,0,0,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0],
|
|
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],
|
|
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],
|
|
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,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,1,1,1,1,1,1,1,1,0],
|
|
[0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0],
|
|
[0,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0],
|
|
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],
|
|
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],
|
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
|
[0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0],
|
|
[0,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,0,0],
|
|
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],
|
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
|
[0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0],
|
|
[0,2,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,2,0,0],
|
|
[0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0],
|
|
[0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0],
|
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
|
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
|
];
|
|
|
|
let maze = JSON.parse(JSON.stringify(mazeLayout));
|
|
|
|
// Pacman object
|
|
const pacman = {
|
|
x: 15 * CELL_SIZE,
|
|
y: 23 * CELL_SIZE,
|
|
size: CELL_SIZE - 2,
|
|
speed: PACMAN_SPEED,
|
|
direction: 'RIGHT',
|
|
nextDirection: null,
|
|
mouthOpen: true,
|
|
mouthAngle: 0.2
|
|
};
|
|
|
|
// Sound system based on assembly audio routines - enhanced for Chrome Android
|
|
class SoundSystem {
|
|
constructor() {
|
|
this.audioContext = null;
|
|
this.sounds = {};
|
|
this.initialized = false;
|
|
this.initAudio();
|
|
}
|
|
|
|
initAudio() {
|
|
try {
|
|
// Create audio context on first user interaction for Chrome Android
|
|
if (typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined') {
|
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
|
this.audioContext = new AudioContextClass();
|
|
|
|
// Resume audio context if suspended (Chrome Android requirement)
|
|
if (this.audioContext.state === 'suspended') {
|
|
this.audioContext.resume();
|
|
}
|
|
|
|
this.createSounds();
|
|
this.initialized = true;
|
|
console.log('Audio system initialized for Chrome Android');
|
|
} else {
|
|
console.log('Web Audio API not supported');
|
|
}
|
|
} catch (e) {
|
|
console.log('Audio initialization failed:', e);
|
|
}
|
|
}
|
|
|
|
createSounds() {
|
|
// Create basic sound effects based on assembly AUDF/AUDC values
|
|
this.sounds.dot = () => this.playTone(200, 50);
|
|
this.sounds.powerPellet = () => this.playTone(400, 200);
|
|
this.sounds.ghostEat = () => this.playTone(800, 100);
|
|
this.sounds.death = () => this.playTone(100, 1000);
|
|
this.sounds.waka = () => this.playTone(300, 30);
|
|
this.sounds.ready = () => this.playTone(500, 300);
|
|
}
|
|
|
|
playTone(frequency, duration) {
|
|
if (!this.audioContext || !this.initialized) return;
|
|
|
|
try {
|
|
// Resume audio context if suspended (Chrome Android)
|
|
if (this.audioContext.state === 'suspended') {
|
|
this.audioContext.resume();
|
|
}
|
|
|
|
const oscillator = this.audioContext.createOscillator();
|
|
const gainNode = this.audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(this.audioContext.destination);
|
|
|
|
oscillator.frequency.value = frequency;
|
|
oscillator.type = 'square';
|
|
|
|
gainNode.gain.setValueAtTime(0.1, this.audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + duration / 1000);
|
|
|
|
oscillator.start(this.audioContext.currentTime);
|
|
oscillator.stop(this.audioContext.currentTime + duration / 1000);
|
|
} catch (e) {
|
|
console.log('Error playing tone:', e);
|
|
}
|
|
}
|
|
|
|
play(soundName) {
|
|
if (this.sounds[soundName]) {
|
|
this.sounds[soundName]();
|
|
}
|
|
}
|
|
}
|
|
|
|
const soundSystem = new SoundSystem();
|
|
// Ghosts object with assembly-based properties
|
|
const ghosts = [
|
|
{
|
|
x: 14 * CELL_SIZE,
|
|
y: 15 * CELL_SIZE,
|
|
originalColor: '#ff0000',
|
|
color: '#ff0000',
|
|
direction: 'UP',
|
|
speed: GHOST_SPEED,
|
|
state: 0, // CHASE mode
|
|
mode: 'CHASE',
|
|
name: 'Blinky',
|
|
frightTimer: 0,
|
|
reverseDirection: false
|
|
},
|
|
{
|
|
x: 15 * CELL_SIZE,
|
|
y: 15 * CELL_SIZE,
|
|
originalColor: '#ffb8ff',
|
|
color: '#ffb8ff',
|
|
direction: 'DOWN',
|
|
speed: GHOST_SPEED,
|
|
state: 0, // CHASE mode
|
|
mode: 'CHASE',
|
|
name: 'Pinky',
|
|
frightTimer: 0,
|
|
reverseDirection: false
|
|
},
|
|
{
|
|
x: 14 * CELL_SIZE,
|
|
y: 16 * CELL_SIZE,
|
|
originalColor: '#00ffff',
|
|
color: '#00ffff',
|
|
direction: 'LEFT',
|
|
speed: GHOST_SPEED,
|
|
state: 0, // CHASE mode
|
|
mode: 'CHASE',
|
|
name: 'Inky',
|
|
frightTimer: 0,
|
|
reverseDirection: false
|
|
},
|
|
{
|
|
x: 15 * CELL_SIZE,
|
|
y: 16 * CELL_SIZE,
|
|
originalColor: '#ffb852',
|
|
color: '#ffb852',
|
|
direction: 'RIGHT',
|
|
speed: GHOST_SPEED,
|
|
state: 0, // CHASE mode
|
|
mode: 'CHASE',
|
|
name: 'Clyde',
|
|
frightTimer: 0,
|
|
reverseDirection: false
|
|
}
|
|
];
|
|
|
|
function drawMaze() {
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Attract mode color cycling based on assembly ADLIV routine
|
|
const attractColors = ['#0000ff', '#ff0000', '#ffff00', '#00ff00', '#ff00ff', '#00ffff'];
|
|
const wallColor = gameState === 'ATTRACT' ? attractColors[attractColorIndex] : '#0000ff';
|
|
|
|
for (let row = 0; row < MAZE_HEIGHT; row++) {
|
|
for (let col = 0; col < MAZE_WIDTH; col++) {
|
|
const x = col * CELL_SIZE;
|
|
const y = row * CELL_SIZE;
|
|
const cell = maze[row][col];
|
|
|
|
if (cell === 0) {
|
|
ctx.fillStyle = wallColor;
|
|
ctx.fillRect(x, y, CELL_SIZE, CELL_SIZE);
|
|
} else if (cell === 1) {
|
|
// Blinking dots in attract mode
|
|
if (gameState === 'ATTRACT' && Math.floor(animationFrame / 30) % 2 === 0) {
|
|
continue; // Skip drawing every other frame
|
|
}
|
|
ctx.fillStyle = '#fff';
|
|
ctx.beginPath();
|
|
ctx.arc(x + CELL_SIZE/2, y + CELL_SIZE/2, 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
} else if (cell === 2) {
|
|
// Power pellets with enhanced animation
|
|
if (gameState === 'ATTRACT' && Math.floor(animationFrame / 15) % 2 === 0) {
|
|
ctx.fillStyle = attractColors[(attractColorIndex + 1) % attractColors.length];
|
|
} else {
|
|
ctx.fillStyle = '#fff';
|
|
}
|
|
ctx.beginPath();
|
|
ctx.arc(x + CELL_SIZE/2, y + CELL_SIZE/2, 4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawReadyScreen() {
|
|
// READY1 routine - "READY!" text display
|
|
ctx.fillStyle = '#ff0';
|
|
ctx.font = '24px Courier New';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('READY!', canvas.width / 2, canvas.height / 2);
|
|
|
|
// Player indicator based on assembly logic
|
|
ctx.font = '16px Courier New';
|
|
ctx.fillText(`PLAYER ${currentLevel}`, canvas.width / 2, canvas.height / 2 + 30);
|
|
}
|
|
|
|
function drawTitleScreen() {
|
|
// Title/Intro screen based on assembly INTROF logic
|
|
ctx.fillStyle = '#ff0';
|
|
ctx.font = '32px Courier New';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('PAC-MAN', canvas.width / 2, canvas.height / 3);
|
|
|
|
ctx.font = '16px Courier New';
|
|
ctx.fillText('ATARI 1980', canvas.width / 2, canvas.height / 3 + 40);
|
|
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '20px Courier New';
|
|
ctx.fillText('PRESS SPACE TO START', canvas.width / 2, canvas.height * 2/3);
|
|
|
|
// Blinking instruction
|
|
if (Math.floor(animationFrame / 30) % 2 === 0) {
|
|
ctx.font = '14px Courier New';
|
|
ctx.fillText('USE ARROW KEYS TO MOVE', canvas.width / 2, canvas.height * 2/3 + 30);
|
|
}
|
|
}
|
|
|
|
function drawIntermission() {
|
|
// INTMIS routine - between-level animations
|
|
ctx.fillStyle = '#ff0';
|
|
ctx.font = '24px Courier New';
|
|
ctx.textAlign = 'center';
|
|
|
|
if (intermissionTimer < 180) {
|
|
ctx.fillText('INTERMISSION', canvas.width / 2, canvas.height / 2);
|
|
} else {
|
|
ctx.fillText(`LEVEL ${currentLevel} COMPLETE!`, canvas.width / 2, canvas.height / 2);
|
|
}
|
|
}
|
|
|
|
function drawPacman() {
|
|
ctx.save();
|
|
ctx.translate(pacman.x + pacman.size/2, pacman.y + pacman.size/2);
|
|
|
|
let rotation = 0;
|
|
switch(pacman.direction) {
|
|
case 'RIGHT': rotation = 0; break;
|
|
case 'DOWN': rotation = Math.PI/2; break;
|
|
case 'LEFT': rotation = Math.PI; break;
|
|
case 'UP': rotation = -Math.PI/2; break;
|
|
}
|
|
ctx.rotate(rotation);
|
|
|
|
ctx.fillStyle = '#ffff00';
|
|
ctx.beginPath();
|
|
|
|
// Enhanced mouth animation based on assembly PACRGT/PACLFT data
|
|
if (pacman.mouthOpen) {
|
|
const mouthSize = 0.2 + Math.sin(animationFrame * 0.3) * 0.3;
|
|
ctx.arc(0, 0, pacman.size/2, mouthSize, Math.PI * 2 - mouthSize);
|
|
} else {
|
|
ctx.arc(0, 0, pacman.size/2, 0, Math.PI * 2);
|
|
}
|
|
ctx.lineTo(0, 0);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawGhosts() {
|
|
ghosts.forEach((ghost, index) => {
|
|
ctx.fillStyle = powerPelletActive ? (powerPelletTimer < 60 && powerPelletTimer % 20 < 10 ? '#ffffff' : '#0000ff') : ghost.color;
|
|
|
|
// Ghost body shape based on assembly graphics
|
|
ctx.beginPath();
|
|
ctx.arc(ghost.x + CELL_SIZE/2, ghost.y + CELL_SIZE/2, CELL_SIZE/2 - 2, Math.PI, 0);
|
|
ctx.lineTo(ghost.x + CELL_SIZE - 2, ghost.y + CELL_SIZE - 2);
|
|
|
|
// Wavy bottom
|
|
for (let i = 0; i < 3; i++) {
|
|
const waveX = ghost.x + CELL_SIZE - 2 - (i * 4);
|
|
const waveY = ghost.y + CELL_SIZE - 2 + (i % 2 === 0 ? -2 : 0);
|
|
ctx.lineTo(waveX, waveY);
|
|
}
|
|
|
|
ctx.lineTo(ghost.x + 2, ghost.y + CELL_SIZE - 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Eyes based on assembly MONSEY data
|
|
if (!powerPelletActive) {
|
|
ctx.fillStyle = '#fff';
|
|
ctx.fillRect(ghost.x + 3, ghost.y + 4, 3, 3);
|
|
ctx.fillRect(ghost.x + 8, ghost.y + 4, 3, 3);
|
|
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(ghost.x + 4, ghost.y + 5, 1, 1);
|
|
ctx.fillRect(ghost.x + 9, ghost.y + 5, 1, 1);
|
|
} else {
|
|
// Scared eyes
|
|
ctx.fillStyle = '#fff';
|
|
ctx.fillRect(ghost.x + 4, ghost.y + 6, 2, 1);
|
|
ctx.fillRect(ghost.x + 8, ghost.y + 6, 2, 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Assembly-based MAZHND (Maze Handler) - faithful implementation
|
|
function MAZHND(x, y) {
|
|
const col = Math.floor(x / CELL_SIZE);
|
|
const row = Math.floor(y / CELL_SIZE);
|
|
|
|
// Check if position is valid (not a wall)
|
|
if (row < 0 || row >= MAZE_HEIGHT || col < 0 || col >= MAZE_WIDTH) {
|
|
return { valid: false, decisionPoint: false };
|
|
}
|
|
|
|
const cell = maze[row][col];
|
|
|
|
// Check if it's a wall
|
|
if (cell === 0) {
|
|
return { valid: false, decisionPoint: false };
|
|
}
|
|
|
|
// Check if at a decision point (intersection)
|
|
const neighbors = [];
|
|
const directions = [
|
|
{ dir: 'UP', dx: 0, dy: -1 },
|
|
{ dir: 'DOWN', dx: 0, dy: 1 },
|
|
{ dir: 'LEFT', dx: -1, dy: 0 },
|
|
{ dir: 'RIGHT', dx: 1, dy: 0 }
|
|
];
|
|
|
|
directions.forEach(({ dir, dx, dy }) => {
|
|
const newCol = col + dx;
|
|
const newRow = row + dy;
|
|
if (newRow >= 0 && newRow < MAZE_HEIGHT &&
|
|
newCol >= 0 && newCol < MAZE_WIDTH &&
|
|
maze[newRow][newCol] !== 0) {
|
|
neighbors.push(dir);
|
|
}
|
|
});
|
|
|
|
return {
|
|
valid: true,
|
|
decisionPoint: neighbors.length > 2,
|
|
availableDirections: neighbors
|
|
};
|
|
}
|
|
|
|
// Assembly-based MDIRCT (Monster Direction) - faithful implementation
|
|
function MDIRCT(ghost, targetX, targetY) {
|
|
const ghostX = ghost.x;
|
|
const ghostY = ghost.y;
|
|
|
|
// Calculate vertical and horizontal differences
|
|
const vDiff = targetY - ghostY;
|
|
const hDiff = targetX - ghostX;
|
|
|
|
let vDir = null;
|
|
let hDir = null;
|
|
|
|
// Determine vertical direction
|
|
if (vDiff < 0) {
|
|
vDir = 'UP';
|
|
} else if (vDiff > 0) {
|
|
vDir = 'DOWN';
|
|
}
|
|
|
|
// Determine horizontal direction
|
|
if (hDiff < 0) {
|
|
hDir = 'LEFT';
|
|
} else if (hDiff > 0) {
|
|
hDir = 'RIGHT';
|
|
}
|
|
|
|
// Assembly logic: prioritize direction with greater distance
|
|
let primaryDir, secondaryDir;
|
|
|
|
if (Math.abs(vDiff) > Math.abs(hDiff)) {
|
|
primaryDir = vDir;
|
|
secondaryDir = hDir;
|
|
} else {
|
|
primaryDir = hDir;
|
|
secondaryDir = vDir;
|
|
}
|
|
|
|
// Check if primary direction is valid
|
|
const mazhndResult = MAZHND(
|
|
ghost.x + (primaryDir === 'LEFT' ? -CELL_SIZE : primaryDir === 'RIGHT' ? CELL_SIZE : 0),
|
|
ghost.y + (primaryDir === 'UP' ? -CELL_SIZE : primaryDir === 'DOWN' ? CELL_SIZE : 0)
|
|
);
|
|
|
|
if (mazhndResult.valid) {
|
|
return primaryDir;
|
|
}
|
|
|
|
// Try secondary direction
|
|
if (secondaryDir) {
|
|
const mazhndResult2 = MAZHND(
|
|
ghost.x + (secondaryDir === 'LEFT' ? -CELL_SIZE : secondaryDir === 'RIGHT' ? CELL_SIZE : 0),
|
|
ghost.y + (secondaryDir === 'UP' ? -CELL_SIZE : secondaryDir === 'DOWN' ? CELL_SIZE : 0)
|
|
);
|
|
|
|
if (mazhndResult2.valid) {
|
|
return secondaryDir;
|
|
}
|
|
}
|
|
|
|
// If neither preferred direction works, find any valid direction
|
|
const directions = ['UP', 'DOWN', 'LEFT', 'RIGHT'];
|
|
for (const dir of directions) {
|
|
if (dir === getOppositeDirection(ghost.direction)) continue; // Don't reverse
|
|
|
|
const testResult = MAZHND(
|
|
ghost.x + (dir === 'LEFT' ? -CELL_SIZE : dir === 'RIGHT' ? CELL_SIZE : 0),
|
|
ghost.y + (dir === 'UP' ? -CELL_SIZE : dir === 'DOWN' ? CELL_SIZE : 0)
|
|
);
|
|
|
|
if (testResult.valid) {
|
|
return dir;
|
|
}
|
|
}
|
|
|
|
return ghost.direction; // Keep current direction if no valid moves
|
|
}
|
|
|
|
// Assembly-based SEEPAC (See Pacman) - faithful implementation
|
|
function SEEPAC(ghost) {
|
|
const ghostX = Math.floor(ghost.x / CELL_SIZE);
|
|
const ghostY = Math.floor(ghost.y / CELL_SIZE);
|
|
const pacmanX = Math.floor(pacman.x / CELL_SIZE);
|
|
const pacmanY = Math.floor(pacman.y / CELL_SIZE);
|
|
|
|
// Check if in same row
|
|
if (ghostY === pacmanY) {
|
|
// Check clear line of sight horizontally
|
|
const minX = Math.min(ghostX, pacmanX);
|
|
const maxX = Math.max(ghostX, pacmanX);
|
|
|
|
let clearPath = true;
|
|
for (let x = minX + 1; x < maxX; x++) {
|
|
if (maze[ghostY][x] === 0) {
|
|
clearPath = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (clearPath) {
|
|
return { canSee: true, direction: pacmanX > ghostX ? 'RIGHT' : 'LEFT' };
|
|
}
|
|
}
|
|
|
|
// Check if in same column
|
|
if (ghostX === pacmanX) {
|
|
// Check clear line of sight vertically
|
|
const minY = Math.min(ghostY, pacmanY);
|
|
const maxY = Math.max(ghostY, pacmanY);
|
|
|
|
let clearPath = true;
|
|
for (let y = minY + 1; y < maxY; y++) {
|
|
if (maze[y][ghostX] === 0) {
|
|
clearPath = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (clearPath) {
|
|
return { canSee: true, direction: pacmanY > ghostY ? 'DOWN' : 'UP' };
|
|
}
|
|
}
|
|
|
|
return { canSee: false, direction: null };
|
|
}
|
|
|
|
// Assembly-based GOHOME (Return to Ghost House) - faithful implementation
|
|
function GOHOME(ghost) {
|
|
// Target position in ghost house
|
|
const homeX = 14 * CELL_SIZE;
|
|
const homeY = 15 * CELL_SIZE;
|
|
|
|
// Use MDIRCT to find path home
|
|
const direction = MDIRCT(ghost, homeX, homeY);
|
|
|
|
// Move ghost
|
|
const speed = GHOST_SPEED * 1.5; // Faster when returning home
|
|
|
|
switch(direction) {
|
|
case 'UP': ghost.y -= speed; break;
|
|
case 'DOWN': ghost.y += speed; break;
|
|
case 'LEFT': ghost.x -= speed; break;
|
|
case 'RIGHT': ghost.x += speed; break;
|
|
}
|
|
|
|
ghost.direction = direction;
|
|
|
|
// Check if reached home
|
|
if (Math.abs(ghost.x - homeX) < CELL_SIZE && Math.abs(ghost.y - homeY) < CELL_SIZE) {
|
|
// Reset ghost to normal state
|
|
ghost.mode = 'CHASE';
|
|
ghost.color = ghost.originalColor;
|
|
ghost.x = homeX;
|
|
ghost.y = homeY;
|
|
return true; // Reached home
|
|
}
|
|
|
|
return false; // Still traveling home
|
|
}
|
|
|
|
// Helper function for opposite direction
|
|
function getOppositeDirection(direction) {
|
|
switch(direction) {
|
|
case 'UP': return 'DOWN';
|
|
case 'DOWN': return 'UP';
|
|
case 'LEFT': return 'RIGHT';
|
|
case 'RIGHT': return 'LEFT';
|
|
default: return direction;
|
|
}
|
|
}
|
|
|
|
function movePacman() {
|
|
if (pacman.nextDirection) {
|
|
const nextPos = getNextPosition(pacman.x, pacman.y, pacman.nextDirection);
|
|
const mazhndResult = MAZHND(nextPos.x, nextPos.y);
|
|
if (mazhndResult.valid) {
|
|
pacman.direction = pacman.nextDirection;
|
|
pacman.nextDirection = null;
|
|
}
|
|
}
|
|
|
|
const nextPos = getNextPosition(pacman.x, pacman.y, pacman.direction);
|
|
const mazhndResult = MAZHND(nextPos.x, nextPos.y);
|
|
if (mazhndResult.valid) {
|
|
pacman.x = nextPos.x;
|
|
pacman.y = nextPos.y;
|
|
|
|
// Enhanced tunnel logic based on assembly TUNNEL routine
|
|
if (pacman.x < 0) {
|
|
pacman.x = canvas.width - CELL_SIZE;
|
|
tunnelMask = 0x7F; // Start tunnel mask
|
|
} else if (pacman.x >= canvas.width) {
|
|
pacman.x = 0;
|
|
tunnelMask = 0x7F; // Start tunnel mask
|
|
} else if (tunnelMask !== 0xFF) {
|
|
// Gradually restore tunnel mask
|
|
tunnelMask = (tunnelMask << 1) | 0x01;
|
|
if (tunnelMask === 0xFF) tunnelMask = 0xFF;
|
|
}
|
|
}
|
|
|
|
// Animate mouth
|
|
pacman.mouthOpen = Math.floor(animationFrame / 3) % 2 === 0;
|
|
}
|
|
|
|
function getNextPosition(x, y, direction) {
|
|
switch(direction) {
|
|
case 'UP': return { x: x, y: y - pacman.speed };
|
|
case 'DOWN': return { x: x, y: y + pacman.speed };
|
|
case 'LEFT': return { x: x - pacman.speed, y: y };
|
|
case 'RIGHT': return { x: x + pacman.speed, y: y };
|
|
default: return { x: x, y: y };
|
|
}
|
|
}
|
|
|
|
// Assembly-based MONSTR (Monster State Machine) - faithful implementation
|
|
function MONSTR(ghost, index) {
|
|
// Ghost states based on assembly logic
|
|
// 0-7 = CHASE, 8-15 = SCATTER, 16-23 = FRIGHT, 24-31 = HOME
|
|
const state = ghost.state || 0;
|
|
|
|
if (state >= 0 && state <= 7) {
|
|
// CHASE mode
|
|
ghost.mode = 'CHASE';
|
|
return CHSSEQ(ghost, index);
|
|
} else if (state >= 8 && state <= 15) {
|
|
// SCATTER mode
|
|
ghost.mode = 'SCATTER';
|
|
return MPATRN(ghost, index);
|
|
} else if (state >= 16 && state <= 23) {
|
|
// FRIGHT mode
|
|
ghost.mode = 'FRIGHT';
|
|
return FRIGHTBEHAVIOR(ghost, index);
|
|
} else if (state >= 24 && state <= 31) {
|
|
// HOME mode
|
|
ghost.mode = 'HOME';
|
|
return GOHOME(ghost);
|
|
}
|
|
|
|
return ghost.direction;
|
|
}
|
|
|
|
// Assembly-based CHSSEQ (Chase Sequence) - faithful implementation
|
|
function CHSSEQ(ghost, index) {
|
|
let targetX = pacman.x;
|
|
let targetY = pacman.y;
|
|
|
|
// Individual ghost chase patterns based on assembly logic
|
|
switch(index) {
|
|
case 0: // Blinky - Direct chase
|
|
// Target is Pacman directly
|
|
break;
|
|
|
|
case 1: // Pinky - Ambush (target 4 cells ahead)
|
|
const ambushOffset = 4 * CELL_SIZE;
|
|
switch(pacman.direction) {
|
|
case 'UP':
|
|
targetY = pacman.y - ambushOffset;
|
|
targetX = pacman.x - ambushOffset; // Slight offset for accuracy
|
|
break;
|
|
case 'DOWN':
|
|
targetY = pacman.y + ambushOffset;
|
|
targetX = pacman.x + ambushOffset;
|
|
break;
|
|
case 'LEFT':
|
|
targetX = pacman.x - ambushOffset;
|
|
targetY = pacman.y - ambushOffset;
|
|
break;
|
|
case 'RIGHT':
|
|
targetX = pacman.x + ambushOffset;
|
|
targetY = pacman.y + ambushOffset;
|
|
break;
|
|
}
|
|
break;
|
|
|
|
case 2: // Inky - Complex targeting
|
|
// Target is based on Pacman's position and direction
|
|
// Uses Blinky's position as reference
|
|
const blinky = ghosts[0];
|
|
const pivotX = pacman.x + (pacman.direction === 'RIGHT' ? 2 * CELL_SIZE :
|
|
pacman.direction === 'LEFT' ? -2 * CELL_SIZE : 0);
|
|
const pivotY = pacman.y + (pacman.direction === 'DOWN' ? 2 * CELL_SIZE :
|
|
pacman.direction === 'UP' ? -2 * CELL_SIZE : 0);
|
|
targetX = pivotX + (pivotX - blinky.x);
|
|
targetY = pivotY + (pivotY - blinky.y);
|
|
break;
|
|
|
|
case 3: // Clyde - Random/chase hybrid
|
|
const distance = Math.sqrt(Math.pow(ghost.x - pacman.x, 2) + Math.pow(ghost.y - pacman.y, 2));
|
|
if (distance < 8 * CELL_SIZE) {
|
|
// If close, target scatter corner
|
|
targetX = CELL_SIZE;
|
|
targetY = MAZE_HEIGHT * CELL_SIZE;
|
|
} else {
|
|
// If far, chase Pacman
|
|
targetX = pacman.x;
|
|
targetY = pacman.y;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Use SEEPAC to check if ghost can see Pacman
|
|
const seeResult = SEEPAC(ghost);
|
|
if (seeResult.canSee && ghost.mode === 'CHASE') {
|
|
// Direct line of sight - move toward Pacman
|
|
return seeResult.direction;
|
|
}
|
|
|
|
// Use MDIRCT for pathfinding
|
|
return MDIRCT(ghost, targetX, targetY);
|
|
}
|
|
|
|
// Assembly-based MPATRN (Pattern Movement) - faithful implementation
|
|
function MPATRN(ghost, index) {
|
|
// Scatter mode - each ghost targets a corner
|
|
const corners = [
|
|
{ x: CELL_SIZE, y: CELL_SIZE }, // Top-left
|
|
{ x: (MAZE_WIDTH - 1) * CELL_SIZE, y: CELL_SIZE }, // Top-right
|
|
{ x: CELL_SIZE, y: (MAZE_HEIGHT - 1) * CELL_SIZE }, // Bottom-left
|
|
{ x: (MAZE_WIDTH - 1) * CELL_SIZE, y: (MAZE_HEIGHT - 1) * CELL_SIZE } // Bottom-right
|
|
];
|
|
|
|
const target = corners[index];
|
|
return MDIRCT(ghost, target.x, target.y);
|
|
}
|
|
|
|
// Assembly-based FRIGHTBEHAVIOR - faithful implementation
|
|
function FRIGHTBEHAVIOR(ghost, index) {
|
|
// In fright mode, ghosts move randomly away from Pacman
|
|
const directions = ['UP', 'DOWN', 'LEFT', 'RIGHT'];
|
|
const validDirections = [];
|
|
|
|
directions.forEach(dir => {
|
|
if (dir === getOppositeDirection(ghost.direction)) return; // Don't reverse
|
|
|
|
const testResult = MAZHND(
|
|
ghost.x + (dir === 'LEFT' ? -CELL_SIZE : dir === 'RIGHT' ? CELL_SIZE : 0),
|
|
ghost.y + (dir === 'UP' ? -CELL_SIZE : dir === 'DOWN' ? CELL_SIZE : 0)
|
|
);
|
|
|
|
if (testResult.valid) {
|
|
validDirections.push(dir);
|
|
}
|
|
});
|
|
|
|
if (validDirections.length > 0) {
|
|
// Random selection from valid directions
|
|
return validDirections[Math.floor(Math.random() * validDirections.length)];
|
|
}
|
|
|
|
return ghost.direction;
|
|
}
|
|
|
|
// Assembly-based DOTTST (Power Pellet Test) - faithful implementation
|
|
function DOTTST() {
|
|
const col = Math.floor(pacman.x / CELL_SIZE);
|
|
const row = Math.floor(pacman.y / CELL_SIZE);
|
|
|
|
if (maze[row][col] === 2) {
|
|
// Power pellet eaten
|
|
maze[row][col] = 3; // Clear the pellet
|
|
|
|
// Activate fright mode for all ghosts
|
|
ghosts.forEach((ghost, index) => {
|
|
ghost.state = 16; // Set to FRIGHT state
|
|
ghost.frightTimer = 300; // 5 seconds at 60fps
|
|
ghost.speed = GHOST_SPEED * 0.5; // Slower in fright mode
|
|
ghost.reverseDirection = true; // Ghosts reverse direction
|
|
});
|
|
|
|
score += 50;
|
|
soundSystem.play('powerPellet');
|
|
updateScore();
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Assembly-based MUNCHY (Dot Eating) - faithful implementation
|
|
function MUNCHY() {
|
|
const col = Math.floor(pacman.x / CELL_SIZE);
|
|
const row = Math.floor(pacman.y / CELL_SIZE);
|
|
|
|
if (maze[row][col] === 1) {
|
|
// Dot eaten
|
|
maze[row][col] = 3; // Clear the dot
|
|
score += 10;
|
|
soundSystem.play('dot');
|
|
updateScore();
|
|
|
|
// Waka-waka sound effect timing
|
|
if (animationFrame % 10 === 0) {
|
|
soundSystem.play('waka');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function moveGhosts() {
|
|
ghosts.forEach((ghost, index) => {
|
|
// Handle reverse direction at fright mode start
|
|
if (ghost.reverseDirection) {
|
|
ghost.direction = getOppositeDirection(ghost.direction);
|
|
ghost.reverseDirection = false;
|
|
}
|
|
|
|
// Update fright timer
|
|
if (ghost.frightTimer > 0) {
|
|
ghost.frightTimer--;
|
|
if (ghost.frightTimer === 0) {
|
|
ghost.state = 0; // Back to CHASE mode
|
|
ghost.speed = GHOST_SPEED;
|
|
}
|
|
}
|
|
|
|
// Use MONSTR state machine to determine direction
|
|
const newDirection = MONSTR(ghost, index);
|
|
ghost.direction = newDirection;
|
|
|
|
// Move ghost
|
|
const speed = ghost.speed || GHOST_SPEED;
|
|
switch(ghost.direction) {
|
|
case 'UP': ghost.y -= speed; break;
|
|
case 'DOWN': ghost.y += speed; break;
|
|
case 'LEFT': ghost.x -= speed; break;
|
|
case 'RIGHT': ghost.x += speed; break;
|
|
}
|
|
|
|
// Tunnel wrapping
|
|
if (ghost.x < 0) ghost.x = canvas.width - CELL_SIZE;
|
|
if (ghost.x >= canvas.width) ghost.x = 0;
|
|
});
|
|
}
|
|
|
|
function getNextPositionGhost(x, y, direction) {
|
|
switch(direction) {
|
|
case 'UP': return { x: x, y: y - ghost.speed };
|
|
case 'DOWN': return { x: x, y: y + ghost.speed };
|
|
case 'LEFT': return { x: x - ghost.speed, y: y };
|
|
case 'RIGHT': return { x: x + ghost.speed, y: y };
|
|
default: return { x: x, y: y };
|
|
}
|
|
}
|
|
|
|
function chasePacman(ghost, validDirections) {
|
|
const dx = pacman.x - ghost.x;
|
|
const dy = pacman.y - ghost.y;
|
|
|
|
if (Math.abs(dx) > Math.abs(dy)) {
|
|
return dx > 0 && validDirections.includes('RIGHT') ? 'RIGHT' :
|
|
dx < 0 && validDirections.includes('LEFT') ? 'LEFT' :
|
|
dy > 0 && validDirections.includes('DOWN') ? 'DOWN' : 'UP';
|
|
} else {
|
|
return dy > 0 && validDirections.includes('DOWN') ? 'DOWN' :
|
|
dy < 0 && validDirections.includes('UP') ? 'UP' :
|
|
dx > 0 && validDirections.includes('RIGHT') ? 'RIGHT' : 'LEFT';
|
|
}
|
|
}
|
|
|
|
function ambushPacman(ghost, validDirections) {
|
|
// Predict where Pacman will be based on his direction
|
|
let targetX = pacman.x;
|
|
let targetY = pacman.y;
|
|
|
|
switch(pacman.direction) {
|
|
case 'RIGHT': targetX += CELL_SIZE * 4; break;
|
|
case 'LEFT': targetX -= CELL_SIZE * 4; break;
|
|
case 'UP': targetY -= CELL_SIZE * 4; break;
|
|
case 'DOWN': targetY += CELL_SIZE * 4; break;
|
|
}
|
|
|
|
const dx = targetX - ghost.x;
|
|
const dy = targetY - ghost.y;
|
|
|
|
if (Math.abs(dx) > Math.abs(dy)) {
|
|
return dx > 0 && validDirections.includes('RIGHT') ? 'RIGHT' :
|
|
dx < 0 && validDirections.includes('LEFT') ? 'LEFT' :
|
|
dy > 0 && validDirections.includes('DOWN') ? 'DOWN' : 'UP';
|
|
} else {
|
|
return dy > 0 && validDirections.includes('DOWN') ? 'DOWN' :
|
|
dy < 0 && validDirections.includes('UP') ? 'UP' :
|
|
dx > 0 && validDirections.includes('RIGHT') ? 'RIGHT' : 'LEFT';
|
|
}
|
|
}
|
|
|
|
function checkCollisions() {
|
|
// Assembly-based collision detection using DOTTST and MUNCHY
|
|
|
|
// Check for power pellet first (DOTTST routine)
|
|
if (DOTTST()) {
|
|
// Power pellet was eaten, ghosts are now in fright mode
|
|
return;
|
|
}
|
|
|
|
// Check for regular dot (MUNCHY routine)
|
|
MUNCHY();
|
|
|
|
// Check ghost collisions (COLCHK routine)
|
|
ghosts.forEach((ghost, index) => {
|
|
const distance = Math.sqrt(Math.pow(pacman.x - ghost.x, 2) + Math.pow(pacman.y - ghost.y, 2));
|
|
if (distance < CELL_SIZE * 0.8) { // Collision detected
|
|
if (ghost.mode === 'FRIGHT') {
|
|
// Ghost eaten - return to home
|
|
ghost.state = 24; // Set to HOME state
|
|
ghost.speed = GHOST_SPEED * 1.5; // Faster when returning home
|
|
ghost.color = '#ffffff'; // Eyes only
|
|
score += 200;
|
|
soundSystem.play('ghostEat');
|
|
updateScore();
|
|
} else {
|
|
// Pacman death sequence
|
|
soundSystem.play('death');
|
|
lives--;
|
|
updateLives();
|
|
if (lives <= 0) {
|
|
gameState = 'GAME_OVER';
|
|
gameOverElement.style.display = 'block';
|
|
} else {
|
|
gameState = 'READY';
|
|
readyTimer = 180; // 3 seconds at 60fps
|
|
resetPositions();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function resetPositions() {
|
|
pacman.x = 15 * CELL_SIZE;
|
|
pacman.y = 23 * CELL_SIZE;
|
|
pacman.direction = 'RIGHT';
|
|
|
|
ghosts[0].x = 14 * CELL_SIZE; ghosts[0].y = 15 * CELL_SIZE;
|
|
ghosts[1].x = 15 * CELL_SIZE; ghosts[1].y = 15 * CELL_SIZE;
|
|
ghosts[2].x = 14 * CELL_SIZE; ghosts[2].y = 16 * CELL_SIZE;
|
|
ghosts[3].x = 15 * CELL_SIZE; ghosts[3].y = 16 * CELL_SIZE;
|
|
}
|
|
|
|
function updateScore() {
|
|
scoreElement.textContent = `SCORE: ${score}`;
|
|
}
|
|
|
|
function updateLives() {
|
|
livesElement.textContent = `LIVES: ${lives}`;
|
|
}
|
|
|
|
function checkWinCondition() {
|
|
let dotsRemaining = 0;
|
|
for (let row = 0; row < MAZE_HEIGHT; row++) {
|
|
for (let col = 0; col < MAZE_WIDTH; col++) {
|
|
if (maze[row][col] === 1 || maze[row][col] === 2) {
|
|
dotsRemaining++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (dotsRemaining === 0) {
|
|
gameState = 'INTERMISSION';
|
|
intermissionTimer = 300; // 5 seconds at 60fps
|
|
currentLevel++;
|
|
}
|
|
}
|
|
|
|
function gameLoop() {
|
|
if (gameState === 'ATTRACT') {
|
|
// Attract mode logic based on assembly SEQATM routine
|
|
attractModeTimer++;
|
|
if (attractModeTimer % 60 === 0) {
|
|
attractColorIndex = (attractColorIndex + 1) % 6;
|
|
}
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
drawMaze();
|
|
|
|
// Demo gameplay in attract mode
|
|
if (attractModeTimer % 120 === 0) {
|
|
// Random direction changes for demo
|
|
const directions = ['UP', 'DOWN', 'LEFT', 'RIGHT'];
|
|
pacman.nextDirection = directions[Math.floor(Math.random() * directions.length)];
|
|
}
|
|
|
|
movePacman();
|
|
moveGhosts();
|
|
checkCollisions();
|
|
|
|
animationFrame++;
|
|
updateDebugInfo();
|
|
|
|
// Auto-start game after attract mode timeout
|
|
if (attractModeTimer > 600) { // 10 seconds at 60fps
|
|
gameState = 'START';
|
|
attractModeTimer = 0;
|
|
startScreenElement.style.display = 'block';
|
|
}
|
|
|
|
} else if (gameState === 'START') {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
drawTitleScreen();
|
|
animationFrame++;
|
|
updateDebugInfo();
|
|
|
|
} else if (gameState === 'READY') {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
drawMaze();
|
|
drawPacman();
|
|
drawGhosts();
|
|
drawReadyScreen();
|
|
|
|
readyTimer--;
|
|
if (readyTimer <= 0) {
|
|
gameState = 'PLAYING';
|
|
soundSystem.play('ready');
|
|
}
|
|
animationFrame++;
|
|
updateDebugInfo();
|
|
|
|
} else if (gameState === 'INTERMISSION') {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
drawIntermission();
|
|
|
|
intermissionTimer--;
|
|
if (intermissionTimer <= 0) {
|
|
maze = JSON.parse(JSON.stringify(mazeLayout));
|
|
resetPositions();
|
|
gameState = 'READY';
|
|
readyTimer = 180;
|
|
}
|
|
animationFrame++;
|
|
updateDebugInfo();
|
|
|
|
} else if (gameState === 'PLAYING') {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
drawMaze();
|
|
drawPacman();
|
|
drawGhosts();
|
|
|
|
movePacman();
|
|
moveGhosts();
|
|
checkCollisions();
|
|
checkWinCondition();
|
|
|
|
// Assembly-based ghost state timing
|
|
ghostStateTimer++;
|
|
if (ghostStateTimer >= 420) { // 7 seconds at 60fps
|
|
ghostStateTimer = 0;
|
|
ghostStateIndex = (ghostStateIndex + 1) % ghostStateSequence.length;
|
|
|
|
// Update ghost states based on sequence (skip if in fright mode)
|
|
ghosts.forEach(ghost => {
|
|
if (ghost.state < 16) { // Not in fright mode
|
|
ghost.state = ghostStateSequence[ghostStateIndex];
|
|
}
|
|
});
|
|
}
|
|
|
|
if (powerPelletActive) {
|
|
powerPelletTimer--;
|
|
if (powerPelletTimer <= 0) {
|
|
powerPelletActive = false;
|
|
}
|
|
}
|
|
|
|
// Waka-waka sound effect
|
|
if (animationFrame % 10 === 0 && Math.random() < 0.3) {
|
|
soundSystem.play('waka');
|
|
}
|
|
|
|
animationFrame++;
|
|
updateDebugInfo();
|
|
|
|
// Auto-switch to attract mode after inactivity
|
|
if (animationFrame % 3600 === 0) { // 1 minute of inactivity
|
|
gameState = 'ATTRACT';
|
|
attractModeTimer = 0;
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
// Keyboard controls with attract mode support
|
|
document.addEventListener('keydown', (e) => {
|
|
// Reset inactivity timer on any key press
|
|
animationFrame = 0;
|
|
|
|
if (gameState === 'ATTRACT') {
|
|
if (e.code === 'Space') {
|
|
gameState = 'START';
|
|
attractModeTimer = 0;
|
|
soundSystem.play('ready');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (gameState === 'START' && e.code === 'Space') {
|
|
gameState = 'READY';
|
|
readyTimer = 180;
|
|
startScreenElement.style.display = 'none';
|
|
resetPositions();
|
|
soundSystem.play('ready');
|
|
} else if (gameState === 'GAME_OVER' && e.code === 'Space') {
|
|
gameState = 'START';
|
|
gameOverElement.style.display = 'none';
|
|
score = 0;
|
|
lives = 3;
|
|
currentLevel = 1;
|
|
updateScore();
|
|
updateLives();
|
|
maze = JSON.parse(JSON.stringify(mazeLayout));
|
|
soundSystem.play('ready');
|
|
} else if (gameState === 'PLAYING') {
|
|
switch(e.code) {
|
|
case 'ArrowUp': pacman.nextDirection = 'UP'; break;
|
|
case 'ArrowDown': pacman.nextDirection = 'DOWN'; break;
|
|
case 'ArrowLeft': pacman.nextDirection = 'LEFT'; break;
|
|
case 'ArrowRight': pacman.nextDirection = 'RIGHT'; break;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Mobile touch controls - enhanced for Chrome Android
|
|
function setupMobileControls() {
|
|
console.log('Setting up mobile controls for Chrome Android...');
|
|
const dpadButtons = document.querySelectorAll('.dpad-btn');
|
|
|
|
dpadButtons.forEach(button => {
|
|
const direction = button.dataset.direction;
|
|
if (!direction) return; // Skip center button
|
|
|
|
console.log('Setting up button for direction:', direction);
|
|
|
|
// Enhanced touch events for Chrome Android
|
|
button.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
console.log('Touch start for direction:', direction, 'Game state:', gameState);
|
|
|
|
// Add visual feedback
|
|
button.style.backgroundColor = 'rgba(255, 255, 0, 0.8)';
|
|
|
|
if (gameState === 'PLAYING') {
|
|
pacman.nextDirection = direction;
|
|
console.log('Set pacman direction to:', direction);
|
|
} else {
|
|
// For testing, allow direction changes in any state
|
|
pacman.nextDirection = direction;
|
|
console.log('Set pacman direction to:', direction, '(testing mode)');
|
|
}
|
|
}, { passive: false });
|
|
|
|
button.addEventListener('touchend', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Remove visual feedback
|
|
button.style.backgroundColor = '';
|
|
}, { passive: false });
|
|
|
|
// Mouse events for desktop testing
|
|
button.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
console.log('Mouse down for direction:', direction, 'Game state:', gameState);
|
|
button.style.backgroundColor = 'rgba(255, 255, 0, 0.8)';
|
|
|
|
if (gameState === 'PLAYING') {
|
|
pacman.nextDirection = direction;
|
|
console.log('Set pacman direction to:', direction);
|
|
} else {
|
|
pacman.nextDirection = direction;
|
|
console.log('Set pacman direction to:', direction, '(testing mode)');
|
|
}
|
|
});
|
|
|
|
button.addEventListener('mouseup', (e) => {
|
|
e.preventDefault();
|
|
button.style.backgroundColor = '';
|
|
});
|
|
});
|
|
|
|
// Enhanced space button for Chrome Android
|
|
mobileSpaceBtnElement.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
console.log('Mobile space button touched');
|
|
mobileSpaceBtnElement.style.backgroundColor = 'rgba(0, 255, 255, 0.8)';
|
|
handleSpaceButton();
|
|
}, { passive: false });
|
|
|
|
mobileSpaceBtnElement.addEventListener('touchend', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
mobileSpaceBtnElement.style.backgroundColor = '';
|
|
}, { passive: false });
|
|
|
|
// Mouse events for testing
|
|
mobileSpaceBtnElement.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
console.log('Mobile space button clicked');
|
|
mobileSpaceBtnElement.style.backgroundColor = 'rgba(0, 255, 255, 0.8)';
|
|
handleSpaceButton();
|
|
});
|
|
|
|
mobileSpaceBtnElement.addEventListener('mouseup', (e) => {
|
|
e.preventDefault();
|
|
mobileSpaceBtnElement.style.backgroundColor = '';
|
|
});
|
|
|
|
console.log('Mobile controls setup complete for Chrome Android');
|
|
}
|
|
|
|
function handleSpaceButton() {
|
|
console.log('=== SPACE BUTTON PRESSED ===');
|
|
console.log('Current game state:', gameState);
|
|
console.log('Space button element:', mobileSpaceBtnElement);
|
|
|
|
if (gameState === 'ATTRACT') {
|
|
console.log('ATTRACT → START transition');
|
|
gameState = 'START';
|
|
attractModeTimer = 0;
|
|
startScreenElement.style.display = 'block';
|
|
console.log('ATTRACT → START completed');
|
|
soundSystem.play('ready');
|
|
} else if (gameState === 'START') {
|
|
console.log('START → READY transition');
|
|
gameState = 'READY';
|
|
readyTimer = 180;
|
|
startScreenElement.style.display = 'none';
|
|
resetPositions();
|
|
console.log('START → READY completed');
|
|
soundSystem.play('ready');
|
|
} else if (gameState === 'GAME_OVER') {
|
|
console.log('GAME_OVER → START transition');
|
|
gameState = 'START';
|
|
gameOverElement.style.display = 'none';
|
|
score = 0;
|
|
lives = 3;
|
|
currentLevel = 1;
|
|
updateScore();
|
|
updateLives();
|
|
maze = JSON.parse(JSON.stringify(mazeLayout));
|
|
startScreenElement.style.display = 'block';
|
|
console.log('GAME_OVER → START completed');
|
|
soundSystem.play('ready');
|
|
} else if (gameState === 'PLAYING') {
|
|
console.log('Already in PLAYING state');
|
|
} else {
|
|
console.log('Unhandled game state:', gameState);
|
|
}
|
|
|
|
console.log('New game state:', gameState);
|
|
updateDebugInfo();
|
|
console.log('=== SPACE BUTTON HANDLER COMPLETE ===');
|
|
}
|
|
|
|
// Initialize game with ATTRACT mode (assembly-based startup)
|
|
console.log('=== INITIALIZING GAME ===');
|
|
gameState = 'ATTRACT';
|
|
attractModeTimer = 0;
|
|
|
|
console.log('Mobile detected:', isMobile);
|
|
console.log('Window width:', window.innerWidth);
|
|
|
|
// Setup mobile controls
|
|
if (isMobile || window.innerWidth <= 768) {
|
|
console.log('Setting up mobile controls...');
|
|
setupMobileControls();
|
|
} else {
|
|
console.log('Not setting up mobile controls - desktop mode');
|
|
}
|
|
|
|
// Show mobile controls
|
|
mobileControlsElement.classList.add('active');
|
|
mobileSpaceBtnElement.style.display = 'block';
|
|
|
|
console.log('Mobile space button element:', mobileSpaceBtnElement);
|
|
console.log('Mobile controls element:', mobileControlsElement);
|
|
|
|
// Update debug info
|
|
updateDebugInfo();
|
|
|
|
console.log('Full Pacman game initialized with ATTRACT mode');
|
|
console.log('Game state:', gameState);
|
|
console.log('=== GAME INITIALIZATION COMPLETE ===');
|
|
|
|
// Start game loop
|
|
gameLoop();
|
|
</script>
|
|
</body>
|
|
</html>
|