- Add AIReportSystem.js for detailed AI response capture and report generation - Add AIReportInterface.js UI component for report access and export - Integrate AI reporting into LLMValidator and SmartPreviewOrchestrator - Add missing modules to Application.js configuration (unifiedDRS, smartPreviewOrchestrator) - Create missing content/chapters/sbs.json for book metadata - Enhance Application.js with debug logging for module loading - Add multi-format export capabilities (text, HTML, JSON) - Implement automatic learning insights extraction from AI feedback - Add session management and performance tracking for AI reports 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
3902 lines
154 KiB
JavaScript
3902 lines
154 KiB
JavaScript
import Module from '../core/Module.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;
|
||
|
||
// 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;
|
||
|
||
// UI elements (need to be declared before seal)
|
||
this._uiOverlay = null;
|
||
|
||
// Sound system
|
||
this._audioContext = null;
|
||
this._sounds = {};
|
||
|
||
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 vocab = content?.vocabulary || {};
|
||
const texts = content?.texts || [];
|
||
const story = content?.story || '';
|
||
|
||
const vocabCount = Object.keys(vocab).length;
|
||
const sentenceCount = sentences.length;
|
||
|
||
// Count sentences from texts and story
|
||
let extraSentenceCount = 0;
|
||
|
||
if (story && typeof story === 'string') {
|
||
extraSentenceCount += story.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
||
}
|
||
|
||
if (Array.isArray(texts)) {
|
||
texts.forEach(text => {
|
||
if (typeof text === 'string') {
|
||
extraSentenceCount += text.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
||
} else if (text.content) {
|
||
extraSentenceCount += text.content.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
||
}
|
||
});
|
||
}
|
||
|
||
const totalSentences = sentenceCount + extraSentenceCount;
|
||
|
||
if (totalSentences < 3 && vocabCount < 10) {
|
||
return {
|
||
score: 0,
|
||
reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`,
|
||
requirements: ['sentences', 'vocabulary', 'texts', 'story'],
|
||
minSentences: 3,
|
||
minVocabulary: 10,
|
||
details: 'Mario Educational needs at least 3 sentences OR 10+ vocabulary words'
|
||
};
|
||
}
|
||
|
||
if (vocabCount < 5) {
|
||
return {
|
||
score: 0.3,
|
||
reason: `Limited vocabulary (${vocabCount}/5 minimum)`,
|
||
requirements: ['sentences', 'vocabulary'],
|
||
minWords: 5,
|
||
details: 'Game can work with sentences but vocabulary enhances learning'
|
||
};
|
||
}
|
||
|
||
// Perfect score at 30+ sentences, good score for 10+
|
||
const score = Math.min((totalSentences + vocabCount) / 50, 1);
|
||
|
||
return {
|
||
score,
|
||
reason: `${totalSentences} sentences and ${vocabCount} vocabulary words available`,
|
||
requirements: ['sentences', 'vocabulary', 'texts', 'story'],
|
||
minSentences: 3,
|
||
optimalSentences: 30,
|
||
details: `Can create ${Math.min(totalSentences + vocabCount, 50)} question blocks from all content 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
|
||
this._initializeSoundSystem();
|
||
|
||
// Generate all levels
|
||
this._generateAllLevels();
|
||
|
||
// Start first level
|
||
this._startLevel(5); // Start at level 6 (index 5) to continue boss work
|
||
|
||
// 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);
|
||
|
||
// 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 vocab = this._content.vocabulary || {};
|
||
const texts = this._content.texts || [];
|
||
const story = this._content.story || '';
|
||
|
||
// Combine sentences and vocabulary for questions
|
||
this._sentences = [];
|
||
|
||
// Add actual sentences - handle both formats
|
||
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'}`
|
||
});
|
||
}
|
||
});
|
||
|
||
// Extract sentences from story text
|
||
if (story && typeof story === 'string') {
|
||
const storySentences = this._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 = this._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 = this._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}`
|
||
});
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Add vocabulary as contextual sentences
|
||
Object.entries(vocab).forEach(([word, data]) => {
|
||
if (data.user_language) {
|
||
const generatedSentence = this._generateSentenceFromWord(word, data);
|
||
this._sentences.push({
|
||
type: 'vocabulary',
|
||
english: generatedSentence.english,
|
||
translation: generatedSentence.translation,
|
||
context: data.type || 'vocabulary'
|
||
});
|
||
}
|
||
});
|
||
|
||
// Shuffle sentences for variety
|
||
this._sentences = this._shuffleArray(this._sentences);
|
||
|
||
console.log(`📝 Extracted ${this._sentences.length} sentences/vocabulary for questions`);
|
||
}
|
||
|
||
/**
|
||
* Generate contextual sentences from vocabulary words
|
||
* @param {string} word - The vocabulary word
|
||
* @param {Object} data - Word data including type and translation
|
||
* @returns {Object} Generated sentence with English and translation
|
||
*/
|
||
/**
|
||
* Split long text into individual sentences
|
||
* @param {string} text - Long text to split
|
||
* @returns {Array} Array of sentences
|
||
*/
|
||
_splitTextIntoSentences(text) {
|
||
if (!text || typeof text !== 'string') return [];
|
||
|
||
// Clean the text
|
||
const cleanText = text.trim();
|
||
|
||
// Split by sentence-ending punctuation
|
||
const sentences = cleanText
|
||
.split(/[.!?]+/)
|
||
.map(s => s.trim())
|
||
.filter(s => s.length > 0)
|
||
.map(s => {
|
||
// Add period if sentence doesn't end with punctuation
|
||
if (!s.match(/[.!?]$/)) {
|
||
s += '.';
|
||
}
|
||
// Capitalize first letter
|
||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||
});
|
||
|
||
// Filter out very short sentences (less than 3 words)
|
||
return sentences.filter(sentence => sentence.split(' ').length >= 3);
|
||
}
|
||
|
||
_generateSentenceFromWord(word, data) {
|
||
const type = data.type || 'noun';
|
||
const translation = data.user_language.split(';')[0]; // Use first translation
|
||
|
||
// Simple sentence templates based on word type
|
||
const templates = {
|
||
'noun': [
|
||
`This is a ${word}.`,
|
||
`I see a ${word}.`,
|
||
`The ${word} is here.`,
|
||
`Where is the ${word}?`,
|
||
`I need a ${word}.`
|
||
],
|
||
'adjective': [
|
||
`The house is ${word}.`,
|
||
`This looks ${word}.`,
|
||
`It seems ${word}.`,
|
||
`How ${word} it is!`,
|
||
`The weather is ${word}.`
|
||
],
|
||
'verb': [
|
||
`I ${word} every day.`,
|
||
`Please ${word} this.`,
|
||
`Don't ${word} too fast.`,
|
||
`Can you ${word}?`,
|
||
`Let's ${word} together.`
|
||
],
|
||
'adverb': [
|
||
`He walks ${word}.`,
|
||
`She speaks ${word}.`,
|
||
`They work ${word}.`,
|
||
`Do it ${word}.`,
|
||
`Move ${word}.`
|
||
],
|
||
'preposition': [
|
||
`The book is ${word} the table.`,
|
||
`Walk ${word} the street.`,
|
||
`It's ${word} the house.`,
|
||
`Look ${word} the window.`,
|
||
`Go ${word} the door.`
|
||
]
|
||
};
|
||
|
||
// Get templates for this word type, fallback to noun if type unknown
|
||
const typeTemplates = templates[type] || templates['noun'];
|
||
const randomTemplate = typeTemplates[Math.floor(Math.random() * typeTemplates.length)];
|
||
|
||
return {
|
||
english: randomTemplate,
|
||
translation: `${translation} - ${randomTemplate.replace(word, `**${word}**`)}`
|
||
};
|
||
}
|
||
|
||
_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;
|
||
|
||
// Prevent default for game controls
|
||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'].includes(e.code)) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
this._handleKeyUp = (e) => {
|
||
this._keys[e.code] = false;
|
||
};
|
||
|
||
document.addEventListener('keydown', this._handleKeyDown);
|
||
document.addEventListener('keyup', this._handleKeyUp);
|
||
}
|
||
|
||
_initializeSoundSystem() {
|
||
try {
|
||
// Initialize Web Audio Context
|
||
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
console.log('🔊 Sound system initialized');
|
||
|
||
// Create sound library
|
||
this._createSoundLibrary();
|
||
} catch (error) {
|
||
console.warn('⚠️ Sound system not available:', error);
|
||
this._audioContext = null;
|
||
}
|
||
}
|
||
|
||
_createSoundLibrary() {
|
||
// Sound definitions with parameters for programmatic generation
|
||
this._sounds = {
|
||
jump: { type: 'sweep', frequency: 330, endFrequency: 600, duration: 0.1 },
|
||
coin: { type: 'bell', frequency: 800, duration: 0.3 },
|
||
powerup: { type: 'arpeggio', frequencies: [264, 330, 396, 528], duration: 0.6 },
|
||
enemy_defeat: { type: 'noise_sweep', frequency: 200, endFrequency: 50, duration: 0.2 },
|
||
question_block: { type: 'sparkle', frequency: 600, endFrequency: 1200, duration: 0.4 },
|
||
level_complete: { type: 'victory', frequencies: [523, 659, 784, 1047], duration: 1.0 },
|
||
death: { type: 'descend', frequency: 300, endFrequency: 100, duration: 0.8 },
|
||
finish_stars: { type: 'magical', frequencies: [880, 1100, 1320, 1760], duration: 2.0 }
|
||
};
|
||
|
||
console.log('🎵 Sound library created with', Object.keys(this._sounds).length, 'sounds');
|
||
}
|
||
|
||
_playSound(soundName, volume = 0.3) {
|
||
if (!this._audioContext || !this._sounds[soundName]) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const sound = this._sounds[soundName];
|
||
const oscillator = this._audioContext.createOscillator();
|
||
const gainNode = this._audioContext.createGain();
|
||
|
||
oscillator.connect(gainNode);
|
||
gainNode.connect(this._audioContext.destination);
|
||
|
||
const currentTime = this._audioContext.currentTime;
|
||
const duration = sound.duration;
|
||
|
||
// Set volume
|
||
gainNode.gain.setValueAtTime(0, currentTime);
|
||
gainNode.gain.linearRampToValueAtTime(volume, currentTime + 0.01);
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, currentTime + duration);
|
||
|
||
// Configure sound based on type
|
||
switch (sound.type) {
|
||
case 'sweep':
|
||
oscillator.type = 'square';
|
||
oscillator.frequency.setValueAtTime(sound.frequency, currentTime);
|
||
oscillator.frequency.linearRampToValueAtTime(sound.endFrequency, currentTime + duration);
|
||
break;
|
||
|
||
case 'bell':
|
||
oscillator.type = 'sine';
|
||
oscillator.frequency.setValueAtTime(sound.frequency, currentTime);
|
||
oscillator.frequency.exponentialRampToValueAtTime(sound.frequency * 0.5, currentTime + duration);
|
||
break;
|
||
|
||
case 'noise_sweep':
|
||
oscillator.type = 'sawtooth';
|
||
oscillator.frequency.setValueAtTime(sound.frequency, currentTime);
|
||
oscillator.frequency.linearRampToValueAtTime(sound.endFrequency, currentTime + duration);
|
||
break;
|
||
|
||
case 'sparkle':
|
||
oscillator.type = 'triangle';
|
||
oscillator.frequency.setValueAtTime(sound.frequency, currentTime);
|
||
oscillator.frequency.linearRampToValueAtTime(sound.endFrequency, currentTime + duration * 0.7);
|
||
oscillator.frequency.linearRampToValueAtTime(sound.frequency, currentTime + duration);
|
||
break;
|
||
|
||
case 'descend':
|
||
oscillator.type = 'square';
|
||
oscillator.frequency.setValueAtTime(sound.frequency, currentTime);
|
||
oscillator.frequency.exponentialRampToValueAtTime(sound.endFrequency, currentTime + duration);
|
||
break;
|
||
|
||
case 'arpeggio':
|
||
case 'victory':
|
||
case 'magical':
|
||
// For complex sounds, play the first frequency and schedule others
|
||
oscillator.type = sound.type === 'magical' ? 'triangle' : 'square';
|
||
oscillator.frequency.setValueAtTime(sound.frequencies[0], currentTime);
|
||
|
||
// Schedule frequency changes for arpeggio effect
|
||
const noteLength = duration / sound.frequencies.length;
|
||
sound.frequencies.forEach((freq, index) => {
|
||
if (index > 0) {
|
||
oscillator.frequency.setValueAtTime(freq, currentTime + noteLength * index);
|
||
}
|
||
});
|
||
break;
|
||
|
||
default:
|
||
oscillator.type = 'square';
|
||
oscillator.frequency.setValueAtTime(sound.frequency || 440, currentTime);
|
||
}
|
||
|
||
oscillator.start(currentTime);
|
||
oscillator.stop(currentTime + duration);
|
||
|
||
console.log(`🎵 Playing sound: ${soundName}`);
|
||
|
||
} catch (error) {
|
||
console.warn('⚠️ Failed to play sound:', soundName, error);
|
||
}
|
||
}
|
||
|
||
_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);
|
||
|
||
// 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;
|
||
|
||
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
|
||
});
|
||
|
||
// Check if enemy has solid ground/platform/stair support below
|
||
const hasSolidSupport = this._hasSolidSupportBelow(enemyX, enemyY + 20, level); // Check below enemy position
|
||
|
||
if (!wouldOverlapWall && !wouldOverlapHole && hasSolidSupport) {
|
||
// Level 2+ gets exactly ONE helmet enemy per level
|
||
const isHelmetEnemy = index >= 1 && !helmetEnemyPlaced && i === 0; // Only first enemy can be helmet
|
||
|
||
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;
|
||
} else {
|
||
console.log(`🚫 Enemy ${i} attempt ${attempts} would overlap wall/hole/unsupported ground, retrying...`);
|
||
}
|
||
attempts++;
|
||
}
|
||
|
||
if (!enemyPlaced) {
|
||
console.log(`⚠️ Failed to place enemy ${i} after ${maxAttempts} attempts`);
|
||
}
|
||
}
|
||
|
||
// Level 3+ gets advanced features (except level 6 boss level)
|
||
if (index >= 2 && index <= 4) {
|
||
this._generateHoles(level, difficulty);
|
||
this._generateStairs(level, difficulty);
|
||
this._generatePiranhaPlants(level, difficulty);
|
||
}
|
||
|
||
// Level 6 boss level: only stairs (no holes, limited piranha plants)
|
||
if (index === 5) {
|
||
this._generateBossStairs(level, difficulty);
|
||
// Reduced piranha plants for boss level
|
||
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
|
||
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) {
|
||
const plantCount = Math.min(difficulty - 2, 2); // 0-2 plants for level 3+
|
||
|
||
for (let i = 0; i < plantCount; i++) {
|
||
// Find a suitable ground platform for the plant
|
||
const groundPlatforms = level.platforms.filter(p => p.type === 'ground');
|
||
if (groundPlatforms.length === 0) continue;
|
||
|
||
const platform = groundPlatforms[Math.floor(Math.random() * groundPlatforms.length)];
|
||
const plantX = platform.x + Math.random() * (platform.width - 30);
|
||
|
||
level.piranhaPlants = level.piranhaPlants || [];
|
||
level.piranhaPlants.push({
|
||
x: plantX,
|
||
y: platform.y - 40, // Plant height
|
||
width: 30,
|
||
height: 40,
|
||
color: '#228B22', // Forest green
|
||
lastShot: 0,
|
||
shootCooldown: 2000 + Math.random() * 1000, // 2-3 second intervals
|
||
type: 'piranha'
|
||
});
|
||
|
||
console.log(`🌸 Piranha plant placed at x=${plantX.toFixed(0)}`);
|
||
}
|
||
}
|
||
|
||
_generateCatapults(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;
|
||
|
||
// 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() {
|
||
// Don't update movement during celebration
|
||
if (this._isCelebrating) return;
|
||
|
||
// Horizontal movement
|
||
if (this._keys['ArrowLeft'] || this._keys['KeyA']) {
|
||
this._mario.velocityX = -this._config.moveSpeed;
|
||
this._mario.facing = 'left';
|
||
} else if (this._keys['ArrowRight'] || this._keys['KeyD']) {
|
||
this._mario.velocityX = this._config.moveSpeed;
|
||
this._mario.facing = 'right';
|
||
} else {
|
||
this._mario.velocityX *= 0.8; // Friction
|
||
}
|
||
|
||
// Jumping
|
||
if ((this._keys['ArrowUp'] || this._keys['KeyW'] || this._keys['Space']) && this._mario.onGround) {
|
||
this._mario.velocityY = this._config.jumpForce;
|
||
this._mario.onGround = false;
|
||
this._playSound('jump');
|
||
}
|
||
}
|
||
|
||
_updateMarioPhysics() {
|
||
// Apply gravity
|
||
this._mario.velocityY += this._config.gravity;
|
||
|
||
// Update position
|
||
this._mario.x += this._mario.velocityX;
|
||
this._mario.y += this._mario.velocityY;
|
||
|
||
// Prevent going off left edge
|
||
if (this._mario.x < 0) {
|
||
this._mario.x = 0;
|
||
}
|
||
|
||
// Stop Mario at finish line during celebration
|
||
const level = this._levelData[this._currentLevelIndex];
|
||
if (this._mario.x > level.endX && this._levelCompleted) {
|
||
this._mario.x = level.endX;
|
||
this._mario.velocityX = 0;
|
||
}
|
||
|
||
// Check if Mario fell off the world
|
||
if (this._mario.y > this._config.canvasHeight + 100) {
|
||
this._restartLevel();
|
||
}
|
||
}
|
||
|
||
_updateEnemies() {
|
||
// Don't update enemies during celebration
|
||
if (this._isCelebrating) return;
|
||
|
||
this._enemies.forEach(enemy => {
|
||
// Store old position for collision detection
|
||
const oldX = enemy.x;
|
||
enemy.x += enemy.velocityX;
|
||
|
||
// Check wall collisions
|
||
const hitWall = this._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 = this._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 >= this._levelWidth) {
|
||
enemy.velocityX *= -1;
|
||
}
|
||
});
|
||
}
|
||
|
||
_checkCollisions() {
|
||
// Platform collisions
|
||
this._mario.onGround = false;
|
||
|
||
this._platforms.forEach(platform => {
|
||
if (this._isColliding(this._mario, platform)) {
|
||
// Calculate overlap amounts to determine collision direction
|
||
const overlapLeft = (this._mario.x + this._mario.width) - platform.x;
|
||
const overlapRight = (platform.x + platform.width) - this._mario.x;
|
||
const overlapTop = (this._mario.y + this._mario.height) - platform.y;
|
||
const overlapBottom = (platform.y + platform.height) - this._mario.y;
|
||
|
||
// Find the smallest overlap to determine collision side
|
||
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
|
||
|
||
if (minOverlap === overlapTop && this._mario.velocityY > 0) {
|
||
// Landing on top of platform
|
||
this._mario.y = platform.y - this._mario.height;
|
||
this._mario.velocityY = 0;
|
||
this._mario.onGround = true;
|
||
}
|
||
else if (minOverlap === overlapBottom && this._mario.velocityY < 0) {
|
||
// Hitting platform from below
|
||
this._mario.y = platform.y + platform.height;
|
||
this._mario.velocityY = 0;
|
||
}
|
||
else if (minOverlap === overlapLeft && this._mario.velocityX > 0) {
|
||
// Hitting platform from left
|
||
this._mario.x = platform.x - this._mario.width;
|
||
this._mario.velocityX = 0;
|
||
}
|
||
else if (minOverlap === overlapRight && this._mario.velocityX < 0) {
|
||
// Hitting platform from right
|
||
this._mario.x = platform.x + platform.width;
|
||
this._mario.velocityX = 0;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Boulder platform collisions (landed boulders act as platforms)
|
||
this._boulders.forEach(boulder => {
|
||
if (boulder.hasLanded && this._isColliding(this._mario, boulder)) {
|
||
// Same collision logic as platforms
|
||
const overlapLeft = (this._mario.x + this._mario.width) - boulder.x;
|
||
const overlapRight = (boulder.x + boulder.width) - this._mario.x;
|
||
const overlapTop = (this._mario.y + this._mario.height) - boulder.y;
|
||
const overlapBottom = (boulder.y + boulder.height) - this._mario.y;
|
||
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
|
||
|
||
if (minOverlap === overlapTop && this._mario.velocityY > 0) {
|
||
// Landing on top of boulder
|
||
this._mario.y = boulder.y - this._mario.height;
|
||
this._mario.velocityY = 0;
|
||
this._mario.onGround = true;
|
||
}
|
||
else if (minOverlap === overlapBottom && this._mario.velocityY < 0) {
|
||
// Hitting boulder from below
|
||
this._mario.y = boulder.y + boulder.height;
|
||
this._mario.velocityY = 0;
|
||
}
|
||
else if (minOverlap === overlapLeft && this._mario.velocityX > 0) {
|
||
// Hitting boulder from left
|
||
this._mario.x = boulder.x - this._mario.width;
|
||
this._mario.velocityX = 0;
|
||
}
|
||
else if (minOverlap === overlapRight && this._mario.velocityX < 0) {
|
||
// Hitting boulder from right
|
||
this._mario.x = boulder.x + boulder.width;
|
||
this._mario.velocityX = 0;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Question block collisions
|
||
this._questionBlocks.forEach(block => {
|
||
if (!block.hit && this._isColliding(this._mario, block)) {
|
||
// Hit from below (jumping into block)
|
||
if (this._mario.velocityY < 0 &&
|
||
this._mario.y > block.y + block.height / 2) {
|
||
this._hitQuestionBlock(block);
|
||
}
|
||
// Touch from side or top
|
||
else {
|
||
this._hitQuestionBlock(block);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Wall collisions
|
||
this._walls.forEach(wall => {
|
||
if (this._isColliding(this._mario, wall)) {
|
||
// Calculate overlap amounts to determine collision direction
|
||
const overlapLeft = (this._mario.x + this._mario.width) - wall.x;
|
||
const overlapRight = (wall.x + wall.width) - this._mario.x;
|
||
const overlapTop = (this._mario.y + this._mario.height) - wall.y;
|
||
const overlapBottom = (wall.y + wall.height) - this._mario.y;
|
||
|
||
// Find the smallest overlap to determine collision side
|
||
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
|
||
|
||
if (minOverlap === overlapTop && this._mario.velocityY > 0) {
|
||
// Landing on top of wall
|
||
this._mario.y = wall.y - this._mario.height;
|
||
this._mario.velocityY = 0;
|
||
this._mario.onGround = true;
|
||
}
|
||
else if (minOverlap === overlapBottom && this._mario.velocityY < 0) {
|
||
// Hitting wall from below
|
||
this._mario.y = wall.y + wall.height;
|
||
this._mario.velocityY = 0;
|
||
}
|
||
else if (minOverlap === overlapLeft && this._mario.velocityX > 0) {
|
||
// Hitting wall from left
|
||
this._mario.x = wall.x - this._mario.width;
|
||
this._mario.velocityX = 0;
|
||
}
|
||
else if (minOverlap === overlapRight && this._mario.velocityX < 0) {
|
||
// Hitting wall from right
|
||
this._mario.x = wall.x + wall.width;
|
||
this._mario.velocityX = 0;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Catapult collisions (Mario destroys catapults!)
|
||
this._catapults.forEach((catapult, index) => {
|
||
if (this._isColliding(this._mario, catapult)) {
|
||
console.log(`💥 Mario destroyed catapult! Explosion!`);
|
||
|
||
// Create massive explosion effect at catapult center
|
||
const explosionX = catapult.x + catapult.width / 2;
|
||
const explosionY = catapult.y + catapult.height / 2;
|
||
|
||
// Generate lots of particles flying in all directions
|
||
for (let i = 0; i < 25; i++) {
|
||
const angle = (Math.PI * 2 * i) / 25; // Spread particles in circle
|
||
const speed = 3 + Math.random() * 4; // Random speed 3-7
|
||
const particleColor = ['#8B4513', '#654321', '#D2691E', '#FF4500', '#FFD700'][Math.floor(Math.random() * 5)];
|
||
|
||
this._particles.push({
|
||
x: explosionX,
|
||
y: explosionY,
|
||
velocityX: Math.cos(angle) * speed,
|
||
velocityY: Math.sin(angle) * speed,
|
||
color: particleColor,
|
||
life: 60 + Math.random() * 30, // Live 60-90 frames
|
||
maxLife: 60 + Math.random() * 30,
|
||
size: 3 + Math.random() * 4 // Size 3-7
|
||
});
|
||
}
|
||
|
||
// Remove the destroyed catapult
|
||
this._catapults.splice(index, 1);
|
||
|
||
// Play destruction sound and bounce Mario up slightly
|
||
this._playSound('enemy_defeat');
|
||
this._mario.velocityY = this._config.jumpForce * 0.5; // Small bounce from impact
|
||
|
||
console.log(`🏹 Catapult destroyed! ${this._catapults.length} catapults remaining.`);
|
||
}
|
||
});
|
||
|
||
// Enemy collisions
|
||
this._enemies.forEach((enemy, index) => {
|
||
if (this._isColliding(this._mario, enemy)) {
|
||
// Calculate overlap to determine if Mario is stomping enemy
|
||
const overlapTop = (this._mario.y + this._mario.height) - enemy.y;
|
||
const overlapBottom = (enemy.y + enemy.height) - this._mario.y;
|
||
|
||
// Stomp enemy if Mario is coming from above
|
||
if (this._mario.velocityY > 0 && overlapTop < overlapBottom) {
|
||
if (enemy.hasHelmet) {
|
||
// Can't stomp helmet enemies! Mario bounces off
|
||
this._mario.velocityY = this._config.jumpForce * 0.7; // Big bounce back
|
||
this._addParticles(enemy.x, enemy.y, '#C0C0C0'); // Silver particles
|
||
this._playSound('jump'); // Bounce sound
|
||
console.log(`🛡️ Mario bounced off helmet enemy!`);
|
||
} else {
|
||
// Normal enemy - can be stomped
|
||
this._enemies.splice(index, 1);
|
||
this._mario.velocityY = this._config.jumpForce / 2; // Small bounce
|
||
// NO SCORE for killing enemies anymore!
|
||
this._addParticles(enemy.x, enemy.y, '#FFD700');
|
||
this._playSound('enemy_defeat');
|
||
console.log(`🦶 Mario stomped normal enemy! (No score)`);
|
||
}
|
||
} else {
|
||
// Mario gets hurt by side/bottom collision
|
||
console.log(`💥 Mario hit by enemy - restarting level`);
|
||
this._restartLevel();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Check piranha plant collisions (skip flattened plants)
|
||
this._piranhaPlants.forEach((plant, index) => {
|
||
if (!plant.flattened && this._isColliding(this._mario, plant)) {
|
||
// Calculate overlap to determine if Mario is stomping plant
|
||
const overlapTop = (this._mario.y + this._mario.height) - plant.y;
|
||
const overlapBottom = (plant.y + plant.height) - this._mario.y;
|
||
|
||
// Stomp plant if Mario is coming from above
|
||
if (this._mario.velocityY > 0 && overlapTop < overlapBottom) {
|
||
// Flatten the plant instead of removing it
|
||
plant.flattened = true;
|
||
plant.height = 5; // Very flat
|
||
plant.y += 35; // Move to ground level (original height was 40, new is 5)
|
||
plant.shootCooldown = Infinity; // Stop shooting
|
||
this._mario.velocityY = this._config.jumpForce / 2; // Small bounce
|
||
this._addParticles(plant.x, plant.y, '#228B22'); // Green particles
|
||
this._playSound('enemy_defeat');
|
||
console.log(`🌸 Mario flattened piranha plant!`);
|
||
} else {
|
||
// Mario gets hurt by side collision
|
||
console.log(`💥 Mario hit by piranha plant - restarting level`);
|
||
this._restartLevel();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Check walking on flattened plants (for particles)
|
||
this._piranhaPlants.forEach((plant, index) => {
|
||
if (plant.flattened && this._isColliding(this._mario, plant)) {
|
||
// Mario is standing on a flattened plant - add particles occasionally
|
||
if (Math.random() < 0.1) { // 10% chance per frame for particles
|
||
this._addSmallParticles(plant.x + Math.random() * plant.width, plant.y, '#8B4513'); // Brown dust particles
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
_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;
|
||
}
|
||
|
||
_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');
|
||
this._playSound('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);
|
||
}
|
||
|
||
_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;
|
||
|
||
// Try to use a nice English voice
|
||
const voices = speechSynthesis.getVoices();
|
||
const englishVoice = voices.find(voice =>
|
||
voice.lang.startsWith('en') && (voice.name.includes('Female') || voice.name.includes('Google'))
|
||
) || voices.find(voice => voice.lang.startsWith('en'));
|
||
|
||
if (englishVoice) {
|
||
utterance.voice = englishVoice;
|
||
console.log(`🎤 Using voice: ${englishVoice.name}`);
|
||
}
|
||
|
||
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() {
|
||
const currentTime = Date.now();
|
||
|
||
this._piranhaPlants.forEach(plant => {
|
||
// Check if it's time to shoot
|
||
if (currentTime - plant.lastShot > plant.shootCooldown) {
|
||
// Check if Mario is in range (within 400 pixels)
|
||
const distanceToMario = Math.abs(plant.x - this._mario.x);
|
||
|
||
if (distanceToMario < 400) {
|
||
// Shoot projectile towards Mario
|
||
const direction = this._mario.x > plant.x ? 1 : -1;
|
||
|
||
this._projectiles.push({
|
||
x: plant.x + plant.width / 2,
|
||
y: plant.y + plant.height / 2,
|
||
velocityX: direction * 3, // Projectile speed
|
||
velocityY: 0,
|
||
width: 8,
|
||
height: 8,
|
||
color: '#FF4500', // Orange fireball
|
||
type: 'fireball',
|
||
life: 200 // 200 frames lifetime
|
||
});
|
||
|
||
plant.lastShot = currentTime;
|
||
this._playSound('enemy_defeat'); // Shooting sound
|
||
console.log(`🔥 Piranha plant shot fireball towards Mario!`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
_updateProjectiles() {
|
||
// Update projectile positions
|
||
this._projectiles.forEach((projectile, index) => {
|
||
projectile.x += projectile.velocityX;
|
||
projectile.y += projectile.velocityY;
|
||
projectile.life--;
|
||
|
||
// Remove projectiles that are off-screen or expired
|
||
if (projectile.life <= 0 || projectile.x < -50 || projectile.x > this._levelWidth + 50) {
|
||
this._projectiles.splice(index, 1);
|
||
return;
|
||
}
|
||
|
||
// Check collision with Mario
|
||
if (this._isColliding(this._mario, projectile)) {
|
||
console.log(`🔥 Mario hit by projectile - restarting level`);
|
||
this._restartLevel();
|
||
return;
|
||
}
|
||
|
||
// Check collision with walls/platforms
|
||
const hitObstacle = this._platforms.some(platform => this._isColliding(projectile, platform)) ||
|
||
this._walls.some(wall => this._isColliding(projectile, wall));
|
||
|
||
if (hitObstacle) {
|
||
this._projectiles.splice(index, 1);
|
||
this._addParticles(projectile.x, projectile.y, '#FF4500');
|
||
console.log(`💥 Projectile hit obstacle`);
|
||
}
|
||
});
|
||
}
|
||
|
||
_updateCatapults() {
|
||
const currentTime = Date.now();
|
||
|
||
this._catapults.forEach(catapult => {
|
||
// Check if it's time to shoot
|
||
if (currentTime - catapult.lastShot > catapult.shootCooldown) {
|
||
// Target Mario's position with imperfect aim (randomness)
|
||
const aimOffset = 100 + Math.random() * 150; // 100-250 pixel spread
|
||
const aimDirection = Math.random() < 0.5 ? -1 : 1;
|
||
const targetX = this._mario.x + (aimOffset * aimDirection);
|
||
const targetY = this._config.canvasHeight - 50; // Ground level
|
||
|
||
// ONAGER ONLY: Check minimum range - don't fire if Mario is too close
|
||
if (catapult.isOnager) {
|
||
const distanceToMario = Math.abs(catapult.x - this._mario.x);
|
||
const minimumRange = 300; // Onager won't fire if Mario is within 300px
|
||
|
||
if (distanceToMario < minimumRange) {
|
||
console.log(`🏛️ Onager held fire - Mario too close! Distance: ${distanceToMario.toFixed(0)}px (min: ${minimumRange}px)`);
|
||
return; // Skip this shot, Mario is too close
|
||
}
|
||
}
|
||
|
||
// Check if there's a clear line of sight (no platform blocking)
|
||
if (!this._hasLineOfSight(catapult, targetX, targetY)) {
|
||
console.log(`🚫 ${catapult.type} blocked by obstacle, skipping shot`);
|
||
return; // Skip this shot, try again next time
|
||
}
|
||
|
||
if (catapult.isOnager) {
|
||
// ONAGER: Fire 8 small stones in spread pattern
|
||
console.log(`🏛️ Onager firing stone rain!`);
|
||
|
||
for (let stone = 0; stone < 8; stone++) {
|
||
// Much more random targeting for fear factor
|
||
const randomSpreadX = (Math.random() - 0.5) * 400; // ±200px spread
|
||
const randomSpreadY = (Math.random() - 0.5) * 100; // ±50px spread
|
||
const stoneTargetX = targetX + randomSpreadX;
|
||
const stoneTargetY = targetY + randomSpreadY;
|
||
|
||
// Calculate trajectory for small stone
|
||
const deltaX = stoneTargetX - catapult.x;
|
||
const deltaY = stoneTargetY - catapult.y;
|
||
const time = 5 + Math.random() * 2; // 5-7 seconds flight time (varied)
|
||
const velocityX = deltaX / (time * 60);
|
||
const velocityY = (deltaY - 0.5 * 0.015 * time * time * 60 * 60) / (time * 60); // Slightly more gravity
|
||
|
||
this._stones.push({
|
||
x: catapult.x + 30 + (Math.random() - 0.5) * 20, // Spread launch point
|
||
y: catapult.y - 10 + (Math.random() - 0.5) * 10,
|
||
width: 8, // Much smaller than boulders
|
||
height: 8,
|
||
velocityX: velocityX,
|
||
velocityY: velocityY,
|
||
color: '#A0522D', // Brown stone color
|
||
type: 'stone',
|
||
sourceCatapultX: catapult.x,
|
||
sourceCatapultY: catapult.y
|
||
});
|
||
}
|
||
|
||
catapult.lastShot = currentTime;
|
||
this._playSound('enemy_defeat'); // Different sound for stone rain
|
||
console.log(`🏛️ Onager fired 8 stones in spread pattern!`);
|
||
} else {
|
||
// CATAPULT: Fire single boulder (original behavior)
|
||
const deltaX = targetX - catapult.x;
|
||
const deltaY = targetY - catapult.y;
|
||
const time = 7.5; // 7.5 seconds flight time
|
||
const velocityX = deltaX / (time * 60);
|
||
const velocityY = (deltaY - 0.5 * 0.01 * time * time * 60 * 60) / (time * 60);
|
||
|
||
this._boulders.push({
|
||
x: catapult.x + 30,
|
||
y: catapult.y - 10,
|
||
width: 25,
|
||
height: 25,
|
||
velocityX: velocityX,
|
||
velocityY: velocityY,
|
||
color: '#696969', // Dark gray
|
||
type: 'boulder',
|
||
hasLanded: false,
|
||
sourceCatapultX: catapult.x,
|
||
sourceCatapultY: catapult.y,
|
||
health: 2,
|
||
maxHealth: 2
|
||
});
|
||
|
||
catapult.lastShot = currentTime;
|
||
this._playSound('jump'); // Boulder launch sound
|
||
console.log(`🏹 Catapult fired boulder towards x=${targetX.toFixed(0)}`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
_updateBoulders() {
|
||
this._boulders.forEach((boulder, index) => {
|
||
if (boulder.hasLanded) return; // Don't update landed boulders
|
||
|
||
// Apply gravity and movement (MUCH slower with very light gravity)
|
||
boulder.velocityY += 0.01; // Ultra-light gravity so boulders can actually fly properly
|
||
boulder.x += boulder.velocityX;
|
||
boulder.y += boulder.velocityY;
|
||
|
||
// Check ground collision
|
||
const groundLevel = this._config.canvasHeight - 50;
|
||
if (boulder.y + boulder.height >= groundLevel) {
|
||
this._handleBoulderImpact(boulder, index, boulder.x, groundLevel - boulder.height);
|
||
return;
|
||
}
|
||
|
||
// Check collision with platforms, walls, stairs
|
||
let hasHit = false;
|
||
|
||
// Check platforms
|
||
this._platforms.forEach((platform, platformIndex) => {
|
||
if (!hasHit && this._isColliding(boulder, platform)) {
|
||
this._handleBoulderImpact(boulder, index, boulder.x, platform.y - boulder.height, platform, platformIndex);
|
||
hasHit = true;
|
||
}
|
||
});
|
||
|
||
// Check walls (boulder damages walls)
|
||
if (!hasHit) {
|
||
this._walls.forEach((wall, wallIndex) => {
|
||
if (!hasHit && this._isColliding(boulder, wall)) {
|
||
console.log(`🪨 Boulder hit wall! Wall damage system activated.`);
|
||
|
||
// Boulder is DESTROYED when hitting wall
|
||
this._addParticles(boulder.x + boulder.width/2, boulder.y + boulder.height/2, '#696969');
|
||
this._boulders.splice(index, 1);
|
||
this._playSound('enemy_defeat');
|
||
console.log(`💥 Boulder destroyed on wall impact`);
|
||
|
||
// Wall takes damage
|
||
wall.health--;
|
||
console.log(`🧱 Wall health: ${wall.health}/${wall.maxHealth}`);
|
||
|
||
if (wall.health <= 0) {
|
||
// Wall is destroyed
|
||
this._addParticles(wall.x + wall.width/2, wall.y + wall.height/2, '#8B4513');
|
||
this._walls.splice(wallIndex, 1);
|
||
console.log(`💥 Wall destroyed!`);
|
||
} else {
|
||
// Visual damage - change color to show damage
|
||
if (wall.health === 2) {
|
||
wall.color = '#A0522D'; // Slightly damaged - darker brown
|
||
} else if (wall.health === 1) {
|
||
wall.color = '#654321'; // Heavily damaged - dark brown
|
||
}
|
||
console.log(`🩹 Wall damaged but still standing`);
|
||
}
|
||
|
||
hasHit = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Check collision with catapults (but NOT the source catapult)
|
||
if (!hasHit) {
|
||
this._catapults.forEach((catapult, catapultIndex) => {
|
||
// Don't collide with the catapult that fired this boulder
|
||
const isSameCatapult = Math.abs(boulder.sourceCatapultX - catapult.x) < 10 &&
|
||
Math.abs(boulder.sourceCatapultY - catapult.y) < 10;
|
||
|
||
if (!hasHit && !isSameCatapult && this._isColliding(boulder, catapult)) {
|
||
// Boulder hits a different catapult - land on top
|
||
this._handleBoulderImpact(boulder, index, boulder.x, catapult.y - boulder.height, catapult, catapultIndex, 'catapult');
|
||
hasHit = true;
|
||
console.log(`🪨 Boulder hit different catapult!`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Check collision with Mario (boulder resets level like enemies!)
|
||
if (!hasHit && this._isColliding(boulder, this._mario)) {
|
||
console.log(`💀 Boulder hit Mario! Respawning at level start.`);
|
||
this._addParticles(this._mario.x, this._mario.y, '#FF0000'); // Red death particles
|
||
this._playSound('enemy_defeat');
|
||
|
||
// Reset Mario to start position like other enemy deaths
|
||
const level = this._levelData[this._currentLevelIndex];
|
||
this._mario.x = level.startX;
|
||
this._mario.y = level.startY;
|
||
this._mario.velocityX = 0;
|
||
this._mario.velocityY = 0;
|
||
|
||
// Boulder lands after hitting Mario
|
||
this._handleBoulderImpact(boulder, index, boulder.x, this._config.canvasHeight - 75);
|
||
hasHit = true;
|
||
return;
|
||
}
|
||
|
||
// Check collision with OTHER boulders (boulder-to-boulder damage system)
|
||
if (!hasHit) {
|
||
this._boulders.forEach((otherBoulder, otherIndex) => {
|
||
if (!hasHit && otherIndex !== index && otherBoulder.hasLanded && this._isColliding(boulder, otherBoulder)) {
|
||
console.log(`🪨 Flying boulder hit landed boulder! Damage system activated.`);
|
||
|
||
// Flying boulder is DESTROYED (removed completely)
|
||
this._addParticles(boulder.x + boulder.width/2, boulder.y + boulder.height/2, '#696969');
|
||
this._boulders.splice(index, 1);
|
||
this._playSound('enemy_defeat');
|
||
console.log(`💥 Flying boulder destroyed on impact`);
|
||
|
||
// Landed boulder takes damage
|
||
otherBoulder.health--;
|
||
console.log(`🩹 Landed boulder health: ${otherBoulder.health}/${otherBoulder.maxHealth}`);
|
||
|
||
if (otherBoulder.health <= 0) {
|
||
// Landed boulder is destroyed
|
||
this._addParticles(otherBoulder.x + otherBoulder.width/2, otherBoulder.y + otherBoulder.height/2, '#8B4513');
|
||
this._boulders.splice(otherIndex, 1);
|
||
console.log(`💥 Landed boulder destroyed!`);
|
||
} else {
|
||
// Visual damage - change color to show damage
|
||
otherBoulder.color = otherBoulder.health === 1 ? '#A0522D' : '#696969'; // Darker when damaged
|
||
}
|
||
|
||
hasHit = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Remove boulders that go off-screen
|
||
if (boulder.x < -100 || boulder.x > this._levelWidth + 100 || boulder.y > this._config.canvasHeight + 100) {
|
||
this._boulders.splice(index, 1);
|
||
}
|
||
});
|
||
}
|
||
|
||
_handleBoulderImpact(boulder, boulderIndex, impactX, impactY, hitObject = null, hitObjectIndex = -1, hitType = 'platform') {
|
||
// 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');
|
||
this._playSound('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)}`);
|
||
}
|
||
});
|
||
|
||
// Reset Mario to level start if he's in explosion radius (50 pixels)
|
||
const marioDistance = Math.sqrt(
|
||
Math.pow(this._mario.x - (boulder.x + boulder.width/2), 2) +
|
||
Math.pow(this._mario.y - (boulder.y + boulder.height/2), 2)
|
||
);
|
||
if (marioDistance < 50) {
|
||
console.log(`💀 Mario killed by boulder explosion! Respawning at level start. Distance: ${marioDistance.toFixed(0)}`);
|
||
this._addParticles(this._mario.x, this._mario.y, '#FF0000'); // Red death particles
|
||
this._playSound('enemy_defeat');
|
||
|
||
// Reset Mario to start position like other enemy deaths
|
||
const level = this._levelData[this._currentLevelIndex];
|
||
this._mario.x = level.startX;
|
||
this._mario.y = level.startY;
|
||
this._mario.velocityX = 0;
|
||
this._mario.velocityY = 0;
|
||
}
|
||
|
||
// 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.forEach((stone, index) => {
|
||
// Apply gravity and movement (similar to boulders but simpler)
|
||
stone.velocityY += 0.015; // Slightly more gravity than boulders
|
||
stone.x += stone.velocityX;
|
||
stone.y += stone.velocityY;
|
||
|
||
// Check ground collision
|
||
const groundLevel = this._config.canvasHeight - 50;
|
||
if (stone.y + stone.height >= groundLevel) {
|
||
// Stone hits ground - create small particle effect and disappear
|
||
this._addParticles(stone.x + stone.width/2, stone.y + stone.height/2, '#A0522D');
|
||
this._stones.splice(index, 1);
|
||
return;
|
||
}
|
||
|
||
// Check collision with Mario (stones kill Mario!)
|
||
if (this._isColliding(stone, this._mario)) {
|
||
console.log(`🪨 Stone hit Mario! Respawning at level start.`);
|
||
this._addParticles(this._mario.x, this._mario.y, '#FF0000'); // Red death particles
|
||
this._playSound('enemy_defeat');
|
||
|
||
// Reset Mario to start position like other deaths
|
||
const level = this._levelData[this._currentLevelIndex];
|
||
this._mario.x = level.startX;
|
||
this._mario.y = level.startY;
|
||
this._mario.velocityX = 0;
|
||
this._mario.velocityY = 0;
|
||
|
||
// Remove the stone that hit Mario
|
||
this._stones.splice(index, 1);
|
||
return;
|
||
}
|
||
|
||
// Check collision with platforms/walls (stones disappear on impact)
|
||
let hasHit = false;
|
||
|
||
// Check platforms
|
||
this._platforms.forEach((platform) => {
|
||
if (!hasHit && this._isColliding(stone, platform)) {
|
||
this._addParticles(stone.x + stone.width/2, stone.y + stone.height/2, '#A0522D');
|
||
this._stones.splice(index, 1);
|
||
hasHit = true;
|
||
return;
|
||
}
|
||
});
|
||
|
||
// Check walls
|
||
if (!hasHit) {
|
||
this._walls.forEach((wall) => {
|
||
if (!hasHit && this._isColliding(stone, wall)) {
|
||
this._addParticles(stone.x + stone.width/2, stone.y + stone.height/2, '#A0522D');
|
||
this._stones.splice(index, 1);
|
||
hasHit = true;
|
||
return;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Remove stones that go off-screen
|
||
if (stone.x < -100 || stone.x > this._levelWidth + 100 || stone.y > this._config.canvasHeight + 100) {
|
||
this._stones.splice(index, 1);
|
||
}
|
||
});
|
||
}
|
||
|
||
_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();
|
||
this._playSound('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
|
||
this._playSound('finish_stars');
|
||
|
||
// Extra sound effect for bigger waves
|
||
if (wave >= 4) {
|
||
setTimeout(() => this._playSound('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
|
||
});
|
||
}
|
||
this._playSound('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
|
||
this._playSound('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);
|
||
this._playSound('death');
|
||
console.log(`💀 Mario died! Score penalty: -${penalty}. New score: ${this._score}`);
|
||
}
|
||
|
||
_render() {
|
||
// Clear canvas
|
||
this._ctx.clearRect(0, 0, this._config.canvasWidth, this._config.canvasHeight);
|
||
|
||
// Render background
|
||
this._renderBackground();
|
||
|
||
// Save context for camera translation
|
||
this._ctx.save();
|
||
this._ctx.translate(-this._camera.x, -this._camera.y);
|
||
|
||
// Render platforms
|
||
this._renderPlatforms();
|
||
|
||
// Render question blocks
|
||
this._renderQuestionBlocks();
|
||
|
||
// Render enemies
|
||
this._renderEnemies();
|
||
|
||
// Render walls
|
||
this._renderWalls();
|
||
|
||
// Render advanced level elements
|
||
this._renderPiranhaPlants();
|
||
this._renderProjectiles();
|
||
|
||
// Render level 4+ elements
|
||
this._renderCatapults();
|
||
this._renderBoulders();
|
||
this._renderStones();
|
||
|
||
// Render level 5+ elements
|
||
this._renderFlyingEyes();
|
||
|
||
// Render level 6 boss elements
|
||
this._renderBoss();
|
||
|
||
// Render castle (visual only)
|
||
this._renderCastle();
|
||
|
||
// Render finish line
|
||
this._renderFinishLine();
|
||
|
||
// Render Mario
|
||
this._renderMario();
|
||
|
||
// Render particles
|
||
this._renderParticles();
|
||
|
||
// Render debug hitboxes if needed
|
||
this._renderDebugHitboxes();
|
||
|
||
// Restore context
|
||
this._ctx.restore();
|
||
|
||
// Render UI
|
||
this._renderUI();
|
||
}
|
||
|
||
_renderBackground() {
|
||
// Sky gradient
|
||
const gradient = this._ctx.createLinearGradient(0, 0, 0, this._config.canvasHeight);
|
||
gradient.addColorStop(0, '#87CEEB');
|
||
gradient.addColorStop(1, '#98FB98');
|
||
this._ctx.fillStyle = gradient;
|
||
this._ctx.fillRect(0, 0, this._config.canvasWidth, this._config.canvasHeight);
|
||
|
||
// Clouds (fixed position, don't scroll with camera)
|
||
this._ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||
for (let i = 0; i < 5; i++) {
|
||
const x = (i * 300 + 100 - this._camera.x * 0.3) % (this._config.canvasWidth + 200);
|
||
const y = 80 + i * 30;
|
||
this._renderCloud(x, y);
|
||
}
|
||
}
|
||
|
||
_renderCloud(x, y) {
|
||
this._ctx.beginPath();
|
||
this._ctx.arc(x, y, 30, 0, Math.PI * 2);
|
||
this._ctx.arc(x + 30, y, 40, 0, Math.PI * 2);
|
||
this._ctx.arc(x + 60, y, 30, 0, Math.PI * 2);
|
||
this._ctx.fill();
|
||
}
|
||
|
||
_renderPlatforms() {
|
||
this._platforms.forEach(platform => {
|
||
this._ctx.fillStyle = platform.color;
|
||
this._ctx.fillRect(platform.x, platform.y, platform.width, platform.height);
|
||
|
||
// Add outline
|
||
this._ctx.strokeStyle = '#000';
|
||
this._ctx.lineWidth = 2;
|
||
this._ctx.strokeRect(platform.x, platform.y, platform.width, platform.height);
|
||
});
|
||
}
|
||
|
||
_renderQuestionBlocks() {
|
||
this._questionBlocks.forEach(block => {
|
||
// Block
|
||
this._ctx.fillStyle = block.color;
|
||
this._ctx.fillRect(block.x, block.y, block.width, block.height);
|
||
|
||
// Outline
|
||
this._ctx.strokeStyle = '#000';
|
||
this._ctx.lineWidth = 2;
|
||
this._ctx.strokeRect(block.x, block.y, block.width, block.height);
|
||
|
||
// Symbol
|
||
this._ctx.fillStyle = '#000';
|
||
this._ctx.font = 'bold 24px Arial';
|
||
this._ctx.textAlign = 'center';
|
||
this._ctx.textBaseline = 'middle';
|
||
this._ctx.fillText(
|
||
block.symbol,
|
||
block.x + block.width / 2,
|
||
block.y + block.height / 2
|
||
);
|
||
});
|
||
}
|
||
|
||
_renderEnemies() {
|
||
this._enemies.forEach(enemy => {
|
||
this._ctx.fillStyle = enemy.color;
|
||
this._ctx.fillRect(enemy.x, enemy.y, enemy.width, enemy.height);
|
||
|
||
// Simple face
|
||
this._ctx.fillStyle = '#FFF';
|
||
this._ctx.fillRect(enemy.x + 3, enemy.y + 3, 3, 3); // Eye
|
||
this._ctx.fillRect(enemy.x + 14, enemy.y + 3, 3, 3); // Eye
|
||
|
||
// Draw helmet for protected enemies
|
||
if (enemy.hasHelmet) {
|
||
this._ctx.fillStyle = '#C0C0C0'; // Silver helmet
|
||
this._ctx.fillRect(enemy.x - 1, enemy.y - 4, enemy.width + 2, 6);
|
||
|
||
// Helmet shine
|
||
this._ctx.fillStyle = '#E8E8E8';
|
||
this._ctx.fillRect(enemy.x + 2, enemy.y - 3, 4, 2);
|
||
|
||
// Helmet symbol (shield)
|
||
this._ctx.fillStyle = '#FFD700';
|
||
this._ctx.fillRect(enemy.x + 8, enemy.y - 2, 4, 2);
|
||
}
|
||
});
|
||
}
|
||
|
||
_renderWalls() {
|
||
this._walls.forEach(wall => {
|
||
// Wall main body
|
||
this._ctx.fillStyle = wall.color;
|
||
this._ctx.fillRect(wall.x, wall.y, wall.width, wall.height);
|
||
|
||
// Add brick pattern for visual appeal
|
||
this._ctx.strokeStyle = '#654321'; // Darker brown for lines
|
||
this._ctx.lineWidth = 1;
|
||
|
||
// Horizontal lines
|
||
for (let y = wall.y + 20; y < wall.y + wall.height; y += 20) {
|
||
this._ctx.beginPath();
|
||
this._ctx.moveTo(wall.x, y);
|
||
this._ctx.lineTo(wall.x + wall.width, y);
|
||
this._ctx.stroke();
|
||
}
|
||
|
||
// Vertical lines (offset every other row)
|
||
for (let row = 0; row < Math.floor(wall.height / 20); row++) {
|
||
const yPos = wall.y + row * 20;
|
||
const offset = (row % 2) * 10; // Offset every other row
|
||
|
||
for (let x = wall.x + offset + 20; x < wall.x + wall.width; x += 20) {
|
||
this._ctx.beginPath();
|
||
this._ctx.moveTo(x, yPos);
|
||
this._ctx.lineTo(x, Math.min(yPos + 20, wall.y + wall.height));
|
||
this._ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Wall type indicator (for debugging)
|
||
if (wall.type === 'tall') {
|
||
this._ctx.fillStyle = '#FF0000';
|
||
this._ctx.fillRect(wall.x + wall.width - 5, wall.y, 5, 20);
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
_renderPiranhaPlants() {
|
||
this._piranhaPlants.forEach(plant => {
|
||
if (plant.flattened) {
|
||
// Flattened plant - just a green pancake on the ground
|
||
this._ctx.fillStyle = '#556B2F'; // Dark olive green (dead/flat)
|
||
this._ctx.fillRect(plant.x, plant.y + plant.height - 5, plant.width, 5);
|
||
|
||
// Some scattered debris
|
||
this._ctx.fillStyle = '#8B4513'; // Brown debris
|
||
this._ctx.fillRect(plant.x + 5, plant.y + plant.height - 3, 3, 2);
|
||
this._ctx.fillRect(plant.x + 20, plant.y + plant.height - 4, 2, 3);
|
||
this._ctx.fillRect(plant.x + 12, plant.y + plant.height - 2, 4, 1);
|
||
} else {
|
||
// Normal living plant
|
||
// Plant stem
|
||
this._ctx.fillStyle = '#228B22'; // Forest green
|
||
this._ctx.fillRect(plant.x + 10, plant.y, 10, plant.height);
|
||
|
||
// Plant head (circular)
|
||
this._ctx.fillStyle = '#32CD32'; // Lime green
|
||
this._ctx.beginPath();
|
||
this._ctx.arc(plant.x + 15, plant.y + 10, 12, 0, Math.PI * 2);
|
||
this._ctx.fill();
|
||
|
||
// Sharp teeth
|
||
this._ctx.fillStyle = '#FFFFFF';
|
||
for (let i = 0; i < 6; i++) {
|
||
const angle = (i * Math.PI * 2) / 6;
|
||
const toothX = plant.x + 15 + Math.cos(angle) * 8;
|
||
const toothY = plant.y + 10 + Math.sin(angle) * 8;
|
||
this._ctx.fillRect(toothX - 1, toothY - 1, 2, 4);
|
||
}
|
||
|
||
// Red mouth center
|
||
this._ctx.fillStyle = '#DC143C'; // Crimson
|
||
this._ctx.beginPath();
|
||
this._ctx.arc(plant.x + 15, plant.y + 10, 6, 0, Math.PI * 2);
|
||
this._ctx.fill();
|
||
|
||
// Eyes
|
||
this._ctx.fillStyle = '#000000';
|
||
this._ctx.fillRect(plant.x + 10, plant.y + 5, 3, 3);
|
||
this._ctx.fillRect(plant.x + 17, plant.y + 5, 3, 3);
|
||
}
|
||
});
|
||
}
|
||
|
||
_renderProjectiles() {
|
||
this._projectiles.forEach(projectile => {
|
||
// Fireball effect
|
||
this._ctx.fillStyle = projectile.color;
|
||
this._ctx.beginPath();
|
||
this._ctx.arc(projectile.x + projectile.width/2, projectile.y + projectile.height/2, projectile.width/2, 0, Math.PI * 2);
|
||
this._ctx.fill();
|
||
|
||
// Inner glow
|
||
this._ctx.fillStyle = '#FFFF00'; // Yellow center
|
||
this._ctx.beginPath();
|
||
this._ctx.arc(projectile.x + projectile.width/2, projectile.y + projectile.height/2, projectile.width/4, 0, Math.PI * 2);
|
||
this._ctx.fill();
|
||
});
|
||
}
|
||
|
||
_renderCatapults() {
|
||
this._catapults.forEach(catapult => {
|
||
// Catapult base
|
||
this._ctx.fillStyle = catapult.color;
|
||
this._ctx.fillRect(catapult.x, catapult.y + 40, catapult.width, 40);
|
||
|
||
// Catapult arm
|
||
this._ctx.fillStyle = '#654321'; // Dark brown
|
||
this._ctx.fillRect(catapult.x + 10, catapult.y, 8, 50);
|
||
|
||
// Bucket
|
||
this._ctx.fillStyle = '#8B4513';
|
||
this._ctx.fillRect(catapult.x + 5, catapult.y, 18, 12);
|
||
|
||
// Support beams
|
||
this._ctx.strokeStyle = '#654321';
|
||
this._ctx.lineWidth = 3;
|
||
this._ctx.beginPath();
|
||
this._ctx.moveTo(catapult.x + 30, catapult.y + 40);
|
||
this._ctx.lineTo(catapult.x + 14, catapult.y + 25);
|
||
this._ctx.stroke();
|
||
});
|
||
}
|
||
|
||
_renderBoulders() {
|
||
this._boulders.forEach(boulder => {
|
||
// Boulder body
|
||
this._ctx.fillStyle = boulder.color;
|
||
this._ctx.beginPath();
|
||
this._ctx.arc(boulder.x + boulder.width/2, boulder.y + boulder.height/2, boulder.width/2, 0, Math.PI * 2);
|
||
this._ctx.fill();
|
||
|
||
// Boulder texture/cracks
|
||
this._ctx.strokeStyle = '#555555';
|
||
this._ctx.lineWidth = 1;
|
||
this._ctx.beginPath();
|
||
this._ctx.arc(boulder.x + boulder.width/2 - 5, boulder.y + boulder.height/2 - 3, 3, 0, Math.PI);
|
||
this._ctx.stroke();
|
||
this._ctx.beginPath();
|
||
this._ctx.arc(boulder.x + boulder.width/2 + 4, boulder.y + boulder.height/2 + 2, 2, 0, Math.PI);
|
||
this._ctx.stroke();
|
||
|
||
// Show trajectory line for flying boulders (debug)
|
||
if (!boulder.hasLanded && boulder.velocityX !== 0) {
|
||
this._ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
||
this._ctx.lineWidth = 1;
|
||
this._ctx.setLineDash([5, 5]);
|
||
this._ctx.beginPath();
|
||
this._ctx.moveTo(boulder.x + boulder.width/2, boulder.y + boulder.height/2);
|
||
this._ctx.lineTo(boulder.x + boulder.velocityX * 10, boulder.y + boulder.velocityY * 10);
|
||
this._ctx.stroke();
|
||
this._ctx.setLineDash([]);
|
||
}
|
||
});
|
||
}
|
||
|
||
_renderFinishLine() {
|
||
const level = this._levelData[this._currentLevelIndex];
|
||
const finishX = level.endX;
|
||
|
||
// Draw checkered flag pattern
|
||
this._ctx.fillStyle = '#FFD700'; // Gold color
|
||
this._ctx.fillRect(finishX - 5, 50, 10, this._config.canvasHeight - 100);
|
||
|
||
// Add black and white checkered pattern
|
||
const squareSize = 20;
|
||
for (let y = 50; y < this._config.canvasHeight - 50; y += squareSize) {
|
||
for (let x = 0; x < 10; x += squareSize / 2) {
|
||
const isBlack = Math.floor((y - 50) / squareSize) % 2 === Math.floor(x / (squareSize / 2)) % 2;
|
||
this._ctx.fillStyle = isBlack ? '#000000' : '#FFFFFF';
|
||
this._ctx.fillRect(finishX - 5 + x, y, squareSize / 2, squareSize);
|
||
}
|
||
}
|
||
|
||
// Add "FINISH" text
|
||
this._ctx.save();
|
||
this._ctx.translate(finishX, 30);
|
||
this._ctx.rotate(-Math.PI / 2);
|
||
this._ctx.fillStyle = '#FF0000';
|
||
this._ctx.font = 'bold 16px Arial';
|
||
this._ctx.textAlign = 'center';
|
||
this._ctx.fillText('FINISH', 0, 0);
|
||
this._ctx.restore();
|
||
|
||
// Add arrow pointing to finish
|
||
if (this._mario.x < finishX - 200) {
|
||
this._ctx.fillStyle = '#FFD700';
|
||
this._ctx.font = 'bold 20px Arial';
|
||
this._ctx.textAlign = 'left';
|
||
this._ctx.fillText('→ FINISH', finishX - 150, 25);
|
||
}
|
||
}
|
||
|
||
_updateFlyingEyes() {
|
||
const currentTime = Date.now();
|
||
|
||
this._flyingEyes.forEach((eye, index) => {
|
||
// Calculate distance to Mario
|
||
const distanceToMario = Math.sqrt(
|
||
Math.pow(eye.x - this._mario.x, 2) +
|
||
Math.pow(eye.y - this._mario.y, 2)
|
||
);
|
||
|
||
// Determine if eye should chase Mario
|
||
eye.isChasing = distanceToMario < eye.chaseDistance;
|
||
|
||
// Handle dash behavior
|
||
if (eye.isDashing) {
|
||
eye.dashDuration--;
|
||
if (eye.dashDuration <= 0) {
|
||
eye.isDashing = false;
|
||
eye.lastDashTime = currentTime;
|
||
console.log(`👁️ Eye finished dashing!`);
|
||
}
|
||
// During dash, maintain dash velocity (no other movement changes)
|
||
} else {
|
||
// Check if it's time to dash (only when chasing)
|
||
if (eye.isChasing && currentTime - eye.lastDashTime > eye.dashInterval && Math.random() < 0.3) {
|
||
// Start dash in random 90-degree direction
|
||
const dashDirections = [
|
||
{ x: 1, y: 0 }, // Right
|
||
{ x: -1, y: 0 }, // Left
|
||
{ x: 0, y: 1 }, // Down
|
||
{ x: 0, y: -1 } // Up
|
||
];
|
||
const dashDir = dashDirections[Math.floor(Math.random() * 4)];
|
||
|
||
eye.velocityX = dashDir.x * eye.dashSpeed;
|
||
eye.velocityY = dashDir.y * eye.dashSpeed;
|
||
eye.isDashing = true;
|
||
eye.dashDuration = 20; // Dash for 20 frames (~0.33 seconds)
|
||
console.log(`👁️ Eye started dashing! Direction: ${dashDir.x}, ${dashDir.y}`);
|
||
} else if (eye.isChasing) {
|
||
// Normal chase behavior - move toward Mario
|
||
const deltaX = this._mario.x - eye.x;
|
||
const deltaY = this._mario.y - eye.y;
|
||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||
|
||
if (distance > 0) {
|
||
// Normalize direction and apply chase speed
|
||
eye.velocityX = (deltaX / distance) * eye.chaseSpeed;
|
||
eye.velocityY = (deltaY / distance) * eye.chaseSpeed;
|
||
}
|
||
|
||
console.log(`👁️ Eye chasing Mario! Distance: ${distanceToMario.toFixed(0)}`);
|
||
} else {
|
||
// Idle behavior - random floating movement (faster now)
|
||
if (currentTime - eye.lastDirectionChange > eye.directionChangeInterval) {
|
||
// Change direction randomly
|
||
eye.velocityX = (Math.random() - 0.5) * eye.idleSpeed * 2;
|
||
eye.velocityY = (Math.random() - 0.5) * eye.idleSpeed * 2;
|
||
eye.lastDirectionChange = currentTime;
|
||
eye.directionChangeInterval = 2000 + Math.random() * 3000;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply movement
|
||
eye.x += eye.velocityX;
|
||
eye.y += eye.velocityY;
|
||
|
||
// Keep eyes within screen bounds
|
||
if (eye.x < 0) {
|
||
eye.x = 0;
|
||
eye.velocityX = Math.abs(eye.velocityX);
|
||
} else if (eye.x > this._levelWidth - eye.width) {
|
||
eye.x = this._levelWidth - eye.width;
|
||
eye.velocityX = -Math.abs(eye.velocityX);
|
||
}
|
||
|
||
if (eye.y < 0) {
|
||
eye.y = 0;
|
||
eye.velocityY = Math.abs(eye.velocityY);
|
||
} else if (eye.y > this._config.canvasHeight - eye.height - 50) { // Stay above ground
|
||
eye.y = this._config.canvasHeight - eye.height - 50;
|
||
eye.velocityY = -Math.abs(eye.velocityY);
|
||
}
|
||
|
||
// Blinking animation
|
||
eye.blinkTimer++;
|
||
if (eye.blinkTimer > 120 + Math.random() * 180) { // Blink every 2-5 seconds
|
||
eye.isBlinking = true;
|
||
eye.blinkTimer = 0;
|
||
}
|
||
if (eye.isBlinking && eye.blinkTimer > 10) { // Blink lasts 10 frames
|
||
eye.isBlinking = false;
|
||
}
|
||
|
||
// Check collision with Mario
|
||
if (this._isColliding(this._mario, eye)) {
|
||
// Eye can be stomped like normal enemies
|
||
const overlapTop = (this._mario.y + this._mario.height) - eye.y;
|
||
const overlapBottom = (eye.y + eye.height) - this._mario.y;
|
||
|
||
if (this._mario.velocityY > 0 && overlapTop < overlapBottom) {
|
||
// Mario stomped the eye
|
||
this._flyingEyes.splice(index, 1);
|
||
this._mario.velocityY = this._config.jumpForce / 2; // Small bounce
|
||
this._addParticles(eye.x, eye.y, '#DC143C'); // Red particles
|
||
this._playSound('enemy_defeat');
|
||
console.log(`👁️ Mario stomped flying eye!`);
|
||
} else {
|
||
// Eye hurts Mario - reset level
|
||
console.log(`💥 Mario hit by flying eye - restarting level`);
|
||
this._restartLevel();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
_renderFlyingEyes() {
|
||
this._flyingEyes.forEach(eye => {
|
||
// Draw eye body (white oval)
|
||
this._ctx.fillStyle = '#FFFFFF';
|
||
this._ctx.beginPath();
|
||
this._ctx.ellipse(eye.x + eye.width/2, eye.y + eye.height/2, eye.width/2, eye.height/2, 0, 0, 2 * Math.PI);
|
||
this._ctx.fill();
|
||
|
||
if (!eye.isBlinking) {
|
||
// Draw red iris
|
||
this._ctx.fillStyle = eye.color;
|
||
this._ctx.beginPath();
|
||
this._ctx.ellipse(eye.x + eye.width/2, eye.y + eye.height/2, eye.width/3, eye.height/3, 0, 0, 2 * Math.PI);
|
||
this._ctx.fill();
|
||
|
||
// Draw black pupil that follows Mario
|
||
const deltaX = this._mario.x - eye.x;
|
||
const deltaY = this._mario.y - eye.y;
|
||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||
|
||
let pupilOffsetX = 0;
|
||
let pupilOffsetY = 0;
|
||
|
||
if (distance > 0) {
|
||
const maxOffset = 4; // Maximum pupil movement
|
||
pupilOffsetX = (deltaX / distance) * maxOffset;
|
||
pupilOffsetY = (deltaY / distance) * maxOffset;
|
||
}
|
||
|
||
this._ctx.fillStyle = eye.pupilColor;
|
||
this._ctx.beginPath();
|
||
this._ctx.arc(
|
||
eye.x + eye.width/2 + pupilOffsetX,
|
||
eye.y + eye.height/2 + pupilOffsetY,
|
||
eye.width/6,
|
||
0,
|
||
2 * Math.PI
|
||
);
|
||
this._ctx.fill();
|
||
} else {
|
||
// Draw closed eye (horizontal line)
|
||
this._ctx.strokeStyle = '#000000';
|
||
this._ctx.lineWidth = 2;
|
||
this._ctx.beginPath();
|
||
this._ctx.moveTo(eye.x + 5, eye.y + eye.height/2);
|
||
this._ctx.lineTo(eye.x + eye.width - 5, eye.y + eye.height/2);
|
||
this._ctx.stroke();
|
||
}
|
||
|
||
// Add subtle outline
|
||
this._ctx.strokeStyle = '#CCCCCC';
|
||
this._ctx.lineWidth = 1;
|
||
this._ctx.beginPath();
|
||
this._ctx.ellipse(eye.x + eye.width/2, eye.y + eye.height/2, eye.width/2, eye.height/2, 0, 0, 2 * Math.PI);
|
||
this._ctx.stroke();
|
||
});
|
||
}
|
||
|
||
_updateBoss() {
|
||
if (!this._boss) return;
|
||
|
||
const currentTime = Date.now();
|
||
|
||
// Update boss collision cooldown
|
||
if (this._bossCollisionCooldown > 0) {
|
||
this._bossCollisionCooldown--;
|
||
}
|
||
|
||
// Update damage flash timer
|
||
if (this._boss.damageFlashTimer > 0) {
|
||
this._boss.damageFlashTimer--;
|
||
if (this._boss.damageFlashTimer <= 0) {
|
||
this._boss.isDamaged = false;
|
||
}
|
||
}
|
||
|
||
// Boss collision with Mario (blocks the path) - with cooldown to prevent loop
|
||
if (this._isColliding(this._mario, this._boss) && this._bossCollisionCooldown <= 0) {
|
||
console.log(`👹 Mario hit boss body - bounced back!`);
|
||
|
||
// TRUE velocity inversion: velocity = velocity * -1
|
||
this._mario.velocityX = this._mario.velocityX * -1 - 3; // Invert + add knockback
|
||
this._mario.velocityY = this._config.jumpForce * 0.7; // Bounce upward
|
||
|
||
// Set collision cooldown (3 ticks)
|
||
this._bossCollisionCooldown = 3;
|
||
|
||
// Impact particles
|
||
this._addParticles(
|
||
this._mario.x + this._mario.width / 2,
|
||
this._mario.y + this._mario.height / 2,
|
||
'#FFFF00' // Yellow impact particles
|
||
);
|
||
|
||
// Impact sound
|
||
this._playSound('enemy_defeat');
|
||
|
||
console.log(`⏱️ Boss collision cooldown set: ${this._bossCollisionCooldown} ticks`);
|
||
}
|
||
}
|
||
|
||
_renderBoss() {
|
||
if (!this._boss) return;
|
||
|
||
const ctx = this._ctx;
|
||
const x = this._boss.x;
|
||
const y = this._boss.y;
|
||
const w = this._boss.width;
|
||
const h = this._boss.height;
|
||
|
||
// Boss main body (torso) - more humanoid proportions
|
||
ctx.fillStyle = this._boss.isDamaged ? '#FF6666' : this._boss.color;
|
||
ctx.fillRect(x + 20, y, w - 40, h * 0.6); // Torso
|
||
|
||
// Boss legs (separated)
|
||
ctx.fillRect(x + 10, y + h * 0.6, 35, h * 0.4); // Left leg
|
||
ctx.fillRect(x + w - 45, y + h * 0.6, 35, h * 0.4); // Right leg
|
||
|
||
// Boss head (bigger and more menacing)
|
||
const headWidth = 60;
|
||
const headHeight = 50;
|
||
ctx.fillRect(x + (w - headWidth) / 2, y - headHeight, headWidth, headHeight);
|
||
|
||
// Boss outline for all parts
|
||
ctx.strokeStyle = '#000000';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeRect(x + 20, y, w - 40, h * 0.6); // Torso outline
|
||
ctx.strokeRect(x + 10, y + h * 0.6, 35, h * 0.4); // Left leg outline
|
||
ctx.strokeRect(x + w - 45, y + h * 0.6, 35, h * 0.4); // Right leg outline
|
||
ctx.strokeRect(x + (w - headWidth) / 2, y - headHeight, headWidth, headHeight); // Head outline
|
||
|
||
// Boss eyes (glowing red, larger)
|
||
ctx.fillStyle = this._boss.eyeColor;
|
||
const eyeSize = 12;
|
||
ctx.fillRect(x + (w - headWidth) / 2 + 10, y - headHeight + 15, eyeSize, eyeSize); // Left eye
|
||
ctx.fillRect(x + (w - headWidth) / 2 + headWidth - 22, y - headHeight + 15, eyeSize, eyeSize); // Right eye
|
||
|
||
// Boss mouth (menacing)
|
||
ctx.fillStyle = '#000000';
|
||
ctx.fillRect(x + (w - headWidth) / 2 + 15, y - headHeight + 35, headWidth - 30, 8);
|
||
|
||
// Shoulder spikes for more intimidating look
|
||
ctx.fillStyle = '#8B4513';
|
||
ctx.fillRect(x + 15, y + 5, 15, 25); // Left shoulder
|
||
ctx.fillRect(x + w - 30, y + 5, 15, 25); // Right shoulder
|
||
|
||
// Boss knees (damage zones) - integrated styling as kneepads
|
||
ctx.fillStyle = '#FFD700'; // Gold kneepads
|
||
ctx.fillRect(
|
||
this._boss.leftKnee.x + 5,
|
||
this._boss.leftKnee.y + 5,
|
||
this._boss.leftKnee.width - 10,
|
||
this._boss.leftKnee.height - 10
|
||
);
|
||
ctx.fillRect(
|
||
this._boss.rightKnee.x + 5,
|
||
this._boss.rightKnee.y + 5,
|
||
this._boss.rightKnee.width - 10,
|
||
this._boss.rightKnee.height - 10
|
||
);
|
||
|
||
// Kneepad outlines
|
||
ctx.strokeStyle = '#B8860B'; // Dark goldenrod outline
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(
|
||
this._boss.leftKnee.x + 5,
|
||
this._boss.leftKnee.y + 5,
|
||
this._boss.leftKnee.width - 10,
|
||
this._boss.leftKnee.height - 10
|
||
);
|
||
ctx.strokeRect(
|
||
this._boss.rightKnee.x + 5,
|
||
this._boss.rightKnee.y + 5,
|
||
this._boss.rightKnee.width - 10,
|
||
this._boss.rightKnee.height - 10
|
||
);
|
||
|
||
// Boss health bar
|
||
const healthBarWidth = 200;
|
||
const healthBarHeight = 20;
|
||
const healthBarX = this._boss.x + (this._boss.width / 2) - (healthBarWidth / 2);
|
||
const healthBarY = this._boss.y - 40;
|
||
|
||
// Health bar background
|
||
ctx.fillStyle = '#FF0000';
|
||
ctx.fillRect(healthBarX, healthBarY, healthBarWidth, healthBarHeight);
|
||
|
||
// Health bar foreground
|
||
const healthPercent = this._boss.health / this._boss.maxHealth;
|
||
ctx.fillStyle = '#00FF00';
|
||
ctx.fillRect(healthBarX, healthBarY, healthBarWidth * healthPercent, healthBarHeight);
|
||
|
||
// Health bar border
|
||
ctx.strokeStyle = '#000000';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(healthBarX, healthBarY, healthBarWidth, healthBarHeight);
|
||
|
||
// Render turrets on boss
|
||
this._bossTurrets.forEach(turret => {
|
||
ctx.fillStyle = turret.color;
|
||
ctx.fillRect(turret.x, turret.y, turret.width, turret.height);
|
||
ctx.strokeStyle = '#000000';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(turret.x, turret.y, turret.width, turret.height);
|
||
});
|
||
}
|
||
|
||
_renderMario() {
|
||
// Mario body
|
||
this._ctx.fillStyle = this._mario.color;
|
||
this._ctx.fillRect(this._mario.x, this._mario.y, this._mario.width, this._mario.height);
|
||
|
||
// Hat
|
||
this._ctx.fillStyle = '#8B0000';
|
||
this._ctx.fillRect(this._mario.x + 4, this._mario.y - 8, this._mario.width - 8, 12);
|
||
|
||
// Face
|
||
this._ctx.fillStyle = '#FFDBAC';
|
||
this._ctx.fillRect(this._mario.x + 8, this._mario.y + 8, 16, 16);
|
||
|
||
// Eyes
|
||
this._ctx.fillStyle = '#000';
|
||
const eyeOffset = this._mario.facing === 'right' ? 2 : -2;
|
||
this._ctx.fillRect(this._mario.x + 10 + eyeOffset, this._mario.y + 12, 2, 2);
|
||
this._ctx.fillRect(this._mario.x + 18 + eyeOffset, this._mario.y + 12, 2, 2);
|
||
|
||
// Mustache
|
||
this._ctx.fillRect(this._mario.x + 12, this._mario.y + 18, 8, 2);
|
||
}
|
||
|
||
_renderParticles() {
|
||
this._particles.forEach(particle => {
|
||
const alpha = particle.life / particle.maxLife;
|
||
|
||
if (particle.isFinishStar) {
|
||
// Render finish line stars as actual star symbols
|
||
this._ctx.font = `${particle.size}px serif`;
|
||
this._ctx.textAlign = 'center';
|
||
this._ctx.textBaseline = 'middle';
|
||
this._ctx.fillStyle = particle.color + Math.floor(alpha * 255).toString(16).padStart(2, '0');
|
||
this._ctx.fillText('⭐', particle.x, particle.y);
|
||
} else {
|
||
// Regular particles
|
||
this._ctx.fillStyle = particle.color + Math.floor(alpha * 255).toString(16).padStart(2, '0');
|
||
this._ctx.fillRect(particle.x, particle.y, 4, 4);
|
||
}
|
||
});
|
||
|
||
// Reset text alignment
|
||
this._ctx.textAlign = 'left';
|
||
this._ctx.textBaseline = 'top';
|
||
}
|
||
|
||
_renderCastle() {
|
||
if (!this._castleStructure) return;
|
||
|
||
const castle = this._castleStructure;
|
||
|
||
// Set font for castle emoji
|
||
this._ctx.font = `${castle.size}px serif`;
|
||
this._ctx.textAlign = 'center';
|
||
this._ctx.textBaseline = 'middle';
|
||
|
||
// Render castle emoji
|
||
this._ctx.fillText(castle.emoji, castle.x, castle.y);
|
||
|
||
// Render princess in the castle
|
||
if (castle.princess) {
|
||
this._ctx.font = `${castle.princess.size}px serif`;
|
||
this._ctx.fillText(castle.princess.emoji, castle.princess.x, castle.princess.y);
|
||
}
|
||
|
||
// Add sparkles around the massive castle (360px)
|
||
this._ctx.font = '40px serif';
|
||
this._ctx.fillText('✨', castle.x - 180, castle.y - 120);
|
||
this._ctx.fillText('✨', castle.x + 180, castle.y - 120);
|
||
this._ctx.fillText('✨', castle.x - 120, castle.y + 120);
|
||
this._ctx.fillText('✨', castle.x + 120, castle.y + 120);
|
||
|
||
// Additional sparkles for the massive castle
|
||
this._ctx.font = '30px serif';
|
||
this._ctx.fillText('✨', castle.x - 220, castle.y);
|
||
this._ctx.fillText('✨', castle.x + 220, castle.y);
|
||
this._ctx.fillText('✨', castle.x, castle.y - 200);
|
||
this._ctx.fillText('✨', castle.x, castle.y + 180);
|
||
|
||
// Extra sparkles for grandeur
|
||
this._ctx.font = '25px serif';
|
||
this._ctx.fillText('⭐', castle.x - 160, castle.y - 60);
|
||
this._ctx.fillText('⭐', castle.x + 160, castle.y - 60);
|
||
this._ctx.fillText('⭐', castle.x - 60, castle.y + 160);
|
||
this._ctx.fillText('⭐', castle.x + 60, castle.y + 160);
|
||
|
||
// Reset text alignment
|
||
this._ctx.textAlign = 'left';
|
||
this._ctx.textBaseline = 'top';
|
||
}
|
||
|
||
_renderUI() {
|
||
if (this._uiOverlay) {
|
||
this._uiOverlay.innerHTML = `
|
||
<div>Score: ${this._score}</div>
|
||
<div>Level: ${this._currentLevel}/${this._config.maxLevels}</div>
|
||
<div>Questions: ${this._questionsAnswered}</div>
|
||
<div style="margin-top: 10px; font-size: 14px;">
|
||
Use Arrow Keys or WASD to move, Space/Up to jump
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
_getRandomSentence() {
|
||
const availableSentences = this._sentences.filter(s => !this._usedSentences.includes(s));
|
||
if (availableSentences.length === 0) {
|
||
// Reset used sentences if all are used
|
||
this._usedSentences = [];
|
||
return this._sentences[0];
|
||
}
|
||
|
||
const sentence = availableSentences[Math.floor(Math.random() * availableSentences.length)];
|
||
this._usedSentences.push(sentence);
|
||
return sentence;
|
||
}
|
||
|
||
_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;
|
||
}
|
||
|
||
_renderDebugHitboxes() {
|
||
// Only render in debug mode (can be toggled)
|
||
if (!window.DEBUG_HITBOXES) return;
|
||
|
||
this._ctx.strokeStyle = '#FF0000';
|
||
this._ctx.lineWidth = 2;
|
||
|
||
// Mario hitbox
|
||
this._ctx.strokeRect(this._mario.x, this._mario.y, this._mario.width, this._mario.height);
|
||
|
||
// Platform hitboxes
|
||
this._ctx.strokeStyle = '#00FF00';
|
||
this._platforms.forEach(platform => {
|
||
this._ctx.strokeRect(platform.x, platform.y, platform.width, platform.height);
|
||
});
|
||
|
||
// Wall hitboxes
|
||
this._ctx.strokeStyle = '#8B4513';
|
||
this._walls.forEach(wall => {
|
||
this._ctx.strokeRect(wall.x, wall.y, wall.width, wall.height);
|
||
});
|
||
|
||
// Enemy hitboxes
|
||
this._ctx.strokeStyle = '#FF00FF';
|
||
this._enemies.forEach(enemy => {
|
||
this._ctx.strokeRect(enemy.x, enemy.y, enemy.width, enemy.height);
|
||
});
|
||
|
||
// Question block hitboxes
|
||
this._ctx.strokeStyle = '#FFFF00';
|
||
this._questionBlocks.forEach(block => {
|
||
this._ctx.strokeRect(block.x, block.y, block.width, block.height);
|
||
});
|
||
}
|
||
}
|
||
|
||
export default MarioEducational; |