Class_generator/src/gameHelpers/MarioEducational/enemies/Catapult.js
StillHammer ab84bbbc71 Reduce game sizes and fix Mario level display
- Reduce RiverRun game height from 100vh to 75vh for better screen fit
- Reduce AdventureReader game height from 100vh to 75vh
- Fix Mario level number display (was showing currentLevel + 1 twice)
  - Updated HUD level display in Renderer.js
  - Updated finish line flag level display in Renderer.js
- Add portable setup files and documentation
- Add new game modules: SentenceInvaders, ThematicQuestions
- Add new content: wte2 book, sbs chapters 2-3, wte2-2 chapter
- Update various game modules for improved compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 16:22:33 +08:00

404 lines
15 KiB
JavaScript

/**
* 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;