- 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>
404 lines
15 KiB
JavaScript
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;
|