/** * PhysicsEngine.js * Helper for physics simulation, collision detection, and movement * Handles Mario physics, enemy physics, particles, and camera */ export class PhysicsEngine { /** * Update Mario movement based on key inputs * @param {Object} mario - Mario object * @param {Object} keys - Key states object * @param {Object} config - Game config with moveSpeed and jumpForce * @param {boolean} isCelebrating - If true, disable movement * @param {Function} playSound - Sound callback for jump */ static updateMarioMovement(mario, keys, config, isCelebrating, playSound) { // Don't update movement during celebration if (isCelebrating) return; // Horizontal movement if (keys['ArrowLeft'] || keys['KeyA']) { mario.velocityX = -config.moveSpeed; mario.facing = 'left'; } else if (keys['ArrowRight'] || keys['KeyD']) { mario.velocityX = config.moveSpeed; mario.facing = 'right'; } else { mario.velocityX *= 0.8; // Friction } // Jumping if ((keys['ArrowUp'] || keys['KeyW'] || keys['Space']) && mario.onGround) { mario.velocityY = config.jumpForce; mario.onGround = false; if (playSound) playSound('jump'); } } /** * Update Mario physics (gravity and position) * @param {Object} mario - Mario object * @param {Object} config - Game config with gravity * @param {Object} level - Current level data * @param {boolean} levelCompleted - If level is completed * @param {Function} onFallOff - Callback when Mario falls off world */ static updateMarioPhysics(mario, config, level, levelCompleted, onFallOff) { // Apply gravity mario.velocityY += config.gravity; // Update position mario.x += mario.velocityX; mario.y += mario.velocityY; // Prevent going off left edge if (mario.x < 0) { mario.x = 0; } // Stop Mario at finish line during celebration if (mario.x > level.endX && levelCompleted) { mario.x = level.endX; mario.velocityX = 0; } // Check if Mario fell off the world if (mario.y > config.canvasHeight + 100) { if (onFallOff) onFallOff(); } } /** * Update enemy movement and AI * @param {Array} enemies - Array of enemies * @param {Array} walls - Array of walls * @param {Array} platforms - Array of platforms * @param {number} levelWidth - Level width * @param {boolean} isCelebrating - If true, disable updates */ static updateEnemies(enemies, walls, platforms, levelWidth, isCelebrating) { // Don't update enemies during celebration if (isCelebrating) return; enemies.forEach(enemy => { // Store old position for collision detection const oldX = enemy.x; enemy.x += enemy.velocityX; // Check wall collisions const hitWall = walls.some(wall => { return enemy.x < wall.x + wall.width && enemy.x + enemy.width > wall.x && enemy.y < wall.y + wall.height && enemy.y + enemy.height > wall.y; }); if (hitWall) { // Reverse position and direction enemy.x = oldX; enemy.velocityX *= -1; console.log(`🧱 Enemy hit wall, reversing direction`); } // Simple AI: reverse direction at platform edges const platform = platforms.find(p => enemy.x >= p.x - 10 && enemy.x <= p.x + p.width + 10 && enemy.y >= p.y - enemy.height - 5 && enemy.y <= p.y + 5 ); if (!platform || enemy.x <= 0 || enemy.x >= levelWidth) { enemy.velocityX *= -1; } }); } /** * Check all collisions (platforms, walls, enemies, etc.) * @param {Object} mario - Mario object * @param {Object} gameState - All game entities * @param {Object} callbacks - Callbacks for various collision events */ static checkCollisions(mario, gameState, callbacks) { const { platforms, questionBlocks, enemies, walls, catapults, piranhaPlants, boulders, flyingEyes } = gameState; const { onQuestionBlock, onEnemyDefeat, onMarioDeath, onAddParticles } = callbacks; // Platform collisions mario.onGround = false; platforms.forEach(platform => { if (this.isColliding(mario, platform)) { // Check if Mario is landing on top if (mario.velocityY > 0 && mario.y + mario.height - mario.velocityY <= platform.y + 5) { mario.y = platform.y - mario.height; mario.velocityY = 0; mario.onGround = true; } // Hit from below else if (mario.velocityY < 0 && mario.y - mario.velocityY >= platform.y + platform.height - 5) { mario.y = platform.y + platform.height; mario.velocityY = 0; } // Side collision else { if (mario.velocityX > 0) { mario.x = platform.x - mario.width; } else if (mario.velocityX < 0) { mario.x = platform.x + platform.width; } mario.velocityX = 0; } } }); // Boulder collisions (grounded boulders only) boulders.forEach(boulder => { if (boulder.hasLanded && this.isColliding(mario, boulder)) { console.log(`🪨 Mario hit by grounded boulder - restarting level`); if (onMarioDeath) onMarioDeath(); } }); // Question block collisions (non-blocking - just trigger on contact) questionBlocks.forEach(block => { if (!block.hit && this.isColliding(mario, block)) { // Trigger question block on any contact (pass-through) if (onQuestionBlock) onQuestionBlock(block); } }); // Wall collisions walls.forEach(wall => { if (this.isColliding(mario, wall)) { // Determine collision direction based on previous position const overlapLeft = (mario.x + mario.width) - wall.x; const overlapRight = (wall.x + wall.width) - mario.x; const overlapTop = (mario.y + mario.height) - wall.y; const overlapBottom = (wall.y + wall.height) - mario.y; // Find the smallest overlap to determine collision side const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom); if (minOverlap === overlapTop && mario.velocityY > 0) { // Landing on top of wall mario.y = wall.y - mario.height; mario.velocityY = 0; mario.onGround = true; } else if (minOverlap === overlapBottom && mario.velocityY < 0) { // Hit wall from below mario.y = wall.y + wall.height; mario.velocityY = 0; } else if (minOverlap === overlapLeft && mario.velocityX > 0) { // Hit wall from left side mario.x = wall.x - mario.width; mario.velocityX = 0; } else if (minOverlap === overlapRight && mario.velocityX < 0) { // Hit wall from right side mario.x = wall.x + wall.width; mario.velocityX = 0; } } }); // Catapult collisions (solid obstacles) catapults.forEach(catapult => { if (this.isColliding(mario, catapult)) { // Treat catapults as solid platforms if (mario.velocityY > 0) { mario.y = catapult.y - mario.height; mario.velocityY = 0; mario.onGround = true; } } }); // Enemy collisions enemies.forEach((enemy, index) => { if (this.isColliding(mario, enemy)) { // Check if Mario jumped on enemy if (mario.velocityY > 0 && mario.y < enemy.y + enemy.height / 2) { // Enemy defeated mario.velocityY = -8; // Bounce if (onEnemyDefeat) onEnemyDefeat(index); if (onAddParticles) onAddParticles(enemy.x, enemy.y, '#FFD700'); } else { // Mario hit by enemy console.log(`👾 Mario hit by enemy - restarting level`); if (onMarioDeath) onMarioDeath(); } } }); // Piranha Plant collisions piranhaPlants.forEach(plant => { if (!plant.flattened && this.isColliding(mario, plant)) { // Check if Mario jumped on plant if (mario.velocityY > 0 && mario.y < plant.y + plant.height / 2) { // Plant flattened plant.flattened = true; plant.flattenedTimer = 120; // Flattened for 2 seconds mario.velocityY = -8; // Bounce if (onAddParticles) onAddParticles(plant.x, plant.y, '#228B22'); console.log(`🌸 Mario flattened piranha plant`); } else { // Mario hit by plant console.log(`🌸 Mario hit by piranha plant - restarting level`); if (onMarioDeath) onMarioDeath(); } } // Check if stepping on flattened plant if (plant.flattened && this.isColliding(mario, plant)) { mario.onGround = true; } }); // Flying Eye collisions if (flyingEyes) { flyingEyes.forEach((eye, index) => { // Eye position is CENTER, convert to top-left corner for collision const eyeLeft = eye.x - eye.width / 2; const eyeTop = eye.y - eye.height / 2; const eyeRect = { x: eyeLeft, y: eyeTop, width: eye.width, height: eye.height }; if (this.isColliding(mario, eyeRect)) { // Check if Mario jumped on eye from above if (mario.velocityY > 0 && mario.y < eyeTop + eye.height / 2) { // Eye defeated mario.velocityY = -8; // Bounce flyingEyes.splice(index, 1); // Remove eye if (onAddParticles) onAddParticles(eye.x, eye.y, '#DC143C'); console.log(`👁️ Mario defeated flying eye`); } else { // Mario hit by eye console.log(`👁️ Mario hit by flying eye - restarting level`); if (onMarioDeath) onMarioDeath(); } } }); } } /** * Rectangle-Rectangle collision detection * @param {Object} rect1 - First rectangle * @param {Object} rect2 - Second rectangle * @returns {boolean} - True if colliding */ static isColliding(rect1, rect2) { return rect1.x < rect2.x + rect2.width && rect1.x + rect1.width > rect2.x && rect1.y < rect2.y + rect2.height && rect1.y + rect1.height > rect2.y; } /** * Update camera to follow Mario * @param {Object} camera - Camera object with x, y * @param {Object} mario - Mario object * @param {number} canvasWidth - Canvas width */ static updateCamera(camera, mario, canvasWidth) { // Camera follows Mario horizontally, centered camera.x = mario.x - canvasWidth / 2 + mario.width / 2; camera.y = 0; // Fixed vertical camera } /** * Create particle effects * @param {number} x - X position * @param {number} y - Y position * @param {string} color - Particle color * @param {Array} particles - Particles array to add to * @param {number} count - Number of particles to create */ static addParticles(x, y, color, particles, count = 10) { for (let i = 0; i < count; i++) { particles.push({ x: x, y: y, velocityX: (Math.random() - 0.5) * 8, velocityY: (Math.random() - 0.5) * 8, life: 1.0, decay: 0.02, size: 4, color: color }); } } /** * Create small particle burst * @param {number} x - X position * @param {number} y - Y position * @param {string} color - Particle color * @param {Array} particles - Particles array to add to */ static addSmallParticles(x, y, color, particles) { this.addParticles(x, y, color, particles, 5); } /** * Update all particles * @param {Array} particles - Array of particles * @returns {Array} - Updated particles array (with dead particles removed) */ static updateParticles(particles) { const updatedParticles = []; particles.forEach(particle => { particle.x += particle.velocityX; particle.y += particle.velocityY; particle.velocityY += 0.3; // Gravity particle.life -= particle.decay; if (particle.life > 0) { updatedParticles.push(particle); } }); return updatedParticles; } } export default PhysicsEngine;