- 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>
2540 lines
96 KiB
JavaScript
2540 lines
96 KiB
JavaScript
import Module from '../core/Module.js';
|
|
import { sentenceGenerator } from '../gameHelpers/MarioEducational/SentenceGenerator.js';
|
|
import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js';
|
|
import { renderer } from '../gameHelpers/MarioEducational/Renderer.js';
|
|
import PhysicsEngine from '../gameHelpers/MarioEducational/PhysicsEngine.js';
|
|
import PiranhaPlant from '../gameHelpers/MarioEducational/enemies/PiranhaPlant.js';
|
|
import Catapult from '../gameHelpers/MarioEducational/enemies/Catapult.js';
|
|
import FlyingEye from '../gameHelpers/MarioEducational/enemies/FlyingEye.js';
|
|
import Boss from '../gameHelpers/MarioEducational/enemies/Boss.js';
|
|
import Projectile from '../gameHelpers/MarioEducational/enemies/Projectile.js';
|
|
|
|
/**
|
|
* MarioEducational - 2D Mario-style educational game with question blocks
|
|
* Classic side-scrolling platformer that integrates vocabulary learning through interactive question blocks
|
|
*/
|
|
class MarioEducational extends Module {
|
|
constructor(name, dependencies, config = {}) {
|
|
super(name, ['eventBus']);
|
|
|
|
// Validate dependencies
|
|
if (!dependencies.eventBus || !dependencies.content) {
|
|
throw new Error('MarioEducational requires eventBus and content dependencies');
|
|
}
|
|
|
|
this._eventBus = dependencies.eventBus;
|
|
this._content = dependencies.content;
|
|
this._config = {
|
|
container: null,
|
|
canvasWidth: 1200,
|
|
canvasHeight: 690, // +15% viewport height (600 * 1.15 = 690)
|
|
gravity: 0.8,
|
|
jumpForce: -16,
|
|
moveSpeed: 5,
|
|
maxLevels: 7,
|
|
...config
|
|
};
|
|
|
|
// Game state
|
|
this._canvas = null;
|
|
this._ctx = null;
|
|
this._gameLoop = null;
|
|
this._keys = {};
|
|
this._isGameOver = false;
|
|
this._isPaused = false;
|
|
this._score = 0;
|
|
this._questionsAnswered = 0;
|
|
this._currentLevel = 1;
|
|
this._gameStartTime = null;
|
|
|
|
// Mario character
|
|
this._mario = {
|
|
x: 100,
|
|
y: 400,
|
|
width: 32,
|
|
height: 32,
|
|
velocityX: 0,
|
|
velocityY: 0,
|
|
onGround: false,
|
|
facing: 'right',
|
|
color: '#FF6B6B' // Red Mario
|
|
};
|
|
|
|
// Game world
|
|
this._camera = { x: 0, y: 0 };
|
|
this._platforms = [];
|
|
this._questionBlocks = [];
|
|
this._enemies = [];
|
|
this._collectibles = [];
|
|
this._particles = [];
|
|
this._walls = [];
|
|
this._castleStructure = null;
|
|
this._finishLine = null;
|
|
|
|
// Advanced level elements
|
|
this._piranhaPlants = [];
|
|
this._projectiles = [];
|
|
|
|
// Level 4+ catapult system
|
|
this._catapults = [];
|
|
this._boulders = [];
|
|
this._stones = []; // Small stones from onager
|
|
|
|
// Level 5+ flying eyes system
|
|
this._flyingEyes = [];
|
|
|
|
// Level 6 boss system
|
|
this._boss = null;
|
|
this._bossTurrets = [];
|
|
this._bossMinions = [];
|
|
this._powerUps = [];
|
|
this._isBoostMode = false;
|
|
this._boostTimer = 0;
|
|
this._bossCollisionCooldown = 0; // Prevent bounce loop
|
|
|
|
// Level generation
|
|
this._levelData = [];
|
|
this._currentLevelIndex = 0;
|
|
this._levelWidth = 3000;
|
|
this._sentences = [];
|
|
this._usedSentences = [];
|
|
|
|
// Question system
|
|
this._questionDialog = null;
|
|
this._isQuestionActive = false;
|
|
|
|
// Celebration mode - blocks enemies but allows particles
|
|
this._isCelebrating = false;
|
|
|
|
// Level completion flag to prevent multiple triggers
|
|
this._levelCompleted = false;
|
|
|
|
// Input handlers (need to be declared before seal)
|
|
this._handleKeyDown = null;
|
|
this._handleKeyUp = null;
|
|
this._handleMouseDown = null;
|
|
this._handleMouseUp = null;
|
|
this._handleMouseMove = null;
|
|
|
|
// Mouse control state
|
|
this._mousePressed = false;
|
|
this._mouseTarget = { x: null, y: null };
|
|
this._mouseWorldPos = { x: null, y: null };
|
|
|
|
// UI elements (need to be declared before seal)
|
|
this._uiOverlay = null;
|
|
|
|
// Debug mode (toggle with 'D' key)
|
|
this._debugMode = false;
|
|
|
|
Object.seal(this);
|
|
}
|
|
|
|
/**
|
|
* Get game metadata
|
|
* @returns {Object} Game metadata
|
|
*/
|
|
static getMetadata() {
|
|
return {
|
|
name: 'Mario Educational Adventure',
|
|
description: '2D Mario-style platformer with educational question blocks and vocabulary challenges',
|
|
difficulty: 'intermediate',
|
|
category: 'platformer',
|
|
estimatedTime: 15, // minutes
|
|
skills: ['reading', 'vocabulary', 'comprehension', 'reflexes', 'problem-solving']
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate compatibility score with content
|
|
* @param {Object} content - Content to check compatibility with
|
|
* @returns {Object} Compatibility score and details
|
|
*/
|
|
static getCompatibilityScore(content) {
|
|
const sentences = content?.sentences || [];
|
|
const phrases = content?.phrases || {};
|
|
const dialogs = content?.dialogs || {};
|
|
const texts = content?.texts || [];
|
|
const story = content?.story || '';
|
|
|
|
let totalSentences = sentences.length;
|
|
|
|
// Count phrases (SBS format)
|
|
totalSentences += Object.keys(phrases).length;
|
|
|
|
// Count dialog lines (SBS format)
|
|
Object.values(dialogs).forEach(dialog => {
|
|
if (dialog.lines && Array.isArray(dialog.lines)) {
|
|
totalSentences += dialog.lines.length;
|
|
}
|
|
});
|
|
|
|
// Count sentences from texts
|
|
if (Array.isArray(texts)) {
|
|
texts.forEach(text => {
|
|
if (typeof text === 'string') {
|
|
totalSentences += text.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
|
|
} else if (text.content) {
|
|
totalSentences += text.content.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Count sentences from story
|
|
if (story && typeof story === 'string') {
|
|
totalSentences += story.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
|
|
}
|
|
|
|
// Mario Educational requires at least 5 sentences
|
|
if (totalSentences < 5) {
|
|
return {
|
|
score: 0,
|
|
reason: `Insufficient sentences (${totalSentences}/5 minimum)`,
|
|
requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
|
|
minSentences: 5,
|
|
details: 'Mario Educational needs at least 5 complete sentences from any source'
|
|
};
|
|
}
|
|
|
|
// Good score for 10+ sentences, perfect at 30+
|
|
const score = Math.min(totalSentences / 30, 1);
|
|
|
|
return {
|
|
score,
|
|
reason: `${totalSentences} sentences available`,
|
|
requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
|
|
minSentences: 5,
|
|
optimalSentences: 30,
|
|
details: `Can create ${Math.min(totalSentences, 50)} question blocks from sentence sources`
|
|
};
|
|
}
|
|
|
|
async init() {
|
|
this._validateNotDestroyed();
|
|
|
|
try {
|
|
// Validate container
|
|
if (!this._config.container) {
|
|
throw new Error('Game container is required');
|
|
}
|
|
|
|
// Extract sentences from content
|
|
this._extractSentences();
|
|
if (this._sentences.length < 5) {
|
|
throw new Error('Insufficient content for Mario Educational game');
|
|
}
|
|
|
|
// Setup canvas
|
|
this._setupCanvas();
|
|
|
|
// Setup input handlers
|
|
this._setupInputHandlers();
|
|
|
|
// Initialize sound system
|
|
soundSystem.initialize();
|
|
|
|
// Generate all levels
|
|
this._generateAllLevels();
|
|
|
|
// Start first level
|
|
this._startLevel(0); // Start at level 1
|
|
|
|
// Setup game UI
|
|
this._setupGameUI();
|
|
|
|
// Start game loop
|
|
this._startGameLoop();
|
|
|
|
this._gameStartTime = Date.now();
|
|
console.log('🎮 Mario Educational game initialized successfully');
|
|
|
|
this._setInitialized();
|
|
|
|
} catch (error) {
|
|
console.error('❌ Error initializing Mario Educational game:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async destroy() {
|
|
this._validateNotDestroyed();
|
|
|
|
// Stop game loop
|
|
if (this._gameLoop) {
|
|
cancelAnimationFrame(this._gameLoop);
|
|
this._gameLoop = null;
|
|
}
|
|
|
|
// Remove event listeners
|
|
document.removeEventListener('keydown', this._handleKeyDown);
|
|
document.removeEventListener('keyup', this._handleKeyUp);
|
|
if (this._canvas) {
|
|
this._canvas.removeEventListener('mousedown', this._handleMouseDown);
|
|
this._canvas.removeEventListener('mouseup', this._handleMouseUp);
|
|
this._canvas.removeEventListener('mousemove', this._handleMouseMove);
|
|
this._canvas.removeEventListener('mouseleave', this._handleMouseUp);
|
|
}
|
|
|
|
// Clear canvas
|
|
if (this._canvas && this._canvas.parentNode) {
|
|
this._canvas.parentNode.removeChild(this._canvas);
|
|
}
|
|
|
|
// Clear game state
|
|
this._platforms = [];
|
|
this._questionBlocks = [];
|
|
this._enemies = [];
|
|
this._collectibles = [];
|
|
this._particles = [];
|
|
|
|
console.log('🎮 Mario Educational game destroyed');
|
|
this._setDestroyed();
|
|
}
|
|
|
|
// Private methods
|
|
_extractSentences() {
|
|
const sentences = this._content.sentences || [];
|
|
const phrases = this._content.phrases || {};
|
|
const dialogs = this._content.dialogs || {};
|
|
const texts = this._content.texts || [];
|
|
const story = this._content.story || '';
|
|
|
|
// Combine all sentence sources
|
|
this._sentences = [];
|
|
|
|
// Add sentences from 'sentences' array
|
|
sentences.forEach(sentence => {
|
|
// Format 1: Modern format with english/user_language
|
|
if (sentence.english && sentence.user_language) {
|
|
this._sentences.push({
|
|
type: 'sentence',
|
|
english: sentence.english,
|
|
translation: sentence.user_language,
|
|
context: sentence.context || ''
|
|
});
|
|
}
|
|
// Format 2: Current format with text field
|
|
else if (sentence.text) {
|
|
this._sentences.push({
|
|
type: 'sentence',
|
|
english: sentence.text,
|
|
translation: sentence.translation || 'Read this sentence carefully',
|
|
context: `Difficulty: ${sentence.difficulty || 'intermediate'}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add phrases from 'phrases' object (SBS format)
|
|
Object.entries(phrases).forEach(([english, data]) => {
|
|
if (data.user_language) {
|
|
this._sentences.push({
|
|
type: 'phrase',
|
|
english: english,
|
|
translation: data.user_language,
|
|
context: data.context || 'phrase'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add dialog lines from 'dialogs' object (SBS format)
|
|
Object.values(dialogs).forEach(dialog => {
|
|
if (dialog.lines && Array.isArray(dialog.lines)) {
|
|
dialog.lines.forEach(line => {
|
|
if (line.text && line.user_language) {
|
|
this._sentences.push({
|
|
type: 'dialog',
|
|
english: line.text,
|
|
translation: line.user_language,
|
|
context: dialog.title || 'dialog'
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Extract sentences from story text
|
|
if (story && typeof story === 'string') {
|
|
const storySentences = sentenceGenerator.splitTextIntoSentences(story);
|
|
storySentences.forEach(sentence => {
|
|
this._sentences.push({
|
|
type: 'story',
|
|
english: sentence,
|
|
translation: 'Story sentence - read carefully',
|
|
context: 'Story'
|
|
});
|
|
});
|
|
}
|
|
|
|
// Extract sentences from texts array
|
|
if (Array.isArray(texts)) {
|
|
texts.forEach((text, index) => {
|
|
if (typeof text === 'string') {
|
|
const textSentences = sentenceGenerator.splitTextIntoSentences(text);
|
|
textSentences.forEach(sentence => {
|
|
this._sentences.push({
|
|
type: 'text',
|
|
english: sentence,
|
|
translation: `Text passage ${index + 1} - practice reading`,
|
|
context: `Text ${index + 1}`
|
|
});
|
|
});
|
|
} else if (text.content) {
|
|
const textSentences = sentenceGenerator.splitTextIntoSentences(text.content);
|
|
textSentences.forEach(sentence => {
|
|
this._sentences.push({
|
|
type: 'text',
|
|
english: sentence,
|
|
translation: text.translation || `Read this text sentence`,
|
|
context: text.title || `Text ${index + 1}`
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Shuffle sentences for variety
|
|
this._sentences = this._shuffleArray(this._sentences);
|
|
|
|
console.log(`📝 Extracted ${this._sentences.length} sentences for questions`);
|
|
}
|
|
|
|
|
|
_setupCanvas() {
|
|
this._canvas = document.createElement('canvas');
|
|
this._canvas.width = this._config.canvasWidth;
|
|
this._canvas.height = this._config.canvasHeight;
|
|
this._canvas.style.border = '2px solid #333';
|
|
this._canvas.style.borderRadius = '8px';
|
|
this._canvas.style.background = 'linear-gradient(to bottom, #87CEEB, #98FB98)';
|
|
|
|
this._ctx = this._canvas.getContext('2d');
|
|
this._ctx.imageSmoothingEnabled = false; // Pixel art style
|
|
|
|
this._config.container.appendChild(this._canvas);
|
|
}
|
|
|
|
_setupInputHandlers() {
|
|
this._handleKeyDown = (e) => {
|
|
this._keys[e.code] = true;
|
|
|
|
// Toggle debug mode with 'D' key
|
|
if (e.code === 'KeyD') {
|
|
this._debugMode = !this._debugMode;
|
|
console.log(`🐛 Debug mode: ${this._debugMode ? 'ON' : 'OFF'}`);
|
|
}
|
|
|
|
// Prevent default for game controls
|
|
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'].includes(e.code)) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
this._handleKeyUp = (e) => {
|
|
this._keys[e.code] = false;
|
|
};
|
|
|
|
// Mouse handlers for click and drag controls
|
|
this._handleMouseDown = (e) => {
|
|
if (this._isGameOver || this._isPaused || this._isQuestionActive) return;
|
|
|
|
const rect = this._canvas.getBoundingClientRect();
|
|
const canvasX = e.clientX - rect.left;
|
|
const canvasY = e.clientY - rect.top;
|
|
|
|
// Convert to world coordinates (account for camera)
|
|
this._mouseWorldPos.x = canvasX + this._camera.x;
|
|
this._mouseWorldPos.y = canvasY;
|
|
this._mousePressed = true;
|
|
|
|
console.log(`🖱️ Mouse down at world: (${this._mouseWorldPos.x.toFixed(0)}, ${this._mouseWorldPos.y.toFixed(0)})`);
|
|
};
|
|
|
|
this._handleMouseUp = (e) => {
|
|
this._mousePressed = false;
|
|
this._mouseTarget.x = null;
|
|
this._mouseTarget.y = null;
|
|
console.log(`🖱️ Mouse released`);
|
|
};
|
|
|
|
this._handleMouseMove = (e) => {
|
|
if (!this._mousePressed) return;
|
|
if (this._isGameOver || this._isPaused || this._isQuestionActive) return;
|
|
|
|
const rect = this._canvas.getBoundingClientRect();
|
|
const canvasX = e.clientX - rect.left;
|
|
const canvasY = e.clientY - rect.top;
|
|
|
|
// Convert to world coordinates
|
|
this._mouseWorldPos.x = canvasX + this._camera.x;
|
|
this._mouseWorldPos.y = canvasY;
|
|
};
|
|
|
|
document.addEventListener('keydown', this._handleKeyDown);
|
|
document.addEventListener('keyup', this._handleKeyUp);
|
|
|
|
if (this._canvas) {
|
|
this._canvas.addEventListener('mousedown', this._handleMouseDown);
|
|
this._canvas.addEventListener('mouseup', this._handleMouseUp);
|
|
this._canvas.addEventListener('mousemove', this._handleMouseMove);
|
|
// Also handle mouse leaving canvas
|
|
this._canvas.addEventListener('mouseleave', this._handleMouseUp);
|
|
console.log('🖱️ Mouse event listeners attached to canvas');
|
|
} else {
|
|
console.error('❌ Canvas not found when setting up mouse handlers!');
|
|
}
|
|
}
|
|
|
|
|
|
_generateAllLevels() {
|
|
this._levelData = [];
|
|
|
|
for (let i = 0; i < this._config.maxLevels; i++) {
|
|
const isLastLevel = i === this._config.maxLevels - 1;
|
|
const level = this._generateLevel(i, isLastLevel);
|
|
this._levelData.push(level);
|
|
}
|
|
|
|
console.log(`🏗️ Generated ${this._levelData.length} levels`);
|
|
}
|
|
|
|
_generateLevel(levelIndex, isCastleLevel = false) {
|
|
const level = {
|
|
index: levelIndex,
|
|
isCastleLevel,
|
|
platforms: [],
|
|
questionBlocks: [],
|
|
enemies: [],
|
|
collectibles: [],
|
|
walls: [],
|
|
startX: 100,
|
|
startY: 400,
|
|
endX: this._levelWidth - 200,
|
|
width: this._levelWidth
|
|
};
|
|
|
|
if (isCastleLevel) {
|
|
return this._generateCastleLevel(level);
|
|
} else {
|
|
return this._generateRegularLevel(level);
|
|
}
|
|
}
|
|
|
|
_generateRegularLevel(level) {
|
|
const { index } = level;
|
|
const difficulty = Math.min(index + 1, 5); // Difficulty scales 1-5
|
|
|
|
// Generate ground platforms
|
|
for (let x = 0; x < this._levelWidth; x += 200) {
|
|
level.platforms.push({
|
|
x: x,
|
|
y: this._config.canvasHeight - 50,
|
|
width: 200,
|
|
height: 50,
|
|
type: 'ground',
|
|
color: '#8B4513'
|
|
});
|
|
}
|
|
|
|
// Generate floating platforms with intelligent placement
|
|
this._generateIntelligentPlatforms(level, difficulty);
|
|
|
|
// Level 3+ gets advanced features BEFORE enemies (so enemy spawning can avoid them)
|
|
if (index >= 2 && index <= 4) {
|
|
this._generateHoles(level, difficulty);
|
|
this._generateStairs(level, difficulty);
|
|
}
|
|
|
|
// Level 6 boss level: only stairs (no holes)
|
|
if (index === 5) {
|
|
this._generateBossStairs(level, difficulty);
|
|
}
|
|
|
|
// Generate question blocks on reachable platforms
|
|
const questionCount = 3 + difficulty;
|
|
const availablePlatforms = level.platforms.filter(p => p.y < this._config.canvasHeight - 100);
|
|
|
|
for (let i = 0; i < questionCount; i++) {
|
|
const maxAttempts = 5;
|
|
let attemptCount = 0;
|
|
let blockPlaced = false;
|
|
|
|
while (attemptCount < maxAttempts && !blockPlaced) {
|
|
attemptCount++;
|
|
|
|
const platform = availablePlatforms[Math.floor(Math.random() * availablePlatforms.length)];
|
|
const sentence = this._getRandomSentence();
|
|
|
|
if (sentence && platform) {
|
|
const blockX = platform.x + platform.width / 2 - 16;
|
|
const blockY = platform.y - 32;
|
|
const blockWidth = 32;
|
|
const blockHeight = 32;
|
|
|
|
// Check minimum distance from other question blocks
|
|
const hasMinDistance = this._checkQuestionBlockDistance(
|
|
level.questionBlocks, blockX, blockY, blockWidth, blockHeight, 60
|
|
);
|
|
|
|
if (hasMinDistance) {
|
|
level.questionBlocks.push({
|
|
x: blockX,
|
|
y: blockY,
|
|
width: blockWidth,
|
|
height: blockHeight,
|
|
sentence: sentence,
|
|
hit: false,
|
|
color: '#FFD700',
|
|
symbol: '?'
|
|
});
|
|
|
|
console.log(`💰 Question block ${i}: x=${blockX.toFixed(0)}, y=${blockY.toFixed(0)}, attempts=${attemptCount}`);
|
|
blockPlaced = true;
|
|
} else {
|
|
console.log(`❌ Question block ${i} attempt ${attemptCount} failed: too close to other blocks`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!blockPlaced) {
|
|
console.log(`⚠️ Failed to place question block ${i} after ${maxAttempts} attempts`);
|
|
}
|
|
}
|
|
|
|
// Generate simple enemies (avoid walls)
|
|
let helmetEnemyPlaced = false;
|
|
console.log(`🎮 Level ${index + 1}: Generating ${difficulty} enemies (helmetEnemy condition: index=${index} >= 1)`);
|
|
|
|
for (let i = 0; i < difficulty; i++) {
|
|
let enemyPlaced = false;
|
|
let attempts = 0;
|
|
const maxAttempts = 20;
|
|
|
|
while (!enemyPlaced && attempts < maxAttempts) {
|
|
const platform = level.platforms[Math.floor(Math.random() * level.platforms.length)];
|
|
const enemyX = platform.x + Math.random() * (platform.width - 20);
|
|
const enemyY = platform.y - 20;
|
|
|
|
// Check if enemy would spawn inside a wall
|
|
const wouldOverlapWall = level.walls.some(wall => {
|
|
return enemyX < wall.x + wall.width &&
|
|
enemyX + 20 > wall.x &&
|
|
enemyY < wall.y + wall.height &&
|
|
enemyY + 20 > wall.y;
|
|
});
|
|
|
|
// Check if enemy would spawn over a hole (more thorough check)
|
|
const wouldOverlapHole = level.holes && level.holes.some(hole => {
|
|
return enemyX + 10 >= hole.x && enemyX + 10 <= hole.x + hole.width; // Check center of enemy
|
|
});
|
|
|
|
// Enemy is spawned on a platform, so it has support by definition
|
|
// No need to check hasSolidSupport - the platform we selected IS the support
|
|
|
|
if (!wouldOverlapWall && !wouldOverlapHole) {
|
|
// Level 2+ gets exactly ONE helmet enemy per level
|
|
const isHelmetEnemy = index >= 1 && !helmetEnemyPlaced;
|
|
|
|
if (isHelmetEnemy) {
|
|
helmetEnemyPlaced = true;
|
|
}
|
|
|
|
level.enemies.push({
|
|
x: enemyX,
|
|
y: enemyY,
|
|
width: 20,
|
|
height: 20,
|
|
velocityX: (Math.random() > 0.5 ? 1 : -1) * (1 + Math.random()),
|
|
color: isHelmetEnemy ? '#4169E1' : '#8B0000', // Blue for helmet, red for normal
|
|
type: isHelmetEnemy ? 'koopa' : 'goomba',
|
|
hasHelmet: isHelmetEnemy
|
|
});
|
|
enemyPlaced = true;
|
|
console.log(`✅ Enemy ${i} placed at x=${enemyX.toFixed(0)}, y=${enemyY.toFixed(0)} on platform - Type: ${isHelmetEnemy ? '🔵 KOOPA' : '🔴 Goomba'}`);
|
|
} else {
|
|
const reason = wouldOverlapWall ? 'wall' : 'hole';
|
|
console.log(`🚫 Enemy ${i} attempt ${attempts} failed: ${reason}`);
|
|
}
|
|
attempts++;
|
|
}
|
|
|
|
if (!enemyPlaced) {
|
|
console.log(`⚠️ Failed to place enemy ${i} after ${maxAttempts} attempts`);
|
|
}
|
|
}
|
|
|
|
// Generate piranha plants AFTER enemies (visual decoration)
|
|
if (index >= 2 && index <= 4) {
|
|
this._generatePiranhaPlants(level, difficulty);
|
|
}
|
|
|
|
// Level 6 boss level: limited piranha plants
|
|
if (index === 5) {
|
|
if (difficulty > 3) {
|
|
this._generatePiranhaPlants(level, Math.min(difficulty - 2, 2));
|
|
}
|
|
}
|
|
|
|
// Level 4-5 gets catapults (NOT level 6 - boss level)
|
|
if (index >= 3 && index <= 4) {
|
|
this._generateCatapults(level, difficulty);
|
|
}
|
|
|
|
// Level 5+ gets flying eyes
|
|
if (index >= 4) {
|
|
this._generateFlyingEyes(level, difficulty);
|
|
}
|
|
|
|
// Level 6 gets colossal boss (DISABLED)
|
|
// if (index === 5) {
|
|
// this._generateColossalBoss(level, difficulty);
|
|
// }
|
|
|
|
return level;
|
|
}
|
|
|
|
_generateIntelligentPlatforms(level, difficulty) {
|
|
// Calculate Mario's jump capabilities
|
|
const jumpForce = Math.abs(this._config.jumpForce);
|
|
const gravity = this._config.gravity;
|
|
const moveSpeed = this._config.moveSpeed;
|
|
|
|
// Physics calculations for jump distances
|
|
const maxJumpHeight = (jumpForce * jumpForce) / (2 * gravity); // Peak height
|
|
const totalAirTime = (2 * jumpForce) / gravity; // Time in air
|
|
const maxHorizontalDistance = moveSpeed * totalAirTime; // Max horizontal distance
|
|
|
|
// Safe margins for reachable platforms
|
|
const safeJumpHeight = maxJumpHeight * 0.85;
|
|
const safeHorizontalDistance = maxHorizontalDistance * 0.8;
|
|
|
|
console.log(`🔧 Jump physics: height=${safeJumpHeight.toFixed(1)}, distance=${safeHorizontalDistance.toFixed(1)}`);
|
|
|
|
const platformCount = 8 + difficulty * 2;
|
|
const groundHeight = this._config.canvasHeight - 50;
|
|
|
|
// Define height zones for better vertical distribution
|
|
const heightZones = {
|
|
low: groundHeight - safeJumpHeight * 0.3, // Just above ground level
|
|
mid: groundHeight - safeJumpHeight * 0.6, // Medium height
|
|
high: groundHeight - safeJumpHeight * 0.9, // High platforms
|
|
veryHigh: groundHeight - safeJumpHeight // Max reachable height
|
|
};
|
|
|
|
// Force vertical progression - alternate between height zones
|
|
for (let i = 0; i < platformCount; i++) {
|
|
const maxAttempts = 5;
|
|
let attemptCount = 0;
|
|
let platformPlaced = false;
|
|
|
|
while (attemptCount < maxAttempts && !platformPlaced) {
|
|
attemptCount++;
|
|
|
|
// Find a suitable previous platform to connect from
|
|
const availablePlatforms = level.platforms.filter(p =>
|
|
p.x > 100 && p.x < this._levelWidth - 400
|
|
);
|
|
|
|
if (availablePlatforms.length === 0) break;
|
|
|
|
const fromPlatform = availablePlatforms[Math.floor(Math.random() * availablePlatforms.length)];
|
|
|
|
// Force better height distribution
|
|
let targetY;
|
|
const progressRatio = i / platformCount;
|
|
|
|
// Bias towards higher platforms as we progress
|
|
if (progressRatio < 0.3) {
|
|
// Early platforms: mix of low and mid
|
|
targetY = Math.random() < 0.7 ? heightZones.low : heightZones.mid;
|
|
} else if (progressRatio < 0.7) {
|
|
// Middle platforms: mix of mid and high
|
|
targetY = Math.random() < 0.6 ? heightZones.mid : heightZones.high;
|
|
} else {
|
|
// Later platforms: mix of high and very high
|
|
targetY = Math.random() < 0.5 ? heightZones.high : heightZones.veryHigh;
|
|
}
|
|
|
|
// Add some randomness around target height
|
|
targetY += (Math.random() - 0.5) * safeJumpHeight * 0.2;
|
|
|
|
// Calculate horizontal position within jump range
|
|
const distanceRange = {
|
|
min: safeHorizontalDistance * 0.4,
|
|
max: safeHorizontalDistance * 0.9
|
|
};
|
|
|
|
const direction = Math.random() > 0.5 ? 1 : -1;
|
|
const distance = distanceRange.min + Math.random() * (distanceRange.max - distanceRange.min);
|
|
const newX = fromPlatform.x + (direction * distance);
|
|
const width = 120 + Math.random() * 80;
|
|
const height = 20;
|
|
const finalY = Math.max(50, Math.min(this._config.canvasHeight - 100, targetY));
|
|
|
|
// Validate all conditions
|
|
const heightDifference = Math.abs(finalY - fromPlatform.y);
|
|
const horizontalDistance = Math.abs(newX - fromPlatform.x);
|
|
|
|
const isReachable = this._validateJumpPossible(
|
|
horizontalDistance, heightDifference,
|
|
safeHorizontalDistance, safeJumpHeight,
|
|
finalY < fromPlatform.y // jumping up or down
|
|
);
|
|
|
|
const minDistance = 100; // Increased minimum distance
|
|
const hasMinDistance = this._checkMinimumDistance(
|
|
level.platforms, newX, finalY, width, height, minDistance
|
|
);
|
|
|
|
const isInBounds = newX > 150 && newX + width < this._levelWidth - 300;
|
|
|
|
if (isReachable && hasMinDistance && isInBounds) {
|
|
level.platforms.push({
|
|
x: newX,
|
|
y: finalY,
|
|
width: width,
|
|
height: height,
|
|
type: 'floating',
|
|
color: this._getPlatformColor(finalY, heightZones)
|
|
});
|
|
|
|
console.log(`📍 Platform ${i}: x=${newX.toFixed(0)}, y=${finalY.toFixed(0)}, attempts=${attemptCount}`);
|
|
platformPlaced = true;
|
|
} else {
|
|
console.log(`❌ Platform ${i} attempt ${attemptCount} failed: reachable=${isReachable}, minDist=${hasMinDistance}, bounds=${isInBounds}`);
|
|
}
|
|
}
|
|
|
|
if (!platformPlaced) {
|
|
console.log(`⚠️ Failed to place platform ${i} after ${maxAttempts} attempts`);
|
|
}
|
|
}
|
|
|
|
// Add some challenge platforms at max difficulty
|
|
if (difficulty >= 3) {
|
|
this._addChallengePlatforms(level, safeHorizontalDistance, safeJumpHeight);
|
|
}
|
|
|
|
// Generate walls as obstacles
|
|
this._generateWalls(level, difficulty, safeJumpHeight);
|
|
}
|
|
|
|
_validateJumpPossible(horizontalDist, heightDiff, maxHorizontalDist, maxJumpHeight, isJumpingUp) {
|
|
// Check horizontal distance
|
|
if (horizontalDist > maxHorizontalDist) return false;
|
|
|
|
// Check vertical distance
|
|
if (isJumpingUp && heightDiff > maxJumpHeight) return false;
|
|
|
|
// For downward jumps, allow more flexibility (gravity helps)
|
|
if (!isJumpingUp && heightDiff > maxJumpHeight * 1.5) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
_checkMinimumDistance(existingPlatforms, newX, newY, newWidth, newHeight, minDistance) {
|
|
for (const platform of existingPlatforms) {
|
|
// Skip ground platforms for distance check, but increase minimum distance from ground
|
|
if (platform.type === 'ground') {
|
|
// Check minimum distance from ground level (increased)
|
|
if (Math.abs(newY - platform.y) < 120) { // Increased from 80 to 120
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Check for overlap and minimum distance
|
|
const newRight = newX + newWidth;
|
|
const newBottom = newY + newHeight;
|
|
const platformRight = platform.x + platform.width;
|
|
const platformBottom = platform.y + platform.height;
|
|
|
|
// Calculate distances between edges
|
|
const horizontalDistance = Math.max(0,
|
|
Math.max(platform.x - newRight, newX - platformRight)
|
|
);
|
|
const verticalDistance = Math.max(0,
|
|
Math.max(platform.y - newBottom, newY - platformBottom)
|
|
);
|
|
|
|
// Check if platforms overlap or are too close
|
|
if (horizontalDistance === 0 && verticalDistance === 0) {
|
|
// Overlapping
|
|
return false;
|
|
}
|
|
|
|
// Check minimum distance
|
|
const totalDistance = Math.sqrt(horizontalDistance * horizontalDistance + verticalDistance * verticalDistance);
|
|
if (totalDistance < minDistance) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
_checkQuestionBlockDistance(existingBlocks, newX, newY, newWidth, newHeight, minDistance) {
|
|
for (const block of existingBlocks) {
|
|
// Calculate distances between edges
|
|
const newRight = newX + newWidth;
|
|
const newBottom = newY + newHeight;
|
|
const blockRight = block.x + block.width;
|
|
const blockBottom = block.y + block.height;
|
|
|
|
const horizontalDistance = Math.max(0,
|
|
Math.max(block.x - newRight, newX - blockRight)
|
|
);
|
|
const verticalDistance = Math.max(0,
|
|
Math.max(block.y - newBottom, newY - blockBottom)
|
|
);
|
|
|
|
// Check if blocks overlap or are too close
|
|
if (horizontalDistance === 0 && verticalDistance === 0) {
|
|
// Overlapping
|
|
return false;
|
|
}
|
|
|
|
// Check minimum distance
|
|
const totalDistance = Math.sqrt(horizontalDistance * horizontalDistance + verticalDistance * verticalDistance);
|
|
if (totalDistance < minDistance) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
_getPlatformColor(y, heightZones) {
|
|
if (y >= heightZones.low) return '#90EE90'; // Light green (low)
|
|
if (y >= heightZones.mid) return '#32CD32'; // Green (mid)
|
|
if (y >= heightZones.high) return '#228B22'; // Forest green (high)
|
|
return '#006400'; // Dark green (very high)
|
|
}
|
|
|
|
_generateWalls(level, difficulty, maxJumpHeight) {
|
|
const wallCount = Math.floor(difficulty * 1.5); // More walls as difficulty increases
|
|
const groundHeight = this._config.canvasHeight - 50;
|
|
|
|
console.log(`🧱 Generating ${wallCount} walls for difficulty ${difficulty}`);
|
|
|
|
for (let i = 0; i < wallCount; i++) {
|
|
// Find suitable positions that don't block critical paths
|
|
const attempts = 20;
|
|
let placed = false;
|
|
|
|
for (let attempt = 0; attempt < attempts && !placed; attempt++) {
|
|
// Random position in the middle section of the level
|
|
const x = 400 + Math.random() * (this._levelWidth - 800);
|
|
|
|
// Wall heights based on difficulty
|
|
let wallHeight;
|
|
if (difficulty <= 2) {
|
|
// Low walls - can be jumped over
|
|
wallHeight = maxJumpHeight * (0.3 + Math.random() * 0.4);
|
|
} else if (difficulty <= 4) {
|
|
// Medium walls - some jumpable, some need platform
|
|
wallHeight = Math.random() < 0.5
|
|
? maxJumpHeight * (0.3 + Math.random() * 0.4) // Jumpable
|
|
: maxJumpHeight * (0.8 + Math.random() * 0.3); // Need platform
|
|
} else {
|
|
// High walls - mostly need platforms or alternate routes
|
|
wallHeight = maxJumpHeight * (0.7 + Math.random() * 0.4);
|
|
}
|
|
|
|
const wallY = groundHeight - wallHeight;
|
|
const wallWidth = 20 + Math.random() * 30; // Variable width
|
|
|
|
// Check if wall placement is valid (not too close to other obstacles)
|
|
const isValidPosition = this._isValidWallPosition(
|
|
level, x, wallY, wallWidth, wallHeight
|
|
);
|
|
|
|
if (isValidPosition) {
|
|
level.walls.push({
|
|
x: x,
|
|
y: wallY,
|
|
width: wallWidth,
|
|
height: wallHeight,
|
|
color: '#8B4513', // Brown color
|
|
type: wallHeight > maxJumpHeight * 0.6 ? 'tall' : 'short',
|
|
health: 3, // Wall can be damaged 3 times before being destroyed
|
|
maxHealth: 3
|
|
});
|
|
|
|
console.log(`🧱 Wall placed: x=${x.toFixed(0)}, height=${wallHeight.toFixed(0)} (${wallHeight > maxJumpHeight * 0.6 ? 'tall' : 'short'})`);
|
|
placed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_isValidWallPosition(level, wallX, wallY, wallWidth, wallHeight) {
|
|
const wallRight = wallX + wallWidth;
|
|
const wallBottom = wallY + wallHeight;
|
|
const minDistance = 120; // Minimum distance from other obstacles
|
|
|
|
// Check distance from platforms
|
|
for (const platform of level.platforms) {
|
|
if (platform.type === 'ground') continue; // Skip ground check
|
|
|
|
const platformRight = platform.x + platform.width;
|
|
|
|
// Check overlap or too close
|
|
if (!(wallRight < platform.x - minDistance ||
|
|
wallX > platformRight + minDistance ||
|
|
wallBottom < platform.y - minDistance ||
|
|
wallY > platform.y + platform.height + minDistance)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check distance from other walls
|
|
for (const wall of level.walls) {
|
|
const wallRight2 = wall.x + wall.width;
|
|
|
|
if (!(wallRight < wall.x - minDistance ||
|
|
wallX > wallRight2 + minDistance)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Don't place walls too close to start/end
|
|
if (wallX < 300 || wallRight > this._levelWidth - 300) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
_addChallengePlatforms(level, maxDistance, maxHeight) {
|
|
// Add 2-3 challenging but reachable platforms
|
|
const challengeCount = 2 + Math.floor(Math.random() * 2);
|
|
|
|
for (let i = 0; i < challengeCount; i++) {
|
|
const availablePlatforms = level.platforms.filter(p =>
|
|
p.type === 'floating' && p.x > 300 && p.x < this._levelWidth - 500
|
|
);
|
|
|
|
if (availablePlatforms.length === 0) continue;
|
|
|
|
const fromPlatform = availablePlatforms[Math.floor(Math.random() * availablePlatforms.length)];
|
|
|
|
// Use 90-95% of max capabilities for challenge
|
|
const challengeDistance = maxDistance * (0.9 + Math.random() * 0.05);
|
|
const challengeHeight = maxHeight * (0.8 + Math.random() * 0.15);
|
|
|
|
const direction = Math.random() > 0.5 ? 1 : -1;
|
|
const newX = fromPlatform.x + (direction * challengeDistance);
|
|
const newY = Math.max(50, fromPlatform.y - challengeHeight);
|
|
|
|
if (newX > 150 && newX < this._levelWidth - 300) {
|
|
level.platforms.push({
|
|
x: newX,
|
|
y: newY,
|
|
width: 100, // Smaller platforms for challenge
|
|
height: 20,
|
|
type: 'floating',
|
|
color: '#FF6B35' // Orange for challenge platforms
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
_generateCastleLevel(level) {
|
|
// Ground platforms for entire level
|
|
for (let x = 0; x < this._levelWidth; x += 200) {
|
|
level.platforms.push({
|
|
x: x,
|
|
y: this._config.canvasHeight - 50,
|
|
width: 200,
|
|
height: 50,
|
|
type: 'ground',
|
|
color: '#8B4513'
|
|
});
|
|
}
|
|
|
|
// Create single ascending staircase to castle
|
|
const jumpDistance = 180; // Comfortable jump distance
|
|
const castleX = this._levelWidth - 200; // Castle position
|
|
const numberOfPlatforms = Math.floor((castleX - 400) / jumpDistance) + 1;
|
|
|
|
// Progressive platforms leading directly to castle
|
|
for (let i = 0; i < numberOfPlatforms; i++) {
|
|
const x = 400 + i * jumpDistance;
|
|
const y = this._config.canvasHeight - 100 - (i * 30); // Steeper but manageable ascent
|
|
const isNearCastle = x > castleX - 300;
|
|
|
|
level.platforms.push({
|
|
x: x,
|
|
y: Math.max(200, y), // Higher ceiling for castle approach
|
|
width: isNearCastle ? 160 : 140, // Wider near castle
|
|
height: 20,
|
|
type: 'ascending',
|
|
color: isNearCastle ? '#FFD700' : '#32CD32' // Gold near castle
|
|
});
|
|
}
|
|
|
|
// Final platform right at castle level
|
|
level.platforms.push({
|
|
x: this._levelWidth - 360, // Aligned with shifted castle
|
|
y: this._config.canvasHeight - 160, // Lower since castle is on ground
|
|
width: 160,
|
|
height: 20,
|
|
type: 'castle_entrance',
|
|
color: '#FFD700'
|
|
});
|
|
|
|
// Castle emoji (visual only - no collision!)
|
|
const groundLevel = this._config.canvasHeight - 50;
|
|
level.castleStructure = {
|
|
x: this._levelWidth - 280, // Shifted left
|
|
y: groundLevel - 140, // Slightly in ground (360/2 = 180, but -140 puts it 40px in ground)
|
|
emoji: '🏰',
|
|
size: 360,
|
|
princess: {
|
|
x: this._levelWidth - 280, // Same X as castle
|
|
y: groundLevel - 200, // Above castle, adjusted for lower castle
|
|
emoji: '👸',
|
|
size: 50 // Slightly bigger princess
|
|
}
|
|
};
|
|
|
|
// Question blocks on accessible platforms
|
|
const accessiblePlatforms = level.platforms.filter(p => p.type === 'ascending' || p.type === 'floating');
|
|
|
|
for (let i = 0; i < Math.min(5, accessiblePlatforms.length); i++) {
|
|
const platform = accessiblePlatforms[i];
|
|
const sentence = this._getRandomSentence();
|
|
|
|
if (sentence) {
|
|
level.questionBlocks.push({
|
|
x: platform.x + platform.width / 2 - 16,
|
|
y: platform.y - 32,
|
|
width: 32,
|
|
height: 32,
|
|
sentence: sentence,
|
|
hit: false,
|
|
color: '#FFD700',
|
|
symbol: '?'
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`🏰 Castle level generated with ${level.platforms.length} platforms and castle structure`);
|
|
return level;
|
|
}
|
|
|
|
_generateHoles(level, difficulty) {
|
|
const holeCount = Math.min(difficulty - 1, 3); // 1-3 holes based on difficulty
|
|
level.holes = []; // Store hole positions to avoid spawning over them
|
|
|
|
for (let i = 0; i < holeCount; i++) {
|
|
let holeX, holeWidth = 1; // TEST: 1 pixel wide to see the holes clearly
|
|
let validPosition = false;
|
|
let attempts = 0;
|
|
const maxAttempts = 20;
|
|
|
|
while (!validPosition && attempts < maxAttempts) {
|
|
holeX = 300 + (i * 800) + Math.random() * 400; // Spread holes out
|
|
|
|
// Check minimum distance from other holes (at least 2 tiles = 400px apart)
|
|
const tooCloseToOtherHole = level.holes.some(existingHole => {
|
|
return Math.abs(holeX - existingHole.x) < 400;
|
|
});
|
|
|
|
if (!tooCloseToOtherHole) {
|
|
validPosition = true;
|
|
}
|
|
attempts++;
|
|
}
|
|
|
|
if (validPosition) {
|
|
// Store hole position for collision avoidance
|
|
level.holes.push({
|
|
x: holeX,
|
|
width: holeWidth
|
|
});
|
|
|
|
// Simply remove ground platforms that overlap with hole area
|
|
level.platforms = level.platforms.filter(platform => {
|
|
if (platform.type === 'ground') {
|
|
return !(platform.x < holeX + holeWidth && platform.x + platform.width > holeX);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
console.log(`🕳️ Hole ${i} created by removing ground at x=${holeX.toFixed(0)}, width=${holeWidth}px`);
|
|
} else {
|
|
console.log(`⚠️ Failed to place hole ${i} after ${maxAttempts} attempts - too close to other holes`);
|
|
}
|
|
}
|
|
}
|
|
|
|
_generateStairs(level, difficulty) {
|
|
const groundLevel = this._config.canvasHeight - 50;
|
|
const stairCount = Math.min(difficulty, 2); // 1-2 stair sets
|
|
|
|
for (let i = 0; i < stairCount; i++) {
|
|
let stairPlaced = false;
|
|
let attempts = 0;
|
|
const maxAttempts = 20;
|
|
|
|
while (!stairPlaced && attempts < maxAttempts) {
|
|
const stairX = 500 + (i * 1000) + Math.random() * 300;
|
|
const stepWidth = 80;
|
|
const stepHeight = 40;
|
|
const steps = 3 + Math.floor(Math.random() * 3); // 3-5 steps
|
|
|
|
// Check if stairs would collide with existing platforms or holes
|
|
let wouldCollide = false;
|
|
|
|
// Check collision with holes first
|
|
const overHole = level.holes && level.holes.some(hole => {
|
|
return stairX < hole.x + hole.width && stairX + (steps * stepWidth) > hole.x;
|
|
});
|
|
|
|
if (overHole) {
|
|
wouldCollide = true;
|
|
} else {
|
|
// Check collision with existing platforms
|
|
for (let step = 0; step < steps; step++) {
|
|
const stepX = stairX + (step * stepWidth);
|
|
const stepY = groundLevel - ((step + 1) * stepHeight);
|
|
|
|
const hasCollision = level.platforms.some(platform => {
|
|
return stepX < platform.x + platform.width &&
|
|
stepX + stepWidth > platform.x &&
|
|
stepY < platform.y + platform.height &&
|
|
stepY + stepHeight > platform.y;
|
|
});
|
|
|
|
if (hasCollision) {
|
|
wouldCollide = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!wouldCollide) {
|
|
// Generate ascending stairs
|
|
for (let step = 0; step < steps; step++) {
|
|
level.platforms.push({
|
|
x: stairX + (step * stepWidth),
|
|
y: groundLevel - ((step + 1) * stepHeight),
|
|
width: stepWidth,
|
|
height: stepHeight,
|
|
color: '#8B4513', // Brown like walls
|
|
type: 'stair'
|
|
});
|
|
}
|
|
stairPlaced = true;
|
|
console.log(`🪜 Stairs created at x=${stairX.toFixed(0)}, ${steps} steps`);
|
|
} else {
|
|
console.log(`🚫 Stair attempt ${attempts} at x=${stairX.toFixed(0)} would collide, retrying...`);
|
|
}
|
|
attempts++;
|
|
}
|
|
|
|
if (!stairPlaced) {
|
|
console.log(`⚠️ Failed to place stairs ${i} after ${maxAttempts} attempts`);
|
|
}
|
|
}
|
|
}
|
|
|
|
_generatePiranhaPlants(level, difficulty) {
|
|
level.piranhaPlants = PiranhaPlant.generate(level, difficulty);
|
|
}
|
|
|
|
_generateCatapults(level, difficulty) {
|
|
level.catapults = Catapult.generate(level, level.index, this._levelWidth, this._config.canvasHeight);
|
|
}
|
|
|
|
_generateCatapultsOLD(level, difficulty) {
|
|
const { index } = level;
|
|
|
|
let catapultCount = 1; // Always 1 catapult for level 4+
|
|
let onagerCount = 0;
|
|
|
|
// Level 5+ gets onagers
|
|
if (index >= 4) {
|
|
onagerCount = 1; // 1 onager for level 5+
|
|
}
|
|
|
|
const totalCount = catapultCount + onagerCount;
|
|
console.log(`🏹 Generating ${catapultCount} catapult(s) and ${onagerCount} onager(s) for level ${index + 1}`);
|
|
|
|
for (let i = 0; i < totalCount; i++) {
|
|
const isOnager = i >= catapultCount; // Onagers come after catapults
|
|
// Place catapults near END of level to help finish it
|
|
const nearEndX = this._levelWidth * 0.7; // 70% through level
|
|
const catapultX = nearEndX + (i * 300) + Math.random() * 200;
|
|
let catapultY = this._config.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)
|
|
let obstacleAbove = null;
|
|
const obstacles = [platformAbove, wallAbove, stairAbove].filter(obs => obs !== null);
|
|
|
|
if (obstacles.length > 0) {
|
|
// Find the obstacle with the highest Y value (closest to ground)
|
|
obstacleAbove = obstacles.reduce((lowest, current) =>
|
|
current.y > lowest.y ? current : lowest
|
|
);
|
|
}
|
|
|
|
if (obstacleAbove) {
|
|
// Place catapult ON TOP of the obstacle
|
|
catapultY = obstacleAbove.y - 80; // 80 is catapult height
|
|
console.log(`🏹 Catapult moved to obstacle at y=${catapultY.toFixed(0)}`);
|
|
}
|
|
|
|
level.catapults = level.catapults || [];
|
|
level.catapults.push({
|
|
x: catapultX,
|
|
y: catapultY,
|
|
width: 60,
|
|
height: 80,
|
|
color: isOnager ? '#654321' : '#8B4513', // Onager: darker brown, Catapult: normal brown
|
|
lastShot: 0,
|
|
shootCooldown: isOnager ? 6000 + Math.random() * 2000 : 4000 + Math.random() * 2000, // Onager shoots much less often (6-8s vs 4-6s)
|
|
type: isOnager ? 'onager' : 'catapult',
|
|
isOnager: isOnager
|
|
});
|
|
|
|
console.log(`${isOnager ? '🏛️' : '🏹'} ${isOnager ? 'Onager' : 'Catapult'} placed at x=${catapultX.toFixed(0)}, y=${catapultY.toFixed(0)}`);
|
|
}
|
|
}
|
|
|
|
_findPlatformAbove(x, groundY, platforms) {
|
|
// Find the lowest platform that's above the ground position and overlaps horizontally
|
|
let bestPlatform = null;
|
|
let lowestY = 0; // We want the platform closest to ground (highest Y value)
|
|
|
|
platforms.forEach(platform => {
|
|
// Check horizontal overlap (catapult width is 60)
|
|
const catapultLeft = x;
|
|
const catapultRight = x + 60;
|
|
const platformLeft = platform.x;
|
|
const platformRight = platform.x + platform.width;
|
|
|
|
const hasHorizontalOverlap = catapultLeft < platformRight && catapultRight > platformLeft;
|
|
|
|
// Check if platform is above ground and has horizontal overlap
|
|
if (hasHorizontalOverlap && platform.y < groundY && platform.y > lowestY) {
|
|
bestPlatform = platform;
|
|
lowestY = platform.y;
|
|
}
|
|
});
|
|
|
|
return bestPlatform;
|
|
}
|
|
|
|
_findWallAbove(x, groundY, walls) {
|
|
// Find the lowest wall that's above the ground position and overlaps horizontally
|
|
let bestWall = null;
|
|
let lowestY = 0; // We want the wall closest to ground (highest Y value)
|
|
|
|
walls.forEach(wall => {
|
|
// Check horizontal overlap (catapult width is 60)
|
|
const catapultLeft = x;
|
|
const catapultRight = x + 60;
|
|
const wallLeft = wall.x;
|
|
const wallRight = wall.x + wall.width;
|
|
|
|
const hasHorizontalOverlap = catapultLeft < wallRight && catapultRight > wallLeft;
|
|
|
|
// Check if wall is above ground and has horizontal overlap
|
|
if (hasHorizontalOverlap && wall.y < groundY && wall.y > lowestY) {
|
|
bestWall = wall;
|
|
lowestY = wall.y;
|
|
}
|
|
});
|
|
|
|
return bestWall;
|
|
}
|
|
|
|
_findStairAbove(x, groundY, stairs) {
|
|
// Find the lowest stair that's above the ground position and overlaps horizontally
|
|
let bestStair = null;
|
|
let lowestY = 0; // We want the stair closest to ground (highest Y value)
|
|
|
|
stairs.forEach(stair => {
|
|
// Check horizontal overlap (catapult width is 60)
|
|
const catapultLeft = x;
|
|
const catapultRight = x + 60;
|
|
const stairLeft = stair.x;
|
|
const stairRight = stair.x + stair.width;
|
|
|
|
const hasHorizontalOverlap = catapultLeft < stairRight && catapultRight > stairLeft;
|
|
|
|
// Check if stair is above ground and has horizontal overlap
|
|
if (hasHorizontalOverlap && stair.y < groundY && stair.y > lowestY) {
|
|
bestStair = stair;
|
|
lowestY = stair.y;
|
|
}
|
|
});
|
|
|
|
return bestStair;
|
|
}
|
|
|
|
_generateColossalBoss(level, difficulty) {
|
|
console.log(`👹 Generating Colossal Boss for level 6!`);
|
|
|
|
// Boss positioned in center-right of level to block the path
|
|
const bossX = this._levelWidth * 0.6; // 60% through the level
|
|
const bossY = this._config.canvasHeight - 250; // Standing on ground
|
|
const bossWidth = 150;
|
|
const bossHeight = 200;
|
|
|
|
level.boss = {
|
|
x: bossX,
|
|
y: bossY,
|
|
width: bossWidth,
|
|
height: bossHeight,
|
|
health: 5, // Takes 5 hits (1/5 each)
|
|
maxHealth: 5,
|
|
color: '#2F4F4F', // Dark slate gray
|
|
type: 'colossus',
|
|
// Collision boxes (knees for damage)
|
|
leftKnee: {
|
|
x: bossX + 20,
|
|
y: bossY + bossHeight - 60,
|
|
width: 40,
|
|
height: 40
|
|
},
|
|
rightKnee: {
|
|
x: bossX + bossWidth - 60,
|
|
y: bossY + bossHeight - 60,
|
|
width: 40,
|
|
height: 40
|
|
},
|
|
// Boss behavior
|
|
lastTurretShot: Date.now(),
|
|
turretCooldown: 2000, // Turrets fire every 2 seconds
|
|
lastMinionLaunch: Date.now(),
|
|
minionCooldown: 4000, // Launch minions every 4 seconds
|
|
// Visual
|
|
eyeColor: '#FF0000', // Red glowing eyes
|
|
isDamaged: false,
|
|
damageFlashTimer: 0
|
|
};
|
|
|
|
// Generate turrets on the boss (2 turrets)
|
|
level.bossTurrets = [
|
|
{
|
|
x: bossX + 30,
|
|
y: bossY + 50,
|
|
width: 25,
|
|
height: 25,
|
|
color: '#8B4513',
|
|
type: 'turret',
|
|
lastShot: Date.now(),
|
|
shootCooldown: 2500 // Individual cooldown
|
|
},
|
|
{
|
|
x: bossX + bossWidth - 55,
|
|
y: bossY + 50,
|
|
width: 25,
|
|
height: 25,
|
|
color: '#8B4513',
|
|
type: 'turret',
|
|
lastShot: Date.now(),
|
|
shootCooldown: 3000 // Slightly different timing
|
|
}
|
|
];
|
|
|
|
console.log(`👹 Colossal Boss spawned at x=${bossX.toFixed(0)}, health=${level.boss.health}`);
|
|
console.log(`🔫 ${level.bossTurrets.length} turrets mounted on boss`);
|
|
}
|
|
|
|
_generateBossStairs(level, difficulty) {
|
|
const groundLevel = this._config.canvasHeight - 50;
|
|
const stairCount = Math.min(3, 2); // Maximum 3 stair sets for boss level
|
|
|
|
for (let i = 0; i < stairCount; i++) {
|
|
let stairPlaced = false;
|
|
let attempts = 0;
|
|
const maxAttempts = 20;
|
|
|
|
while (!stairPlaced && attempts < maxAttempts) {
|
|
const stairX = 400 + (i * 800) + Math.random() * 300;
|
|
const stepWidth = 80;
|
|
const stepHeight = 30;
|
|
const maxSteps = 2; // BOSS LEVEL: Maximum 2 steps only
|
|
|
|
// Check if stair area is clear of holes and other obstacles
|
|
const stairAreaClear = true; // No holes in boss level
|
|
|
|
if (stairAreaClear) {
|
|
level.stairs = level.stairs || [];
|
|
|
|
// Generate stair steps (max 2 steps)
|
|
for (let step = 0; step < maxSteps; step++) {
|
|
level.stairs.push({
|
|
x: stairX + (step * stepWidth),
|
|
y: groundLevel - ((step + 1) * stepHeight),
|
|
width: stepWidth,
|
|
height: stepHeight * (step + 1), // Height increases with each step
|
|
color: '#D2691E', // Orange brown for boss level stairs
|
|
type: 'stair'
|
|
});
|
|
}
|
|
|
|
stairPlaced = true;
|
|
console.log(`🏰 Boss stair ${i + 1} placed at x=${stairX.toFixed(0)} (${maxSteps} steps max)`);
|
|
}
|
|
|
|
attempts++;
|
|
}
|
|
|
|
if (!stairPlaced) {
|
|
console.log(`⚠️ Failed to place boss stair ${i} after ${maxAttempts} attempts`);
|
|
}
|
|
}
|
|
}
|
|
|
|
_generateFlyingEyes(level, difficulty) {
|
|
const eyeCount = Math.min(4, Math.max(3, difficulty - 2)); // 3-4 flying eyes (more guaranteed)
|
|
console.log(`👁️ Generating ${eyeCount} flying eyes for level 5+`);
|
|
|
|
for (let i = 0; i < eyeCount; i++) {
|
|
// Eyes spawn in the middle-upper area of the level
|
|
const eyeX = 300 + (i * 400) + Math.random() * 200; // Spread across level
|
|
const eyeY = 100 + Math.random() * 150; // Upper area of screen
|
|
|
|
level.flyingEyes = level.flyingEyes || [];
|
|
level.flyingEyes.push({
|
|
x: eyeX,
|
|
y: eyeY,
|
|
width: 30,
|
|
height: 30,
|
|
velocityX: (Math.random() - 0.5) * 2, // Random horizontal drift -1 to +1
|
|
velocityY: (Math.random() - 0.5) * 2, // Random vertical drift -1 to +1
|
|
color: '#DC143C', // Crimson red
|
|
pupilColor: '#000000', // Black pupil
|
|
type: 'flying_eye',
|
|
health: 1,
|
|
// AI behavior properties
|
|
chaseDistance: 200, // Start chasing Mario within 200px
|
|
chaseSpeed: 3.5, // Faster chase speed
|
|
idleSpeed: 1.2, // Faster idle movement
|
|
lastDirectionChange: Date.now(),
|
|
directionChangeInterval: 2000 + Math.random() * 3000, // Change direction every 2-5 seconds
|
|
isChasing: false,
|
|
// Dash behavior
|
|
dashCooldown: 0,
|
|
dashDuration: 0,
|
|
isDashing: false,
|
|
dashSpeed: 8, // Very fast dash
|
|
lastDashTime: Date.now(),
|
|
dashInterval: 3000 + Math.random() * 2000, // Dash every 3-5 seconds
|
|
// Visual properties
|
|
blinkTimer: 0,
|
|
isBlinking: false
|
|
});
|
|
|
|
console.log(`👁️ Flying eye ${i + 1} placed at x=${eyeX.toFixed(0)}, y=${eyeY.toFixed(0)}`);
|
|
}
|
|
}
|
|
|
|
_hasSolidSupportBelow(x, y, level) {
|
|
// Check if there's solid support (ground platforms, floating platforms, stairs) directly below this position
|
|
const enemyWidth = 20;
|
|
const enemyCenterX = x + 10; // Center of enemy
|
|
const checkY = y + 5; // Just below the enemy
|
|
|
|
// Check if there's a ground platform below (that wasn't removed by holes)
|
|
const hasGroundPlatform = level.platforms.some(platform => {
|
|
const isGroundLevel = platform.type === 'ground' && platform.y >= this._config.canvasHeight - 60;
|
|
const isUnderEnemy = platform.x <= enemyCenterX && platform.x + platform.width >= enemyCenterX;
|
|
const isBelow = platform.y >= checkY;
|
|
return isGroundLevel && isUnderEnemy && isBelow;
|
|
});
|
|
|
|
// Check if there's any other platform below
|
|
const hasFloatingPlatform = level.platforms.some(platform => {
|
|
const isFloating = platform.type !== 'ground';
|
|
const isUnderEnemy = platform.x <= enemyCenterX && platform.x + platform.width >= enemyCenterX;
|
|
const isBelow = platform.y >= checkY && platform.y <= checkY + 50; // Within reasonable distance below
|
|
return isFloating && isUnderEnemy && isBelow;
|
|
});
|
|
|
|
// Check if there's a stair below
|
|
const hasStairSupport = level.stairs && level.stairs.some(stair => {
|
|
const isUnderEnemy = stair.x <= enemyCenterX && stair.x + stair.width >= enemyCenterX;
|
|
const isBelow = stair.y >= checkY && stair.y <= checkY + 50;
|
|
return isUnderEnemy && isBelow;
|
|
});
|
|
|
|
return hasGroundPlatform || hasFloatingPlatform || hasStairSupport;
|
|
}
|
|
|
|
_hasLineOfSight(catapult, targetX, targetY) {
|
|
// Check only the BEGINNING of the trajectory (immediate launch area)
|
|
const startX = catapult.x + 30; // Center of catapult
|
|
const startY = catapult.y - 10; // Top of catapult
|
|
|
|
// Check just the first 3 points of trajectory (immediate launch area only)
|
|
const steps = 3;
|
|
for (let i = 1; i <= steps; i++) {
|
|
const progress = i / 10; // Only check first 30% of trajectory
|
|
const checkX = startX + (targetX - startX) * progress;
|
|
const checkY = startY + (targetY - startY) * progress * 0.5; // Slight arc
|
|
|
|
// Check if any platform blocks this immediate launch area
|
|
const blocked = this._platforms.some(platform =>
|
|
checkX >= platform.x &&
|
|
checkX <= platform.x + platform.width &&
|
|
checkY >= platform.y &&
|
|
checkY <= platform.y + platform.height
|
|
);
|
|
|
|
if (blocked) {
|
|
return false; // Launch area blocked
|
|
}
|
|
}
|
|
|
|
return true; // Clear launch area
|
|
}
|
|
|
|
_startLevel(levelIndex) {
|
|
if (levelIndex >= this._levelData.length) {
|
|
this._completeGame();
|
|
return;
|
|
}
|
|
|
|
this._currentLevelIndex = levelIndex;
|
|
this._currentLevel = levelIndex + 1;
|
|
|
|
// Reset completion flag for new level
|
|
this._levelCompleted = false;
|
|
this._isCelebrating = false;
|
|
|
|
const level = this._levelData[levelIndex];
|
|
this._platforms = [...level.platforms];
|
|
this._questionBlocks = [...level.questionBlocks];
|
|
this._enemies = [...level.enemies];
|
|
this._collectibles = [...level.collectibles];
|
|
this._walls = [...(level.walls || [])];
|
|
this._castleStructure = level.castleStructure || null;
|
|
|
|
// Create finish line at level end
|
|
this._finishLine = {
|
|
x: level.endX,
|
|
y: this._config.canvasHeight - 150,
|
|
height: 150
|
|
};
|
|
|
|
// Advanced level elements
|
|
this._piranhaPlants = [...(level.piranhaPlants || [])];
|
|
this._projectiles = []; // Reset projectiles each level
|
|
|
|
// Level 4+ catapult system
|
|
this._catapults = [...(level.catapults || [])];
|
|
this._boulders = []; // Reset boulders each level
|
|
this._stones = []; // Reset stones each level
|
|
|
|
// Level 5+ flying eyes system
|
|
this._flyingEyes = [...(level.flyingEyes || [])];
|
|
|
|
// Level 6 boss system
|
|
this._boss = level.boss || null;
|
|
this._bossTurrets = [...(level.bossTurrets || [])];
|
|
this._bossMinions = []; // Reset minions each level
|
|
this._powerUps = []; // Reset power-ups each level
|
|
|
|
// Reset Mario position
|
|
this._mario.x = level.startX;
|
|
this._mario.y = level.startY;
|
|
this._mario.velocityX = 0;
|
|
this._mario.velocityY = 0;
|
|
this._mario.onGround = false;
|
|
|
|
// Reset camera
|
|
this._camera.x = 0;
|
|
this._camera.y = 0;
|
|
|
|
console.log(`🎮 Started level ${this._currentLevel}${level.isCastleLevel ? ' (Castle)' : ''}`);
|
|
}
|
|
|
|
_setupGameUI() {
|
|
// Create UI overlay
|
|
const uiOverlay = document.createElement('div');
|
|
uiOverlay.style.position = 'absolute';
|
|
uiOverlay.style.top = '10px';
|
|
uiOverlay.style.left = '10px';
|
|
uiOverlay.style.color = 'white';
|
|
uiOverlay.style.fontFamily = 'Arial, sans-serif';
|
|
uiOverlay.style.fontSize = '18px';
|
|
uiOverlay.style.fontWeight = 'bold';
|
|
uiOverlay.style.textShadow = '2px 2px 4px rgba(0,0,0,0.8)';
|
|
uiOverlay.style.pointerEvents = 'none';
|
|
uiOverlay.style.zIndex = '10';
|
|
|
|
this._uiOverlay = uiOverlay;
|
|
this._config.container.style.position = 'relative';
|
|
this._config.container.appendChild(uiOverlay);
|
|
}
|
|
|
|
_startGameLoop() {
|
|
const gameLoop = () => {
|
|
if (!this._isGameOver && !this._isPaused && !this._isQuestionActive) {
|
|
this._update();
|
|
}
|
|
this._render();
|
|
this._gameLoop = requestAnimationFrame(gameLoop);
|
|
};
|
|
|
|
gameLoop();
|
|
}
|
|
|
|
_update() {
|
|
// Handle input
|
|
this._updateMarioMovement();
|
|
|
|
// Update Mario physics
|
|
this._updateMarioPhysics();
|
|
|
|
// Update enemies
|
|
this._updateEnemies();
|
|
|
|
// Check collisions
|
|
this._checkCollisions();
|
|
|
|
// Update camera
|
|
this._updateCamera();
|
|
|
|
// Update advanced elements
|
|
this._updatePiranhaPlants();
|
|
this._updateProjectiles();
|
|
|
|
// Update level 4+ elements
|
|
this._updateCatapults();
|
|
this._updateBoulders();
|
|
this._updateStones();
|
|
|
|
// Update level 5+ elements
|
|
this._updateFlyingEyes();
|
|
|
|
// Update level 6 boss elements
|
|
this._updateBoss();
|
|
|
|
// Update particles
|
|
this._updateParticles();
|
|
|
|
// Check level completion
|
|
this._checkLevelCompletion();
|
|
}
|
|
|
|
_updateMarioMovement() {
|
|
// Mouse controls take priority when mouse is pressed
|
|
if (this._mousePressed && this._mouseWorldPos.x !== null) {
|
|
this._handleMouseMovement();
|
|
} else {
|
|
// Fallback to keyboard controls
|
|
PhysicsEngine.updateMarioMovement(this._mario, this._keys, this._config, this._isCelebrating, (sound) => soundSystem.play(sound));
|
|
}
|
|
}
|
|
|
|
_handleMouseMovement() {
|
|
if (this._isCelebrating) return;
|
|
|
|
const targetX = this._mouseWorldPos.x;
|
|
const targetY = this._mouseWorldPos.y;
|
|
const marioX = this._mario.x + this._mario.width / 2; // Mario center
|
|
const marioY = this._mario.y + this._mario.height / 2;
|
|
|
|
const distanceX = targetX - marioX;
|
|
const distanceY = targetY - marioY;
|
|
const totalDistance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
|
|
|
|
// Dead zone - don't move if target is very close
|
|
if (totalDistance < 10) {
|
|
this._mario.velocityX *= 0.8; // Friction
|
|
return;
|
|
}
|
|
|
|
// Horizontal movement
|
|
if (Math.abs(distanceX) > 5) {
|
|
if (distanceX > 0) {
|
|
this._mario.velocityX = this._config.moveSpeed;
|
|
this._mario.facing = 'right';
|
|
} else {
|
|
this._mario.velocityX = -this._config.moveSpeed;
|
|
this._mario.facing = 'left';
|
|
}
|
|
}
|
|
|
|
// Jump if target is above Mario and Mario is on ground
|
|
if (distanceY < -20 && this._mario.onGround && Math.abs(distanceX) < 100) {
|
|
this._mario.velocityY = this._config.jumpForce;
|
|
this._mario.onGround = false;
|
|
soundSystem.play('jump');
|
|
console.log(`🖱️ Auto-jump towards target`);
|
|
}
|
|
}
|
|
|
|
_updateMarioPhysics() {
|
|
const level = this._levelData[this._currentLevelIndex];
|
|
PhysicsEngine.updateMarioPhysics(this._mario, this._config, level, this._levelCompleted, () => this._restartLevel());
|
|
}
|
|
|
|
_updateEnemies() {
|
|
PhysicsEngine.updateEnemies(this._enemies, this._walls, this._platforms, this._levelWidth, this._isCelebrating);
|
|
}
|
|
|
|
_checkCollisions() {
|
|
const gameState = {
|
|
platforms: this._platforms,
|
|
questionBlocks: this._questionBlocks,
|
|
enemies: this._enemies,
|
|
walls: this._walls,
|
|
catapults: this._catapults,
|
|
piranhaPlants: this._piranhaPlants,
|
|
boulders: this._boulders,
|
|
flyingEyes: this._flyingEyes
|
|
};
|
|
|
|
const callbacks = {
|
|
onQuestionBlock: (block) => this._hitQuestionBlock(block),
|
|
onEnemyDefeat: (index) => {
|
|
this._score += 50;
|
|
this._enemies.splice(index, 1);
|
|
},
|
|
onMarioDeath: () => this._restartLevel(),
|
|
onAddParticles: (x, y, color) => this._addParticles(x, y, color)
|
|
};
|
|
|
|
PhysicsEngine.checkCollisions(this._mario, gameState, callbacks);
|
|
}
|
|
|
|
|
|
_isColliding(rect1, rect2) {
|
|
return PhysicsEngine.isColliding(rect1, rect2);
|
|
}
|
|
|
|
_hitQuestionBlock(block) {
|
|
if (block.hit) return;
|
|
|
|
block.hit = true;
|
|
block.color = '#8B4513'; // Brown when hit
|
|
block.symbol = '!';
|
|
|
|
this._score += 100; // Increased points for question blocks
|
|
this._addParticles(block.x, block.y, '#FFD700');
|
|
soundSystem.play('question_block');
|
|
|
|
// Show question dialog
|
|
this._showQuestionDialog(block.sentence);
|
|
}
|
|
|
|
_showQuestionDialog(sentence) {
|
|
this._isPaused = true;
|
|
this._isQuestionActive = true;
|
|
|
|
// Create dialog overlay
|
|
const overlay = document.createElement('div');
|
|
overlay.style.position = 'fixed';
|
|
overlay.style.top = '0';
|
|
overlay.style.left = '0';
|
|
overlay.style.width = '100%';
|
|
overlay.style.height = '100%';
|
|
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
|
overlay.style.display = 'flex';
|
|
overlay.style.justifyContent = 'center';
|
|
overlay.style.alignItems = 'center';
|
|
overlay.style.zIndex = '1000';
|
|
|
|
const dialog = document.createElement('div');
|
|
dialog.style.backgroundColor = 'white';
|
|
dialog.style.padding = '30px';
|
|
dialog.style.borderRadius = '15px';
|
|
dialog.style.maxWidth = '500px';
|
|
dialog.style.textAlign = 'center';
|
|
dialog.style.boxShadow = '0 10px 30px rgba(0,0,0,0.3)';
|
|
dialog.style.fontFamily = 'Arial, sans-serif';
|
|
|
|
const title = document.createElement('h2');
|
|
title.textContent = '🎧 Listen & Learn!';
|
|
title.style.color = '#FF6B6B';
|
|
title.style.marginBottom = '20px';
|
|
|
|
const questionText = document.createElement('div');
|
|
questionText.innerHTML = `
|
|
<p style="font-size: 18px; margin-bottom: 10px;"><strong>English:</strong> ${sentence.english}</p>
|
|
<p style="font-size: 18px; margin-bottom: 20px; color: #4ECDC4;"><strong>Translation:</strong> ${sentence.translation}</p>
|
|
<p style="font-size: 14px; color: #666;">${sentence.context ? `Context: ${sentence.context}` : ''}</p>
|
|
`;
|
|
|
|
// Add progress indicator
|
|
const progressBar = document.createElement('div');
|
|
progressBar.style.width = '100%';
|
|
progressBar.style.height = '4px';
|
|
progressBar.style.backgroundColor = '#E0E0E0';
|
|
progressBar.style.borderRadius = '2px';
|
|
progressBar.style.marginTop = '20px';
|
|
progressBar.style.overflow = 'hidden';
|
|
|
|
const progressFill = document.createElement('div');
|
|
progressFill.style.width = '0%';
|
|
progressFill.style.height = '100%';
|
|
progressFill.style.backgroundColor = '#4ECDC4';
|
|
progressFill.style.transition = 'width linear';
|
|
progressBar.appendChild(progressFill);
|
|
|
|
dialog.appendChild(title);
|
|
dialog.appendChild(questionText);
|
|
dialog.appendChild(progressBar);
|
|
overlay.appendChild(dialog);
|
|
document.body.appendChild(overlay);
|
|
|
|
// Start TTS and auto-close
|
|
this._playTTSAndAutoClose(sentence.english, overlay, progressFill);
|
|
}
|
|
|
|
async _playTTSAndAutoClose(text, overlay, progressBar) {
|
|
// Calculate duration based on text length (words per minute estimation)
|
|
const words = text.split(' ').length;
|
|
const wordsPerMinute = 150; // Average speaking speed
|
|
const baseDuration = Math.max(2000, (words / wordsPerMinute) * 60000); // Minimum 2 seconds
|
|
const duration = Math.min(baseDuration, 8000); // Maximum 8 seconds
|
|
|
|
console.log(`🔊 Playing TTS for: "${text}" (${words} words, ${duration}ms)`);
|
|
|
|
// Use Web Speech API for TTS
|
|
if ('speechSynthesis' in window) {
|
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
utterance.rate = 0.8; // Slightly slower for clarity
|
|
utterance.pitch = 1.0;
|
|
utterance.volume = 0.8;
|
|
|
|
// Get target language from content
|
|
const targetLanguage = this._content?.language || 'en-US';
|
|
utterance.lang = targetLanguage;
|
|
|
|
// Wait for voices to be loaded before selecting one
|
|
const voices = await this._getVoices();
|
|
const langPrefix = targetLanguage.split('-')[0];
|
|
|
|
const matchingVoice = voices.find(voice =>
|
|
voice.lang.startsWith(langPrefix) && voice.default
|
|
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
|
|
|
if (matchingVoice) {
|
|
utterance.voice = matchingVoice;
|
|
console.log(`🎤 Using voice: ${matchingVoice.name} (${matchingVoice.lang})`);
|
|
} else {
|
|
console.warn(`🔊 No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
|
}
|
|
|
|
speechSynthesis.speak(utterance);
|
|
}
|
|
|
|
// Animate progress bar
|
|
progressBar.style.transitionDuration = `${duration}ms`;
|
|
progressBar.style.width = '100%';
|
|
|
|
// Auto close after duration
|
|
setTimeout(() => {
|
|
if (overlay && overlay.parentNode) {
|
|
document.body.removeChild(overlay);
|
|
this._isPaused = false;
|
|
this._isQuestionActive = false;
|
|
this._questionsAnswered++;
|
|
this._score += 300; // Increased bonus for listening
|
|
console.log(`✅ Question dialog auto-closed. Questions answered: ${this._questionsAnswered}`);
|
|
}
|
|
}, duration);
|
|
}
|
|
|
|
_updatePiranhaPlants() {
|
|
PiranhaPlant.update(this._piranhaPlants, this._mario, this._projectiles, (sound) => soundSystem.play(sound));
|
|
}
|
|
|
|
_updateProjectiles() {
|
|
this._projectiles = Projectile.update(
|
|
this._projectiles,
|
|
this._mario,
|
|
this._platforms,
|
|
this._walls,
|
|
this._levelWidth,
|
|
() => { console.log(`🔥 Mario hit by projectile`); this._restartLevel(); },
|
|
(proj, idx, x, y) => this._addParticles(x, y, '#FF4500')
|
|
);
|
|
}
|
|
|
|
_updateCatapults() {
|
|
Catapult.update(this._catapults, this._mario, this._boulders, this._stones, (sound) => soundSystem.play(sound), this._config.canvasHeight);
|
|
}
|
|
|
|
_updateBoulders() {
|
|
Catapult.updateBoulders(
|
|
this._boulders, this._mario, this._platforms, this._walls,
|
|
(boulder, idx, impactX, impactY, obj, objIdx, type) => {
|
|
this._handleBoulderImpact(boulder, idx, impactX, impactY, obj, objIdx, type);
|
|
}
|
|
);
|
|
}
|
|
|
|
_handleBoulderImpact(boulder, boulderIndex, impactX, impactY, hitObject = null, hitObjectIndex = -1, hitType = 'platform') {
|
|
// Special case: Boulder hit Mario while flying
|
|
if (hitType === 'mario') {
|
|
console.log(`💀 Flying boulder hit Mario - restarting level`);
|
|
this._addParticles(this._mario.x, this._mario.y, '#FF0000'); // Red death particles
|
|
soundSystem.play('enemy_defeat');
|
|
|
|
// Remove the boulder
|
|
this._boulders.splice(boulderIndex, 1);
|
|
|
|
// Restart level
|
|
this._restartLevel();
|
|
return;
|
|
}
|
|
|
|
// Set boulder as landed at impact point
|
|
boulder.x = impactX;
|
|
boulder.y = impactY;
|
|
boulder.velocityX = 0;
|
|
boulder.velocityY = 0;
|
|
boulder.hasLanded = true;
|
|
boulder.color = '#8B4513'; // Brown when landed (becomes platform)
|
|
|
|
// Explosion effect
|
|
this._addParticles(boulder.x + boulder.width/2, boulder.y + boulder.height/2, '#FF4500');
|
|
soundSystem.play('enemy_defeat');
|
|
|
|
// Kill enemies in explosion radius (50 pixels)
|
|
this._enemies.forEach((enemy, enemyIndex) => {
|
|
const distance = Math.sqrt(
|
|
Math.pow(enemy.x - (boulder.x + boulder.width/2), 2) +
|
|
Math.pow(enemy.y - (boulder.y + boulder.height/2), 2)
|
|
);
|
|
if (distance < 50) {
|
|
this._enemies.splice(enemyIndex, 1);
|
|
this._addParticles(enemy.x, enemy.y, '#FFD700');
|
|
console.log(`💥 Boulder explosion killed enemy at distance ${distance.toFixed(0)}`);
|
|
}
|
|
});
|
|
|
|
// Note: Mario is NOT killed by boulder landing explosion
|
|
// Only flying boulders kill Mario (handled above)
|
|
|
|
// Boulder lands only on platforms, ground, stairs - NOT on walls or other boulders (they're destroyed on impact)
|
|
if (hitObject && (hitType === 'platform' || hitType === 'stair')) {
|
|
console.log(`🪨 Boulder landed on ${hitType}, creating additional platform`);
|
|
}
|
|
|
|
console.log(`🪨 Boulder landed at x=${impactX.toFixed(0)}, y=${impactY.toFixed(0)} - now a platform!`);
|
|
}
|
|
|
|
_updateStones() {
|
|
this._stones = Catapult.updateStones(
|
|
this._stones, this._mario, this._platforms,
|
|
(stone, idx, type) => {
|
|
if (type === 'mario') this._restartLevel();
|
|
}
|
|
);
|
|
}
|
|
|
|
_renderStones() {
|
|
this._stones.forEach(stone => {
|
|
// Draw small stone as a circle
|
|
this._ctx.fillStyle = stone.color;
|
|
this._ctx.beginPath();
|
|
this._ctx.arc(stone.x + stone.width/2, stone.y + stone.height/2, stone.width/2, 0, 2 * Math.PI);
|
|
this._ctx.fill();
|
|
|
|
// Add small highlight for 3D effect
|
|
this._ctx.fillStyle = '#D2B48C'; // Light brown highlight
|
|
this._ctx.beginPath();
|
|
this._ctx.arc(stone.x + stone.width/2 - 1, stone.y + stone.height/2 - 1, stone.width/4, 0, 2 * Math.PI);
|
|
this._ctx.fill();
|
|
});
|
|
}
|
|
|
|
_updateCamera() {
|
|
// Follow Mario with smooth camera movement
|
|
const targetX = this._mario.x - this._config.canvasWidth / 2;
|
|
this._camera.x = Math.max(0, Math.min(targetX, this._levelWidth - this._config.canvasWidth));
|
|
}
|
|
|
|
_addParticles(x, y, color) {
|
|
for (let i = 0; i < 8; i++) {
|
|
this._particles.push({
|
|
x: x,
|
|
y: y,
|
|
velocityX: (Math.random() - 0.5) * 10,
|
|
velocityY: -Math.random() * 8 - 2,
|
|
color: color,
|
|
life: 60,
|
|
maxLife: 60
|
|
});
|
|
}
|
|
}
|
|
|
|
_addSmallParticles(x, y, color) {
|
|
// Only 2-3 small particles for walking dust
|
|
for (let i = 0; i < 3; i++) {
|
|
this._particles.push({
|
|
x: x,
|
|
y: y,
|
|
velocityX: (Math.random() - 0.5) * 4, // Smaller velocity
|
|
velocityY: -Math.random() * 3 - 1, // Less upward movement
|
|
color: color,
|
|
life: 30, // Shorter life
|
|
maxLife: 30
|
|
});
|
|
}
|
|
}
|
|
|
|
_updateParticles() {
|
|
this._particles = this._particles.filter(particle => {
|
|
particle.x += particle.velocityX;
|
|
particle.y += particle.velocityY;
|
|
|
|
if (particle.isFinishStar) {
|
|
// Slower gravity for stars and some sparkle effect
|
|
particle.velocityY += 0.1; // Light gravity
|
|
particle.velocityX *= 0.98; // Slight friction
|
|
} else {
|
|
particle.velocityY += 0.3; // Normal gravity
|
|
}
|
|
|
|
particle.life--;
|
|
return particle.life > 0;
|
|
});
|
|
}
|
|
|
|
_checkLevelCompletion() {
|
|
const level = this._levelData[this._currentLevelIndex];
|
|
|
|
// Debug: log Mario position vs end position
|
|
if (this._mario.x > level.endX - 100) { // Close to end
|
|
console.log(`🎯 Mario position: ${this._mario.x.toFixed(0)}, Level end: ${level.endX.toFixed(0)}`);
|
|
}
|
|
|
|
if (this._mario.x >= level.endX && !this._levelCompleted) {
|
|
console.log(`🏁 Level ${this._currentLevel} completed!`);
|
|
this._levelCompleted = true; // Prevent multiple triggers
|
|
this._triggerFinishLineAnimation();
|
|
}
|
|
}
|
|
|
|
_triggerFinishLineAnimation() {
|
|
this._isCelebrating = true; // Block enemies but allow animations
|
|
|
|
// Create star animation particles
|
|
this._createFinishLineStars();
|
|
soundSystem.play('finish_stars');
|
|
|
|
// Complete level after animation
|
|
setTimeout(() => {
|
|
this._completeLevel();
|
|
}, 2000);
|
|
}
|
|
|
|
_createFinishLineStars() {
|
|
const centerX = this._mario.x;
|
|
const centerY = this._mario.y;
|
|
|
|
// Create explosion of stars
|
|
for (let i = 0; i < 20; i++) {
|
|
const angle = (Math.PI * 2 * i) / 20;
|
|
const speed = 5 + Math.random() * 5;
|
|
const distance = 50 + Math.random() * 100;
|
|
|
|
this._particles.push({
|
|
x: centerX,
|
|
y: centerY,
|
|
velocityX: Math.cos(angle) * speed,
|
|
velocityY: Math.sin(angle) * speed,
|
|
life: 2000,
|
|
maxLife: 2000,
|
|
color: i % 3 === 0 ? '#FFD700' : i % 3 === 1 ? '#FF69B4' : '#00BFFF',
|
|
size: 8 + Math.random() * 4,
|
|
isFinishStar: true
|
|
});
|
|
}
|
|
|
|
console.log('🌟 Finish line star animation triggered!');
|
|
}
|
|
|
|
_createLevelCompleteStars() {
|
|
const centerX = this._mario.x;
|
|
const centerY = this._mario.y;
|
|
|
|
// Create celebration stars around Mario
|
|
for (let i = 0; i < 15; i++) {
|
|
const angle = (Math.PI * 2 * i) / 15;
|
|
const speed = 3 + Math.random() * 4;
|
|
|
|
this._particles.push({
|
|
x: centerX,
|
|
y: centerY,
|
|
velocityX: Math.cos(angle) * speed,
|
|
velocityY: Math.sin(angle) * speed,
|
|
life: 2500,
|
|
maxLife: 2500,
|
|
color: ['#FFD700', '#FF69B4', '#00BFFF', '#32CD32', '#FF4500'][Math.floor(Math.random() * 5)],
|
|
size: 6 + Math.random() * 3,
|
|
isLevelCompleteStar: true
|
|
});
|
|
}
|
|
|
|
console.log('⭐ Level complete star animation created!');
|
|
}
|
|
|
|
_createEpicFinalStars() {
|
|
const centerX = this._mario.x;
|
|
const centerY = this._mario.y;
|
|
|
|
// 🌟💥 ULTRA EPIC FINAL EXPLOSION - 8 MASSIVE WAVES! 💥🌟
|
|
for (let wave = 0; wave < 8; wave++) {
|
|
setTimeout(() => {
|
|
// MASSIVE number of stars per wave (80, 100, 120, 140, 160, 180, 200, 220)
|
|
const starsInWave = 80 + (wave * 20);
|
|
|
|
for (let i = 0; i < starsInWave; i++) {
|
|
const angle = (Math.PI * 2 * i) / starsInWave;
|
|
const speed = 3 + Math.random() * 8 + (wave * 1.5);
|
|
const waveOffset = wave * 50; // Much bigger spread
|
|
|
|
this._particles.push({
|
|
x: centerX + (Math.random() - 0.5) * waveOffset,
|
|
y: centerY + (Math.random() - 0.5) * waveOffset,
|
|
velocityX: Math.cos(angle) * speed,
|
|
velocityY: Math.sin(angle) * speed - (Math.random() * 3), // More upward bias
|
|
life: 6000 + (wave * 800), // Longer lasting
|
|
maxLife: 6000 + (wave * 800),
|
|
color: ['#FFD700', '#FF69B4', '#00BFFF', '#32CD32', '#FF4500', '#9400D3', '#FF1493', '#00FF7F', '#FFFF00', '#FF6347', '#98FB98', '#DDA0DD'][Math.floor(Math.random() * 12)],
|
|
size: 10 + Math.random() * 8 + (wave * 0.5), // Bigger stars
|
|
isEpicFinalStar: true
|
|
});
|
|
}
|
|
|
|
// Play sound effects for each wave
|
|
soundSystem.play('finish_stars');
|
|
|
|
// Extra sound effect for bigger waves
|
|
if (wave >= 4) {
|
|
setTimeout(() => soundSystem.play('powerup'), 100);
|
|
}
|
|
}, wave * 200); // Faster succession
|
|
}
|
|
|
|
// BONUS MEGA EXPLOSION at the end!
|
|
setTimeout(() => {
|
|
for (let i = 0; i < 300; i++) { // 300 bonus stars!
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const speed = 2 + Math.random() * 12;
|
|
|
|
this._particles.push({
|
|
x: centerX + (Math.random() - 0.5) * 200,
|
|
y: centerY + (Math.random() - 0.5) * 200,
|
|
velocityX: Math.cos(angle) * speed,
|
|
velocityY: Math.sin(angle) * speed - Math.random() * 4,
|
|
life: 8000,
|
|
maxLife: 8000,
|
|
color: ['#FFD700', '#FF1493', '#00FFFF', '#FF4500', '#9400D3'][Math.floor(Math.random() * 5)],
|
|
size: 15 + Math.random() * 10, // HUGE stars
|
|
isEpicFinalStar: true
|
|
});
|
|
}
|
|
soundSystem.play('level_complete');
|
|
console.log('🌟💥🎆 MEGA BONUS EXPLOSION! 300 GIANT STARS! 🎆💥🌟');
|
|
}, 2000);
|
|
|
|
console.log('🌟💥🎆 ULTRA EPIC FINAL STAR EXPLOSION ACTIVATED! OVER 1500+ STARS! 🎆💥🌟');
|
|
}
|
|
|
|
_completeLevel() {
|
|
// NO SCORE for completing level anymore!
|
|
this._isCelebrating = true; // Block enemies but allow animations
|
|
|
|
// Play level complete sound
|
|
soundSystem.play('level_complete');
|
|
|
|
// Add star animations for every level completion
|
|
this._createLevelCompleteStars();
|
|
|
|
// Show level complete message
|
|
this._showLevelCompleteMessage();
|
|
|
|
if (this._currentLevelIndex < this._levelData.length - 1) {
|
|
// Move to next level after delay
|
|
setTimeout(() => {
|
|
this._isCelebrating = false;
|
|
this._startLevel(this._currentLevelIndex + 1);
|
|
}, 3000); // Increased delay for star animation
|
|
} else {
|
|
// Game completed - EPIC FINAL STAR EXPLOSION
|
|
this._createEpicFinalStars();
|
|
setTimeout(() => {
|
|
this._completeGame();
|
|
}, 5000); // Longer delay for epic animation
|
|
}
|
|
}
|
|
|
|
_showLevelCompleteMessage() {
|
|
// Create level complete overlay
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'level-complete-overlay';
|
|
overlay.style.position = 'fixed';
|
|
overlay.style.top = '0';
|
|
overlay.style.left = '0';
|
|
overlay.style.width = '100%';
|
|
overlay.style.height = '100%';
|
|
overlay.style.backgroundColor = 'rgba(0, 150, 0, 0.8)';
|
|
overlay.style.display = 'flex';
|
|
overlay.style.flexDirection = 'column';
|
|
overlay.style.justifyContent = 'center';
|
|
overlay.style.alignItems = 'center';
|
|
overlay.style.zIndex = '1000';
|
|
overlay.style.color = 'white';
|
|
overlay.style.textAlign = 'center';
|
|
overlay.style.fontFamily = 'Arial, sans-serif';
|
|
|
|
const isLastLevel = this._currentLevelIndex >= this._levelData.length - 1;
|
|
|
|
overlay.innerHTML = `
|
|
<h1 style="font-size: 3em; margin: 0; text-shadow: 2px 2px 4px rgba(0,0,0,0.5);">
|
|
${isLastLevel ? '🏆 VICTORY!' : '🎉 LEVEL COMPLETE!'}
|
|
</h1>
|
|
<h2 style="font-size: 2em; margin: 20px 0; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);">
|
|
${isLastLevel ? 'You completed all levels!' : `Level ${this._currentLevel} Complete`}
|
|
</h2>
|
|
<p style="font-size: 1.5em; margin: 10px 0;">Score: ${this._score.toLocaleString()}</p>
|
|
<p style="font-size: 1.2em; margin: 10px 0;">Questions Answered: ${this._questionsAnswered}</p>
|
|
${!isLastLevel ? '<p style="font-size: 1em; margin: 20px 0; opacity: 0.8;">Next level starting soon...</p>' : ''}
|
|
`;
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
// Remove overlay after delay
|
|
setTimeout(() => {
|
|
const existingOverlay = document.getElementById('level-complete-overlay');
|
|
if (existingOverlay) {
|
|
existingOverlay.remove();
|
|
}
|
|
}, isLastLevel ? 5000 : 1800);
|
|
}
|
|
|
|
_completeGame() {
|
|
this._isGameOver = true;
|
|
|
|
// Show victory screen
|
|
const overlay = document.createElement('div');
|
|
overlay.style.position = 'fixed';
|
|
overlay.style.top = '0';
|
|
overlay.style.left = '0';
|
|
overlay.style.width = '100%';
|
|
overlay.style.height = '100%';
|
|
overlay.style.backgroundColor = 'rgba(0, 100, 0, 0.9)';
|
|
overlay.style.display = 'flex';
|
|
overlay.style.justifyContent = 'center';
|
|
overlay.style.alignItems = 'center';
|
|
overlay.style.zIndex = '1000';
|
|
overlay.style.color = 'white';
|
|
overlay.style.textAlign = 'center';
|
|
overlay.style.fontFamily = 'Arial, sans-serif';
|
|
|
|
const playTime = Math.round((Date.now() - this._gameStartTime) / 1000);
|
|
|
|
const content = document.createElement('div');
|
|
content.innerHTML = `
|
|
<h1 style="font-size: 48px; margin-bottom: 20px;">🏰 Congratulations! 🏰</h1>
|
|
<h2 style="font-size: 32px; margin-bottom: 30px;">You completed Mario Educational Adventure!</h2>
|
|
<div style="font-size: 20px; line-height: 2;">
|
|
<p>🏆 Final Score: ${this._score}</p>
|
|
<p>📚 Questions Answered: ${this._questionsAnswered}</p>
|
|
<p>⏰ Time Played: ${playTime} seconds</p>
|
|
<p>🎮 Levels Completed: ${this._config.maxLevels}</p>
|
|
</div>
|
|
`;
|
|
|
|
const exitButton = document.createElement('button');
|
|
exitButton.textContent = 'Return to Games';
|
|
exitButton.style.cssText = `
|
|
background: #FF6B6B;
|
|
color: white;
|
|
border: none;
|
|
padding: 15px 30px;
|
|
border-radius: 25px;
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
margin-top: 30px;
|
|
`;
|
|
|
|
exitButton.addEventListener('click', () => {
|
|
document.body.removeChild(overlay);
|
|
// Try to go back to game selection or reload page
|
|
if (window.history.length > 1) {
|
|
window.history.back();
|
|
} else {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
|
|
content.appendChild(exitButton);
|
|
overlay.appendChild(content);
|
|
document.body.appendChild(overlay);
|
|
}
|
|
|
|
_restartLevel() {
|
|
// Reset Mario to start position
|
|
const level = this._levelData[this._currentLevelIndex];
|
|
this._mario.x = level.startX;
|
|
this._mario.y = level.startY;
|
|
this._mario.velocityX = 0;
|
|
this._mario.velocityY = 0;
|
|
this._mario.onGround = false;
|
|
|
|
// Reset camera
|
|
this._camera.x = 0;
|
|
this._camera.y = 0;
|
|
|
|
// Subtract score penalty for death
|
|
const penalty = 250;
|
|
this._score = Math.max(0, this._score - penalty);
|
|
soundSystem.play('death');
|
|
console.log(`💀 Mario died! Score penalty: -${penalty}. New score: ${this._score}`);
|
|
}
|
|
|
|
_shuffleArray(array) {
|
|
const shuffled = [...array];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
return shuffled;
|
|
}
|
|
|
|
_getRandomSentence() {
|
|
if (this._sentences.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Get a sentence that hasn't been used yet, or recycle if all used
|
|
const availableSentences = this._sentences.filter(
|
|
s => !this._usedSentences.includes(s)
|
|
);
|
|
|
|
if (availableSentences.length === 0) {
|
|
// All sentences used, reset the used list
|
|
this._usedSentences = [];
|
|
return this._sentences[Math.floor(Math.random() * this._sentences.length)];
|
|
}
|
|
|
|
const randomSentence = availableSentences[Math.floor(Math.random() * availableSentences.length)];
|
|
this._usedSentences.push(randomSentence);
|
|
return randomSentence;
|
|
}
|
|
|
|
_updateFlyingEyes() {
|
|
FlyingEye.update(this._flyingEyes, this._mario, () => this._restartLevel());
|
|
}
|
|
|
|
_updateBoss() {
|
|
Boss.update(this._boss, this._bossTurrets, this._bossMinions, this._mario, this._projectiles, (sound) => soundSystem.play(sound));
|
|
}
|
|
|
|
_render() {
|
|
// Build game state object for renderer
|
|
const gameState = {
|
|
mario: this._mario,
|
|
camera: this._camera,
|
|
platforms: this._platforms,
|
|
questionBlocks: this._questionBlocks,
|
|
enemies: this._enemies,
|
|
walls: this._walls,
|
|
piranhaPlants: this._piranhaPlants,
|
|
projectiles: this._projectiles,
|
|
catapults: this._catapults,
|
|
boulders: this._boulders,
|
|
stones: this._stones,
|
|
flyingEyes: this._flyingEyes,
|
|
boss: this._boss,
|
|
castleStructure: this._castleStructure,
|
|
finishLine: this._finishLine,
|
|
particles: this._particles,
|
|
currentLevel: this._currentLevel,
|
|
lives: this._lives,
|
|
score: this._score,
|
|
debugMode: this._debugMode // Toggle with 'D' key during gameplay
|
|
};
|
|
|
|
// Delegate rendering to helper
|
|
renderer.render(this._ctx, gameState, this._config);
|
|
|
|
// Draw mouse target indicator if mouse is pressed
|
|
if (this._mousePressed && this._mouseWorldPos.x !== null) {
|
|
this._drawMouseTarget();
|
|
}
|
|
}
|
|
|
|
_drawMouseTarget() {
|
|
const ctx = this._ctx;
|
|
const targetX = this._mouseWorldPos.x - this._camera.x;
|
|
const targetY = this._mouseWorldPos.y;
|
|
|
|
// Draw a crosshair at the target position
|
|
ctx.save();
|
|
ctx.strokeStyle = '#FFD700';
|
|
ctx.lineWidth = 2;
|
|
ctx.globalAlpha = 0.8;
|
|
|
|
// Draw circle
|
|
ctx.beginPath();
|
|
ctx.arc(targetX, targetY, 15, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Draw crosshair lines
|
|
ctx.beginPath();
|
|
ctx.moveTo(targetX - 20, targetY);
|
|
ctx.lineTo(targetX + 20, targetY);
|
|
ctx.moveTo(targetX, targetY - 20);
|
|
ctx.lineTo(targetX, targetY + 20);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
|
* @private
|
|
*/
|
|
_getVoices() {
|
|
return new Promise((resolve) => {
|
|
let voices = window.speechSynthesis.getVoices();
|
|
|
|
// If voices are already loaded, return them immediately
|
|
if (voices.length > 0) {
|
|
resolve(voices);
|
|
return;
|
|
}
|
|
|
|
// Otherwise, wait for voiceschanged event
|
|
const voicesChangedHandler = () => {
|
|
voices = window.speechSynthesis.getVoices();
|
|
if (voices.length > 0) {
|
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
|
resolve(voices);
|
|
}
|
|
};
|
|
|
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
|
|
|
// Fallback timeout in case voices never load
|
|
setTimeout(() => {
|
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
|
resolve(window.speechSynthesis.getVoices());
|
|
}, 1000);
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
export default MarioEducational; |