Major improvements: - Add TTSHelper utility for text-to-speech functionality - Enhance content compatibility scoring across all games - Improve sentence extraction from multiple content sources - Update all game modules to support diverse content formats - Refine MarioEducational physics and rendering - Polish UI styles and remove unused CSS Games updated: AdventureReader, FillTheBlank, FlashcardLearning, GrammarDiscovery, MarioEducational, QuizGame, RiverRun, WhackAMole, WhackAMoleHard, WizardSpellCaster, WordDiscovery, WordStorm 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
376 lines
14 KiB
JavaScript
376 lines
14 KiB
JavaScript
/**
|
|
* 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;
|