Add comprehensive Chinese reading course (乐读) with 4 chapters of vocabulary, texts, and exercises. Include architecture documentation for module development and progress tracking system. Content: - LEDU book metadata with 12 chapter outline - Chapter 1: Food culture (民以食为天) - 45+ vocabulary, etiquette - Chapter 2: Shopping (货比三家) - comparative shopping vocabulary - Chapter 3: Sports & fitness (生命在于运动) - exercise habits - Chapter 4: Additional vocabulary and grammar Documentation: - Architecture principles and patterns - Module creation guide (Game, DRS, Progress) - Interface system (C++ style contracts) - Progress tracking and prerequisites Game Enhancements: - MarioEducational helper classes (Physics, Renderer, Sound, Enemies) - VocabularyModule TTS improvements - Updated CLAUDE.md with project status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
348 lines
12 KiB
JavaScript
348 lines
12 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) {
|
|
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) {
|
|
const distanceToMario = Math.abs(catapult.x - mario.x);
|
|
|
|
// Catapult shoots boulders (single target)
|
|
if (!catapult.isOnager && distanceToMario < 600) {
|
|
// Calculate trajectory to Mario
|
|
const dx = mario.x - catapult.x;
|
|
const dy = mario.y - catapult.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
const speed = 8;
|
|
const velocityX = (dx / distance) * speed;
|
|
const velocityY = (dy / distance) * speed;
|
|
|
|
boulders.push({
|
|
x: catapult.x + catapult.width / 2,
|
|
y: catapult.y,
|
|
velocityX: velocityX,
|
|
velocityY: velocityY,
|
|
radius: 20,
|
|
type: 'boulder',
|
|
launched: true
|
|
});
|
|
|
|
catapult.lastShot = currentTime;
|
|
if (playSound) playSound('jump'); // Boulder launch sound
|
|
console.log(`🪨 Catapult launched boulder towards Mario!`);
|
|
}
|
|
// Onager shoots stone rain (area attack)
|
|
else if (catapult.isOnager && distanceToMario < 800) {
|
|
// Create stone rain above Mario's area
|
|
const stoneCount = 8 + Math.floor(Math.random() * 5); // 8-12 stones
|
|
|
|
for (let i = 0; i < stoneCount; i++) {
|
|
const offsetX = (Math.random() - 0.5) * 400; // Spread 400px around Mario
|
|
|
|
stones.push({
|
|
x: mario.x + offsetX,
|
|
y: -50 - Math.random() * 100, // Start above screen
|
|
velocityX: (Math.random() - 0.5) * 2,
|
|
velocityY: 2 + Math.random() * 3,
|
|
width: 15 + Math.random() * 10,
|
|
height: 15 + Math.random() * 10,
|
|
type: 'stone',
|
|
rotation: Math.random() * Math.PI * 2
|
|
});
|
|
}
|
|
|
|
catapult.lastShot = currentTime;
|
|
if (playSound) playSound('enemy_defeat'); // Different sound for stone rain
|
|
console.log(`☄️ Onager launched stone rain (${stoneCount} stones)!`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const GRAVITY = 0.3;
|
|
const updatedBoulders = [];
|
|
|
|
boulders.forEach((boulder, index) => {
|
|
// Apply physics
|
|
boulder.velocityY += GRAVITY;
|
|
boulder.x += boulder.velocityX;
|
|
boulder.y += boulder.velocityY;
|
|
|
|
// Check collision with platforms
|
|
let hitPlatform = false;
|
|
platforms.forEach((platform, platformIndex) => {
|
|
if (this._isCollidingCircleRect(boulder, platform)) {
|
|
hitPlatform = true;
|
|
if (onImpact) {
|
|
onImpact(boulder, index, boulder.x, boulder.y, platform, platformIndex, 'platform');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check collision with walls
|
|
let hitWall = false;
|
|
walls.forEach((wall, wallIndex) => {
|
|
if (this._isCollidingCircleRect(boulder, wall)) {
|
|
hitWall = true;
|
|
if (onImpact) {
|
|
onImpact(boulder, index, boulder.x, boulder.y, wall, wallIndex, 'wall');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check collision with Mario
|
|
if (this._isCollidingCircleRect(boulder, mario)) {
|
|
if (onImpact) {
|
|
onImpact(boulder, index, boulder.x, boulder.y, mario, -1, 'mario');
|
|
}
|
|
return; // Remove boulder
|
|
}
|
|
|
|
// Remove if out of bounds or hit something
|
|
if (!hitPlatform && !hitWall && boulder.y < 1000) {
|
|
updatedBoulders.push(boulder);
|
|
}
|
|
});
|
|
|
|
return updatedBoulders;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const GRAVITY = 0.5;
|
|
const updatedStones = [];
|
|
|
|
stones.forEach((stone, index) => {
|
|
// Apply physics
|
|
stone.velocityY += GRAVITY;
|
|
stone.x += stone.velocityX;
|
|
stone.y += stone.velocityY;
|
|
stone.rotation += 0.1;
|
|
|
|
// Check collision with platforms
|
|
let hitPlatform = false;
|
|
platforms.forEach(platform => {
|
|
if (this._isCollidingRectRect(stone, platform)) {
|
|
hitPlatform = true;
|
|
if (onImpact) {
|
|
onImpact(stone, index, 'platform');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check collision with Mario
|
|
if (this._isCollidingRectRect(stone, mario)) {
|
|
if (onImpact) {
|
|
onImpact(stone, index, 'mario');
|
|
}
|
|
return; // Remove stone
|
|
}
|
|
|
|
// Keep stone if not hit and still on screen
|
|
if (!hitPlatform && stone.y < 1000) {
|
|
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;
|