/** * Catapult.js * Enemy that launches boulders and stone rain at Mario * Catapults appear in level 4+, Onagers (stronger) in level 5+ */ export class Catapult { /** * Generate catapults for a level * @param {Object} level - Level data * @param {number} levelIndex - Level index * @param {number} levelWidth - Width of the level * @param {number} canvasHeight - Canvas height * @returns {Array} - Array of catapult objects */ static generate(level, levelIndex, levelWidth, canvasHeight) { const catapults = []; let catapultCount = 1; // Always 1 catapult for level 4+ let onagerCount = 0; // Level 5+ gets onagers if (levelIndex >= 4) { onagerCount = 1; // 1 onager for level 5+ } const totalCount = catapultCount + onagerCount; console.log(`🏹 Generating ${catapultCount} catapult(s) and ${onagerCount} onager(s) for level ${levelIndex + 1}`); for (let i = 0; i < totalCount; i++) { const isOnager = i >= catapultCount; // Onagers come after catapults // Place catapults near END of level const nearEndX = levelWidth * 0.7; // 70% through level const catapultX = nearEndX + (i * 300) + Math.random() * 200; let catapultY = canvasHeight - 100; // Default: on background ground // Check if there's a platform, wall, or stair above this position const platformAbove = this._findPlatformAbove(catapultX, catapultY, level.platforms || []); const wallAbove = this._findWallAbove(catapultX, catapultY, level.walls || []); const stairAbove = this._findStairAbove(catapultX, catapultY, level.stairs || []); // Choose the lowest obstacle (closest to ground = highest Y value) const obstacles = [platformAbove, wallAbove, stairAbove].filter(obs => obs !== null); if (obstacles.length > 0) { const obstacleAbove = obstacles.reduce((lowest, current) => current.y > lowest.y ? current : lowest ); catapultY = obstacleAbove.y - 80; // 80 is catapult height console.log(`🏹 Catapult moved to obstacle at y=${catapultY.toFixed(0)}`); } catapults.push({ x: catapultX, y: catapultY, width: 60, height: 80, color: isOnager ? '#654321' : '#8B4513', lastShot: 0, shootCooldown: isOnager ? 6000 + Math.random() * 2000 : 4000 + Math.random() * 2000, type: isOnager ? 'onager' : 'catapult', isOnager: isOnager, armAngle: 0 // For rendering }); console.log(`${isOnager ? '🏛️' : '🏹'} ${isOnager ? 'Onager' : 'Catapult'} placed at x=${catapultX.toFixed(0)}, y=${catapultY.toFixed(0)}`); } return catapults; } /** * Find platform above a position */ static _findPlatformAbove(x, groundY, platforms) { let bestPlatform = null; let lowestY = 0; platforms.forEach(platform => { const catapultLeft = x; const catapultRight = x + 60; const platformLeft = platform.x; const platformRight = platform.x + platform.width; const hasHorizontalOverlap = catapultLeft < platformRight && catapultRight > platformLeft; if (hasHorizontalOverlap && platform.y < groundY && platform.y > lowestY) { bestPlatform = platform; lowestY = platform.y; } }); return bestPlatform; } /** * Find wall above a position */ static _findWallAbove(x, groundY, walls) { let bestWall = null; let lowestY = 0; walls.forEach(wall => { const catapultLeft = x; const catapultRight = x + 60; const wallLeft = wall.x; const wallRight = wall.x + wall.width; const hasHorizontalOverlap = catapultLeft < wallRight && catapultRight > wallLeft; if (hasHorizontalOverlap && wall.y < groundY && wall.y > lowestY) { bestWall = wall; lowestY = wall.y; } }); return bestWall; } /** * Find stair above a position */ static _findStairAbove(x, groundY, stairs) { let bestStair = null; let lowestY = 0; stairs.forEach(stair => { const catapultLeft = x; const catapultRight = x + 60; const stairLeft = stair.x; const stairRight = stair.x + stair.width; const hasHorizontalOverlap = catapultLeft < stairRight && catapultRight > stairLeft; if (hasHorizontalOverlap && stair.y < groundY && stair.y > lowestY) { bestStair = stair; lowestY = stair.y; } }); return bestStair; } /** * Update all catapults * @param {Array} catapults - Array of catapults * @param {Object} mario - Mario object * @param {Array} boulders - Boulders array * @param {Array} stones - Stones array (for onagers) * @param {Function} playSound - Sound callback */ static update(catapults, mario, boulders, stones, playSound, canvasHeight) { const currentTime = Date.now(); catapults.forEach(catapult => { // Arm animation catapult.armAngle = Math.sin(Date.now() / 200) * 0.2; // Check if it's time to shoot if (currentTime - catapult.lastShot > catapult.shootCooldown) { // ONAGER: Check minimum range - don't fire if Mario is too close if (catapult.isOnager) { const distanceToMario = Math.abs(catapult.x - mario.x); const minimumRange = 300; if (distanceToMario < minimumRange) { console.log(`🏛️ Onager held fire - Mario too close! Distance: ${distanceToMario.toFixed(0)}px`); return; } } // Target Mario's position with imperfect aim const aimOffset = 100 + Math.random() * 150; // 100-250 pixel spread const aimDirection = Math.random() < 0.5 ? -1 : 1; const targetX = mario.x + (aimOffset * aimDirection); const targetY = canvasHeight - 50; // Ground level if (catapult.isOnager) { // ONAGER: Fire 8 small stones in spread pattern with parabolic trajectory for (let stone = 0; stone < 8; stone++) { const randomSpreadX = (Math.random() - 0.5) * 400; // ±200px spread const randomSpreadY = (Math.random() - 0.5) * 100; // ±50px spread const stoneTargetX = targetX + randomSpreadX; const stoneTargetY = targetY + randomSpreadY; // Calculate parabolic trajectory const deltaX = stoneTargetX - catapult.x; const deltaY = stoneTargetY - catapult.y; const time = 5 + Math.random() * 2; // 5-7 seconds flight time const velocityX = deltaX / (time * 60); const velocityY = (deltaY - 0.5 * 0.015 * time * time * 60 * 60) / (time * 60); stones.push({ x: catapult.x + 30 + (Math.random() - 0.5) * 20, y: catapult.y - 10 + (Math.random() - 0.5) * 10, width: 8, height: 8, velocityX: velocityX, velocityY: velocityY, color: '#A0522D', type: 'stone', sourceCatapultX: catapult.x, sourceCatapultY: catapult.y }); } catapult.lastShot = currentTime; if (playSound) playSound('enemy_defeat'); console.log(`🏛️ Onager fired 8 stones in spread pattern!`); } else { // CATAPULT: Fire single boulder with parabolic trajectory const deltaX = targetX - catapult.x; const deltaY = targetY - catapult.y; const time = 7.5; // 7.5 seconds flight time const velocityX = deltaX / (time * 60); const velocityY = (deltaY - 0.5 * 0.01 * time * time * 60 * 60) / (time * 60); boulders.push({ x: catapult.x + 30, y: catapult.y - 10, width: 25, height: 25, velocityX: velocityX, velocityY: velocityY, color: '#696969', type: 'boulder', hasLanded: false, sourceCatapultX: catapult.x, sourceCatapultY: catapult.y, health: 2, maxHealth: 2 }); catapult.lastShot = currentTime; if (playSound) playSound('jump'); console.log(`🏹 Catapult fired boulder towards x=${targetX.toFixed(0)}`); } } }); } /** * Update boulders * @param {Array} boulders - Array of boulders * @param {Object} mario - Mario object * @param {Array} platforms - Platforms for collision * @param {Array} walls - Walls for collision * @param {Function} onImpact - Callback when boulder hits something * @returns {Array} - Updated boulders array */ static updateBoulders(boulders, mario, platforms, walls, onImpact) { if (!boulders || !Array.isArray(boulders)) return; const GRAVITY = 0.01; // Ultra-light gravity for parabolic arc const canvasHeight = 690; // Default canvas height // Iterate backwards to safely remove boulders for (let index = boulders.length - 1; index >= 0; index--) { const boulder = boulders[index]; if (boulder.hasLanded) continue; // Don't update landed boulders // Apply physics boulder.velocityY += GRAVITY; boulder.x += boulder.velocityX; boulder.y += boulder.velocityY; // Check ground collision const groundLevel = canvasHeight - 50; if (boulder.y + boulder.height >= groundLevel) { if (onImpact) { onImpact(boulder, index, boulder.x, groundLevel - boulder.height, null, -1, 'ground'); } continue; } let hasHit = false; // Check collision with platforms platforms.forEach((platform, platformIndex) => { if (!hasHit && this._isCollidingRectRect(boulder, platform)) { if (onImpact) { onImpact(boulder, index, boulder.x, platform.y - boulder.height, platform, platformIndex, 'platform'); } hasHit = true; } }); // Check collision with walls (boulder destroys wall) if (!hasHit) { walls.forEach((wall, wallIndex) => { if (!hasHit && this._isCollidingRectRect(boulder, wall)) { if (onImpact) { onImpact(boulder, index, boulder.x, boulder.y, wall, wallIndex, 'wall'); } hasHit = true; } }); } // Check collision with Mario if (!hasHit && this._isCollidingRectRect(boulder, mario)) { if (onImpact) { onImpact(boulder, index, boulder.x, boulder.y, mario, -1, 'mario'); } hasHit = true; continue; } // Remove boulders that go off-screen if (boulder.x < -100 || boulder.x > 4000 || boulder.y > canvasHeight + 100) { boulders.splice(index, 1); } } } /** * Update stones (stone rain) * @param {Array} stones - Array of stones * @param {Object} mario - Mario object * @param {Array} platforms - Platforms for collision * @param {Function} onImpact - Callback when stone hits something * @returns {Array} - Updated stones array */ static updateStones(stones, mario, platforms, onImpact) { if (!stones || !Array.isArray(stones)) return []; const GRAVITY = 0.015; // Lighter gravity for parabolic arc const canvasHeight = 690; const updatedStones = []; stones.forEach((stone, index) => { // Apply physics stone.velocityY += GRAVITY; stone.x += stone.velocityX; stone.y += stone.velocityY; // Check ground collision const groundLevel = canvasHeight - 50; if (stone.y + stone.height >= groundLevel) { if (onImpact) { onImpact(stone, index, 'ground'); } return; // Don't keep this stone } let hasHit = false; // Check collision with platforms platforms.forEach(platform => { if (!hasHit && this._isCollidingRectRect(stone, platform)) { if (onImpact) { onImpact(stone, index, 'platform'); } hasHit = true; } }); // Check collision with Mario if (!hasHit && this._isCollidingRectRect(stone, mario)) { if (onImpact) { onImpact(stone, index, 'mario'); } hasHit = true; return; // Don't keep this stone } // Keep stone if not hit and still on screen if (!hasHit && stone.x >= -100 && stone.x <= 4000 && stone.y <= canvasHeight + 100) { updatedStones.push(stone); } }); return updatedStones; } /** * Circle-Rectangle collision detection */ static _isCollidingCircleRect(circle, rect) { const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width)); const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height)); const distanceX = circle.x - closestX; const distanceY = circle.y - closestY; const distanceSquared = distanceX * distanceX + distanceY * distanceY; return distanceSquared < (circle.radius * circle.radius); } /** * Rectangle-Rectangle collision detection */ static _isCollidingRectRect(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; } } export default Catapult;