- Render floating words as wooden trunks using Canvas instead of div elements
- Trunks scale proportionally to word length (longer words = bigger trunks)
- Add realistic wood texture with grain, rings, and highlights
- Display word text both on trunk and below for clarity
- Improve event handling for Canvas-based clickable elements
- Update styles and animations to work with Canvas elements
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2678 lines
92 KiB
JavaScript
2678 lines
92 KiB
JavaScript
import Module from '../core/Module.js';
|
|
|
|
/**
|
|
* WizardSpellCaster - Advanced RPG-style spell casting game
|
|
* Players construct sentences to cast magical spells and defeat enemies
|
|
*/
|
|
class WizardSpellCaster extends Module {
|
|
constructor(name, dependencies, config = {}) {
|
|
super(name, ['eventBus']);
|
|
|
|
// Validate dependencies
|
|
if (!dependencies.eventBus || !dependencies.content) {
|
|
throw new Error('WizardSpellCaster requires eventBus and content dependencies');
|
|
}
|
|
|
|
this._eventBus = dependencies.eventBus;
|
|
this._content = dependencies.content;
|
|
this._config = {
|
|
container: null,
|
|
enemyAttackInterval: { min: 8000, max: 15000 },
|
|
enemyDamage: { min: 12, max: 20 },
|
|
maxPlayerHP: 100,
|
|
maxEnemyHP: 100,
|
|
...config
|
|
};
|
|
|
|
// Game state
|
|
this._score = 0;
|
|
this._enemyHP = this._config.maxEnemyHP;
|
|
this._playerHP = this._config.maxPlayerHP;
|
|
this._gameStartTime = null;
|
|
|
|
// Spell system
|
|
this._spells = { short: [], medium: [], long: [] };
|
|
this._currentSpells = [];
|
|
this._selectedSpell = null;
|
|
this._selectedWords = [];
|
|
this._spellStartTime = null;
|
|
this._averageSpellTime = 0;
|
|
this._spellCount = 0;
|
|
|
|
// Enemy attack system
|
|
this._enemyAttackTimer = null;
|
|
this._nextEnemyAttack = 0;
|
|
|
|
Object.seal(this);
|
|
}
|
|
|
|
/**
|
|
* Get game metadata
|
|
* @returns {Object} Game metadata
|
|
*/
|
|
static getMetadata() {
|
|
return {
|
|
name: 'Wizard Spell Caster',
|
|
description: 'Advanced RPG spell casting game with sentence construction and magical combat',
|
|
difficulty: 'advanced',
|
|
category: 'rpg',
|
|
estimatedTime: 10, // minutes
|
|
skills: ['grammar', 'sentences', 'vocabulary', 'strategy', 'speed']
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 storyChapters = content?.story?.chapters || [];
|
|
const dialogues = content?.dialogues || [];
|
|
const texts = content?.texts || [];
|
|
const phrases = content?.phrases || {};
|
|
const dialogs = content?.dialogs || {};
|
|
|
|
let totalSentences = sentences.length;
|
|
|
|
// Count sentences from story chapters
|
|
storyChapters.forEach(chapter => {
|
|
if (chapter.sentences) {
|
|
totalSentences += chapter.sentences.length;
|
|
}
|
|
});
|
|
|
|
// Count sentences from dialogues
|
|
dialogues.forEach(dialogue => {
|
|
if (dialogue.conversation) {
|
|
totalSentences += dialogue.conversation.length;
|
|
}
|
|
});
|
|
|
|
// Count phrases (object format with key-value pairs)
|
|
if (typeof phrases === 'object') {
|
|
totalSentences += Object.keys(phrases).length;
|
|
}
|
|
|
|
// Count dialog lines (alternative spelling, object format)
|
|
if (typeof dialogs === 'object') {
|
|
Object.values(dialogs).forEach(dialog => {
|
|
if (dialog.lines && Array.isArray(dialog.lines)) {
|
|
totalSentences += dialog.lines.length;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Count extractable sentences from texts (LEDU-style content)
|
|
let extractableSentences = 0;
|
|
texts.forEach(text => {
|
|
if (text.content) {
|
|
const sentencesInText = text.content.split(/[。!?\.\!\?]+/).filter(s => {
|
|
const trimmed = s.trim();
|
|
const wordCount = trimmed.split(/\s+/).length;
|
|
return trimmed && wordCount >= 3 && wordCount <= 15;
|
|
});
|
|
extractableSentences += sentencesInText.length;
|
|
}
|
|
});
|
|
|
|
totalSentences += extractableSentences;
|
|
|
|
// If we have enough sentences, use them
|
|
if (totalSentences >= 9) {
|
|
const score = Math.min(totalSentences / 30, 1);
|
|
return {
|
|
score,
|
|
reason: `${totalSentences} sentences/phrases available for spell construction`,
|
|
requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs'],
|
|
minSentences: 9,
|
|
optimalSentences: 30,
|
|
details: `Can create engaging spell combat with ${totalSentences} sentences/phrases`
|
|
};
|
|
}
|
|
|
|
// Fallback: Check vocabulary for creating basic spell phrases
|
|
let vocabCount = 0;
|
|
let hasVocabulary = false;
|
|
|
|
if (Array.isArray(content?.vocabulary)) {
|
|
vocabCount = content.vocabulary.length;
|
|
hasVocabulary = vocabCount > 0;
|
|
} else if (content?.vocabulary && typeof content.vocabulary === 'object') {
|
|
vocabCount = Object.keys(content.vocabulary).length;
|
|
hasVocabulary = vocabCount > 0;
|
|
}
|
|
|
|
if (hasVocabulary && vocabCount >= 15) {
|
|
// Can create basic spell phrases from vocabulary
|
|
let score = 0.3; // Base score for vocabulary-based spells
|
|
|
|
if (vocabCount >= 20) score += 0.1;
|
|
if (vocabCount >= 30) score += 0.1;
|
|
if (vocabCount >= 40) score += 0.1;
|
|
|
|
return {
|
|
score: Math.min(score, 0.6), // Cap at 60% for vocabulary-only content
|
|
reason: `${vocabCount} vocabulary words available for spell creation`,
|
|
requirements: ['vocabulary'],
|
|
minWords: 15,
|
|
optimalWords: 40,
|
|
details: `Can create basic spell combat using ${vocabCount} vocabulary words`
|
|
};
|
|
}
|
|
|
|
return {
|
|
score: 0,
|
|
reason: `Insufficient content (${totalSentences} sentences/phrases, ${vocabCount} vocabulary words)`,
|
|
requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs', 'vocabulary'],
|
|
minSentences: 9,
|
|
minWords: 15,
|
|
details: 'Wizard Spell Caster needs at least 9 sentences/phrases or 15 vocabulary words'
|
|
};
|
|
}
|
|
|
|
async init() {
|
|
this._validateNotDestroyed();
|
|
|
|
try {
|
|
// Validate container
|
|
if (!this._config.container) {
|
|
throw new Error('Game container is required');
|
|
}
|
|
|
|
// Extract and validate spells
|
|
this._extractSpells();
|
|
if (this._getTotalSpellCount() < 9) {
|
|
throw new Error(`Insufficient spells: need 9, got ${this._getTotalSpellCount()}`);
|
|
}
|
|
|
|
// Set up event listeners
|
|
this._eventBus.on('game:pause', this._handlePause.bind(this), this.name);
|
|
this._eventBus.on('game:resume', this._handleResume.bind(this), this.name);
|
|
|
|
// Inject CSS
|
|
this._injectCSS();
|
|
|
|
// Initialize game interface
|
|
this._createGameInterface();
|
|
this._setupEventListeners();
|
|
this._generateNewSpells();
|
|
this._startEnemyAttackSystem();
|
|
|
|
// Start the game
|
|
this._gameStartTime = Date.now();
|
|
|
|
// Emit game ready event
|
|
this._eventBus.emit('game:ready', {
|
|
gameId: 'wizard-spell-caster',
|
|
instanceId: this.name,
|
|
spells: {
|
|
short: this._spells.short.length,
|
|
medium: this._spells.medium.length,
|
|
long: this._spells.long.length
|
|
}
|
|
}, this.name);
|
|
|
|
this._setInitialized();
|
|
|
|
} catch (error) {
|
|
this._showError(error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async destroy() {
|
|
this._validateNotDestroyed();
|
|
|
|
// Clear timers
|
|
if (this._enemyAttackTimer) {
|
|
clearTimeout(this._enemyAttackTimer);
|
|
this._enemyAttackTimer = null;
|
|
}
|
|
|
|
// Remove CSS
|
|
this._removeCSS();
|
|
|
|
// Clean up event listeners
|
|
if (this._config.container) {
|
|
this._config.container.innerHTML = '';
|
|
}
|
|
|
|
// Emit game end event
|
|
this._eventBus.emit('game:ended', {
|
|
gameId: 'wizard-spell-caster',
|
|
instanceId: this.name,
|
|
score: this._score,
|
|
playerHP: this._playerHP,
|
|
enemyHP: this._enemyHP,
|
|
spellsCast: this._spellCount,
|
|
duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0
|
|
}, this.name);
|
|
|
|
this._setDestroyed();
|
|
}
|
|
|
|
/**
|
|
* Get current game state
|
|
* @returns {Object} Current game state
|
|
*/
|
|
getGameState() {
|
|
this._validateInitialized();
|
|
|
|
return {
|
|
score: this._score,
|
|
playerHP: this._playerHP,
|
|
enemyHP: this._enemyHP,
|
|
maxPlayerHP: this._config.maxPlayerHP,
|
|
maxEnemyHP: this._config.maxEnemyHP,
|
|
spellsCast: this._spellCount,
|
|
averageSpellTime: this._averageSpellTime,
|
|
isComplete: this._playerHP <= 0 || this._enemyHP <= 0,
|
|
duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0
|
|
};
|
|
}
|
|
|
|
// Private methods
|
|
_extractSpells() {
|
|
this._spells = { short: [], medium: [], long: [] };
|
|
|
|
// Extract from sentences
|
|
if (this._content.sentences) {
|
|
this._content.sentences.forEach(sentence => {
|
|
const originalText = sentence.english || sentence.original_language;
|
|
if (originalText) {
|
|
this._processSentence({
|
|
original: originalText,
|
|
translation: sentence.chinese || sentence.french || sentence.user_language || sentence.translation,
|
|
words: this._extractWordsFromSentence(originalText)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Extract from story chapters
|
|
if (this._content.story?.chapters) {
|
|
this._content.story.chapters.forEach(chapter => {
|
|
if (chapter.sentences) {
|
|
chapter.sentences.forEach(sentence => {
|
|
if (sentence.original) {
|
|
this._processSentence({
|
|
original: sentence.original,
|
|
translation: sentence.translation,
|
|
words: sentence.words || this._extractWordsFromSentence(sentence.original)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Extract from dialogues
|
|
if (this._content.dialogues) {
|
|
this._content.dialogues.forEach(dialogue => {
|
|
if (dialogue.conversation) {
|
|
dialogue.conversation.forEach(line => {
|
|
if (line.english && line.chinese) {
|
|
this._processSentence({
|
|
original: line.english,
|
|
translation: line.chinese,
|
|
words: this._extractWordsFromSentence(line.english)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Extract from phrases (key-value object format)
|
|
if (this._content.phrases && typeof this._content.phrases === 'object') {
|
|
Object.entries(this._content.phrases).forEach(([english, phraseData]) => {
|
|
const translation = typeof phraseData === 'string' ? phraseData : phraseData.user_language || phraseData.chinese;
|
|
if (english && translation) {
|
|
this._processSentence({
|
|
original: english,
|
|
translation: translation,
|
|
words: this._extractWordsFromSentence(english)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Extract from dialogs (alternative spelling of dialogues)
|
|
if (this._content.dialogs && typeof this._content.dialogs === 'object') {
|
|
Object.values(this._content.dialogs).forEach(dialog => {
|
|
if (dialog.lines && Array.isArray(dialog.lines)) {
|
|
dialog.lines.forEach(line => {
|
|
if (line.text && line.user_language) {
|
|
this._processSentence({
|
|
original: line.text,
|
|
translation: line.user_language,
|
|
words: this._extractWordsFromSentence(line.text)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Fallback: Extract from texts (for LEDU-style content)
|
|
if (this._getTotalSpellCount() < 9 && this._content.texts && Array.isArray(this._content.texts)) {
|
|
console.log('WizardSpellCaster: Extracting spells from texts as fallback');
|
|
this._content.texts.forEach(text => {
|
|
if (text.content) {
|
|
// Split text into sentences using Chinese punctuation and periods
|
|
const sentences = text.content.split(/[。!?\.\!\?]+/).filter(s => s.trim().length > 0);
|
|
sentences.forEach(sentence => {
|
|
const trimmed = sentence.trim();
|
|
// Only use sentences with reasonable length (3-15 words)
|
|
const wordCount = trimmed.split(/\s+/).length;
|
|
if (trimmed && wordCount >= 3 && wordCount <= 15) {
|
|
this._processSentence({
|
|
original: trimmed,
|
|
translation: trimmed, // In Chinese content, use same for both
|
|
words: this._extractWordsFromSentence(trimmed)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
_processSentence(sentenceData) {
|
|
if (!sentenceData.original || !sentenceData.translation) return;
|
|
|
|
const wordCount = sentenceData.words.length;
|
|
const spellData = {
|
|
english: sentenceData.original,
|
|
translation: sentenceData.translation,
|
|
words: sentenceData.words,
|
|
damage: this._calculateDamage(wordCount),
|
|
castTime: this._calculateCastTime(wordCount)
|
|
};
|
|
|
|
if (wordCount <= 4) {
|
|
this._spells.short.push(spellData);
|
|
} else if (wordCount <= 6) {
|
|
this._spells.medium.push(spellData);
|
|
} else {
|
|
this._spells.long.push(spellData);
|
|
}
|
|
}
|
|
|
|
_extractWordsFromSentence(sentence) {
|
|
// Validate input sentence
|
|
if (!sentence || typeof sentence !== 'string') {
|
|
console.warn('WizardSpellCaster: Invalid sentence provided to _extractWordsFromSentence:', sentence);
|
|
return [];
|
|
}
|
|
|
|
// Simple word extraction with punctuation handling
|
|
const words = sentence.split(/\s+/).map(word => {
|
|
return {
|
|
word: word.replace(/[.!?,;:]/g, ''),
|
|
translation: word.replace(/[.!?,;:]/g, ''),
|
|
type: 'word'
|
|
};
|
|
}).filter(wordData => wordData.word.length > 0);
|
|
|
|
// Add punctuation as separate elements
|
|
const punctuation = sentence.match(/[.!?,;:]/g) || [];
|
|
punctuation.forEach((punct, index) => {
|
|
words.push({
|
|
word: punct,
|
|
translation: punct,
|
|
type: 'punctuation',
|
|
uniqueId: `punct_${index}_${Date.now()}_${Math.random()}`
|
|
});
|
|
});
|
|
|
|
return words;
|
|
}
|
|
|
|
_calculateDamage(wordCount) {
|
|
if (wordCount <= 3) return Math.floor(Math.random() * 10) + 15; // 15-25
|
|
if (wordCount <= 5) return Math.floor(Math.random() * 15) + 30; // 30-45
|
|
if (wordCount <= 7) return Math.floor(Math.random() * 20) + 50; // 50-70
|
|
return Math.floor(Math.random() * 30) + 70; // 70-100
|
|
}
|
|
|
|
_calculateCastTime(wordCount) {
|
|
if (wordCount <= 4) return 1000; // 1 second
|
|
if (wordCount <= 6) return 2000; // 2 seconds
|
|
return 3000; // 3 seconds
|
|
}
|
|
|
|
_getTotalSpellCount() {
|
|
return this._spells.short.length + this._spells.medium.length + this._spells.long.length;
|
|
}
|
|
|
|
_injectCSS() {
|
|
const cssId = `wizard-spell-caster-styles-${this.name}`;
|
|
if (document.getElementById(cssId)) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.id = cssId;
|
|
style.textContent = `
|
|
.wizard-game-wrapper {
|
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
|
height: 100vh;
|
|
color: white;
|
|
font-family: 'Fantasy', serif;
|
|
position: relative;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.wizard-hud {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 15px;
|
|
background: rgba(0,0,0,0.3);
|
|
border-bottom: 2px solid #ffd700;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.wizard-stats {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
}
|
|
|
|
.health-bar {
|
|
width: 120px;
|
|
height: 16px;
|
|
background: rgba(255,255,255,0.2);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border: 2px solid #ffd700;
|
|
}
|
|
|
|
.health-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #ff4757, #ff6b7a);
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.battle-area {
|
|
display: flex;
|
|
height: 180px;
|
|
padding: 10px 20px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.wizard-side {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.enemy-side {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.wizard-character {
|
|
width: 80px;
|
|
height: 80px;
|
|
background: linear-gradient(45deg, #6c5ce7, #a29bfe);
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 36px;
|
|
margin-bottom: 8px;
|
|
animation: float 3s ease-in-out infinite;
|
|
box-shadow: 0 0 30px rgba(108, 92, 231, 0.6);
|
|
}
|
|
|
|
.enemy-character {
|
|
width: 100px;
|
|
height: 100px;
|
|
background: linear-gradient(45deg, #ff4757, #ff6b7a);
|
|
border-radius: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 48px;
|
|
margin-bottom: 8px;
|
|
animation: enemyPulse 2s ease-in-out infinite;
|
|
box-shadow: 0 0 40px rgba(255, 71, 87, 0.6);
|
|
}
|
|
|
|
@keyframes float {
|
|
0%, 100% { transform: translateY(0px); }
|
|
50% { transform: translateY(-10px); }
|
|
}
|
|
|
|
@keyframes enemyPulse {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.05); }
|
|
}
|
|
|
|
.spell-casting-area {
|
|
background: rgba(0,0,0,0.4);
|
|
border: 2px solid #ffd700;
|
|
border-radius: 15px;
|
|
padding: 12px 15px;
|
|
margin: 0 15px 10px 15px;
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.spell-selection {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.spell-card {
|
|
background: linear-gradient(135deg, #2c2c54, #40407a);
|
|
border: 2px solid #ffd700;
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
text-align: center;
|
|
}
|
|
|
|
.spell-card:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 10px 25px rgba(255, 215, 0, 0.3);
|
|
border-color: #fff;
|
|
}
|
|
|
|
.spell-card.selected {
|
|
background: linear-gradient(135deg, #ffd700, #ffed4e);
|
|
color: #000;
|
|
transform: scale(1.05);
|
|
animation: spellCharging 0.5s ease-in-out infinite alternate;
|
|
}
|
|
|
|
.spell-type {
|
|
font-size: 12px;
|
|
color: #ffd700;
|
|
font-weight: bold;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.spell-damage {
|
|
font-size: 14px;
|
|
color: #ff6b7a;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.sentence-builder {
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
margin-bottom: 10px;
|
|
min-height: 60px;
|
|
border: 2px dashed #ffd700;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.word-bank {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.word-tile {
|
|
background: linear-gradient(135deg, #5f27cd, #8854d0);
|
|
color: white;
|
|
padding: 6px 12px;
|
|
border-radius: 15px;
|
|
cursor: grab;
|
|
user-select: none;
|
|
transition: all 0.3s ease;
|
|
border: 2px solid transparent;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.word-tile:hover {
|
|
transform: scale(1.1);
|
|
box-shadow: 0 5px 15px rgba(95, 39, 205, 0.4);
|
|
}
|
|
|
|
.word-tile.selected {
|
|
background: linear-gradient(135deg, #ffd700, #ffed4e);
|
|
color: #000;
|
|
border-color: #fff;
|
|
}
|
|
|
|
.word-tile:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.cast-button {
|
|
background: linear-gradient(135deg, #ff6b7a, #ff4757);
|
|
border: none;
|
|
color: white;
|
|
padding: 12px 25px;
|
|
border-radius: 20px;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
box-shadow: 0 5px 15px rgba(255, 71, 87, 0.3);
|
|
width: 100%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.cast-button:hover {
|
|
transform: translateY(-3px);
|
|
box-shadow: 0 8px 25px rgba(255, 71, 87, 0.5);
|
|
}
|
|
|
|
.cast-button:disabled {
|
|
background: #666;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.exit-wizard-btn {
|
|
padding: 8px 15px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
color: white;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.exit-wizard-btn:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.damage-number {
|
|
position: absolute;
|
|
font-size: 36px;
|
|
font-weight: bold;
|
|
color: #ff4757;
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
|
|
pointer-events: none;
|
|
animation: damageFloat 1.5s ease-out forwards;
|
|
}
|
|
|
|
@keyframes damageFloat {
|
|
0% {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: translateY(-100px) scale(1.5);
|
|
}
|
|
}
|
|
|
|
.spell-effect {
|
|
position: absolute;
|
|
width: 100px;
|
|
height: 100px;
|
|
border-radius: 50%;
|
|
pointer-events: none;
|
|
animation: spellBlast 0.8s ease-out forwards;
|
|
}
|
|
|
|
.fire-effect {
|
|
background: radial-gradient(circle, #ff6b7a, #ff4757, #ff3742, transparent);
|
|
filter: drop-shadow(0 0 20px #ff4757);
|
|
animation: spellBlast 0.8s ease-out forwards, fireGlow 0.8s ease-out;
|
|
}
|
|
|
|
.fireball-projectile {
|
|
position: fixed;
|
|
width: 60px;
|
|
height: 60px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle at 30% 30%, #fff, #ffd700, #ff6b7a, #ff4757);
|
|
box-shadow: 0 0 40px #ff4757, 0 0 80px #ff6b7a, inset 0 0 20px #fff;
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.fireball-trail {
|
|
position: fixed;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle, #ff6b7a, transparent);
|
|
pointer-events: none;
|
|
z-index: 999;
|
|
animation: fadeTrail 0.5s ease-out forwards;
|
|
}
|
|
|
|
@keyframes fadeTrail {
|
|
0% {
|
|
opacity: 0.8;
|
|
transform: scale(1);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: scale(2);
|
|
}
|
|
}
|
|
|
|
.fireball-spark {
|
|
position: fixed;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: #ffd700;
|
|
box-shadow: 0 0 10px #ff4757;
|
|
pointer-events: none;
|
|
z-index: 998;
|
|
}
|
|
|
|
.lightning-bolt {
|
|
position: fixed;
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.lightning-segment {
|
|
position: absolute;
|
|
background: linear-gradient(90deg, transparent, #fff, #ffd700, #fff, transparent);
|
|
box-shadow: 0 0 20px #ffd700, 0 0 40px #ffed4e;
|
|
transform-origin: left center;
|
|
}
|
|
|
|
.lightning-spark {
|
|
position: fixed;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background: #fff;
|
|
box-shadow: 0 0 15px #ffd700, 0 0 30px #ffed4e;
|
|
pointer-events: none;
|
|
z-index: 999;
|
|
}
|
|
|
|
.lightning-flash {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.3);
|
|
pointer-events: none;
|
|
z-index: 998;
|
|
animation: lightningFlash 0.3s ease-out;
|
|
}
|
|
|
|
@keyframes lightningFlash {
|
|
0%, 100% { opacity: 0; }
|
|
50% { opacity: 1; }
|
|
}
|
|
|
|
.lightning-effect {
|
|
background: radial-gradient(circle, #ffd700, #ffed4e, #fff200, transparent);
|
|
filter: drop-shadow(0 0 25px #ffd700);
|
|
animation: spellBlast 0.8s ease-out forwards, lightningPulse 0.8s ease-out;
|
|
}
|
|
|
|
.meteor-effect {
|
|
background: radial-gradient(circle, #a29bfe, #6c5ce7, #5f3dc4, transparent);
|
|
filter: drop-shadow(0 0 30px #6c5ce7);
|
|
animation: spellBlast 0.8s ease-out forwards, meteorImpact 0.8s ease-out;
|
|
}
|
|
|
|
@keyframes spellBlast {
|
|
0% {
|
|
transform: scale(0);
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
transform: scale(1.5);
|
|
opacity: 0.8;
|
|
}
|
|
100% {
|
|
transform: scale(3);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
@keyframes fireGlow {
|
|
0%, 100% { filter: drop-shadow(0 0 20px #ff4757) hue-rotate(0deg); }
|
|
50% { filter: drop-shadow(0 0 40px #ff4757) hue-rotate(30deg); }
|
|
}
|
|
|
|
@keyframes lightningPulse {
|
|
0%, 100% { filter: drop-shadow(0 0 25px #ffd700) brightness(1); }
|
|
50% { filter: drop-shadow(0 0 50px #ffd700) brightness(2); }
|
|
}
|
|
|
|
@keyframes meteorImpact {
|
|
0% { filter: drop-shadow(0 0 30px #6c5ce7) contrast(1); }
|
|
30% { filter: drop-shadow(0 0 60px #6c5ce7) contrast(1.5); }
|
|
100% { filter: drop-shadow(0 0 30px #6c5ce7) contrast(1); }
|
|
}
|
|
|
|
@keyframes spellCharging {
|
|
0% {
|
|
box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
|
|
transform: scale(1.05);
|
|
}
|
|
100% {
|
|
box-shadow: 0 0 40px rgba(255, 215, 0, 0.8);
|
|
transform: scale(1.07);
|
|
}
|
|
}
|
|
|
|
.meteor-projectile {
|
|
position: fixed;
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle at 25% 25%, #fff, #a29bfe, #6c5ce7, #5f3dc4);
|
|
box-shadow: 0 0 50px #6c5ce7, 0 0 100px #a29bfe, inset 0 0 30px rgba(255, 255, 255, 0.5);
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.meteor-trail {
|
|
position: fixed;
|
|
width: 60px;
|
|
height: 60px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle, #a29bfe, #6c5ce7, transparent);
|
|
pointer-events: none;
|
|
z-index: 999;
|
|
animation: fadeTrail 0.6s ease-out forwards;
|
|
}
|
|
|
|
.meteor-debris {
|
|
position: fixed;
|
|
width: 10px;
|
|
height: 10px;
|
|
background: linear-gradient(135deg, #a29bfe, #6c5ce7);
|
|
box-shadow: 0 0 15px #6c5ce7;
|
|
pointer-events: none;
|
|
z-index: 998;
|
|
}
|
|
|
|
.meteor-spark {
|
|
position: fixed;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: #a29bfe;
|
|
box-shadow: 0 0 20px #6c5ce7, 0 0 40px #a29bfe;
|
|
pointer-events: none;
|
|
z-index: 997;
|
|
}
|
|
|
|
.meteor-shockwave {
|
|
position: fixed;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 3px solid #a29bfe;
|
|
border-radius: 50%;
|
|
box-shadow: 0 0 20px #6c5ce7, inset 0 0 20px #6c5ce7;
|
|
pointer-events: none;
|
|
z-index: 996;
|
|
transform: translate(-50%, -50%);
|
|
animation: meteorShockwave 0.8s ease-out forwards;
|
|
}
|
|
|
|
@keyframes meteorShockwave {
|
|
0% {
|
|
width: 20px;
|
|
height: 20px;
|
|
opacity: 1;
|
|
border-width: 5px;
|
|
}
|
|
50% {
|
|
opacity: 0.6;
|
|
border-width: 3px;
|
|
}
|
|
100% {
|
|
width: 200px;
|
|
height: 200px;
|
|
opacity: 0;
|
|
border-width: 1px;
|
|
}
|
|
}
|
|
|
|
.mini-enemy {
|
|
position: absolute;
|
|
width: 60px;
|
|
height: 60px;
|
|
background: linear-gradient(45deg, #ff9ff3, #f368e0);
|
|
border-radius: 50%;
|
|
font-size: 30px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
animation: miniEnemyFloat 3s ease-in-out infinite;
|
|
z-index: 100;
|
|
}
|
|
|
|
@keyframes miniEnemyFloat {
|
|
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
|
50% { transform: translateY(-15px) rotate(180deg); }
|
|
}
|
|
|
|
.magic-quirk {
|
|
position: fixed;
|
|
width: 200px;
|
|
height: 200px;
|
|
border-radius: 50%;
|
|
background: conic-gradient(from 0deg, #ff0080, #0080ff, #ff0080);
|
|
animation: magicQuirk 2s ease-in-out;
|
|
z-index: 1000;
|
|
pointer-events: none;
|
|
}
|
|
|
|
@keyframes magicQuirk {
|
|
0% {
|
|
transform: translate(-50%, -50%) scale(0) rotate(0deg);
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
transform: translate(-50%, -50%) scale(1.5) rotate(180deg);
|
|
opacity: 0.8;
|
|
}
|
|
100% {
|
|
transform: translate(-50%, -50%) scale(0) rotate(360deg);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
.flying-bird {
|
|
position: fixed;
|
|
font-size: 48px;
|
|
z-index: 500;
|
|
pointer-events: none;
|
|
width: 60px;
|
|
height: 60px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.bird-path-1 { animation: flyPath1 8s linear infinite; }
|
|
.bird-path-2 { animation: flyPath2 6s linear infinite; }
|
|
.bird-path-3 { animation: flyPath3 10s linear infinite; }
|
|
|
|
@keyframes flyPath1 {
|
|
0% { left: -100px; top: 20vh; transform: rotate(0deg) scale(1); }
|
|
25% { left: 30vw; top: 5vh; transform: rotate(180deg) scale(1.5); }
|
|
50% { left: 70vw; top: 40vh; transform: rotate(-90deg) scale(0.5); }
|
|
75% { left: 100vw; top: 15vh; transform: rotate(270deg) scale(2); }
|
|
100% { left: -100px; top: 20vh; transform: rotate(360deg) scale(1); }
|
|
}
|
|
|
|
@keyframes flyPath2 {
|
|
0% { left: 50vw; top: -80px; transform: rotate(0deg) scale(0.5); }
|
|
33% { left: 80vw; top: 20vh; transform: rotate(-225deg) scale(2.5); }
|
|
66% { left: 20vw; top: 50vh; transform: rotate(315deg) scale(0.3); }
|
|
100% { left: 50vw; top: -80px; transform: rotate(-630deg) scale(0.5); }
|
|
}
|
|
|
|
@keyframes flyPath3 {
|
|
0% { left: 120vw; top: 10vh; transform: rotate(0deg) scale(1); }
|
|
20% { left: 75vw; top: 70vh; transform: rotate(-360deg) scale(4); }
|
|
40% { left: 25vw; top: 20vh; transform: rotate(540deg) scale(0.2); }
|
|
60% { left: 85vw; top: 90vh; transform: rotate(-720deg) scale(3.5); }
|
|
80% { left: 10vw; top: 5vh; transform: rotate(900deg) scale(0.4); }
|
|
100% { left: 120vw; top: 10vh; transform: rotate(1800deg) scale(1); }
|
|
}
|
|
|
|
.screen-shake {
|
|
animation: screenShake 0.5s ease-in-out;
|
|
}
|
|
|
|
@keyframes screenShake {
|
|
0%, 100% { transform: translateX(0); }
|
|
10% { transform: translateX(-10px); }
|
|
20% { transform: translateX(10px); }
|
|
30% { transform: translateX(-10px); }
|
|
40% { transform: translateX(10px); }
|
|
50% { transform: translateX(-10px); }
|
|
60% { transform: translateX(10px); }
|
|
70% { transform: translateX(-10px); }
|
|
80% { transform: translateX(10px); }
|
|
90% { transform: translateX(-10px); }
|
|
}
|
|
|
|
.enemy-attack-warning {
|
|
position: absolute;
|
|
top: -30px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #ff4757;
|
|
color: white;
|
|
padding: 5px 15px;
|
|
border-radius: 15px;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
animation: warningPulse 1s ease-in-out infinite;
|
|
z-index: 100;
|
|
}
|
|
|
|
@keyframes warningPulse {
|
|
0%, 100% { opacity: 1; transform: translateX(-50%) scale(1); }
|
|
50% { opacity: 0.6; transform: translateX(-50%) scale(1.1); }
|
|
}
|
|
|
|
.enemy-attack-effect {
|
|
position: absolute;
|
|
width: 150px;
|
|
height: 150px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle, #ff4757, transparent);
|
|
animation: enemyAttackBlast 1s ease-out;
|
|
pointer-events: none;
|
|
z-index: 200;
|
|
}
|
|
|
|
@keyframes enemyAttackBlast {
|
|
0% { transform: scale(0); opacity: 1; }
|
|
50% { transform: scale(1.5); opacity: 0.8; }
|
|
100% { transform: scale(3); opacity: 0; }
|
|
}
|
|
|
|
.enemy-charging {
|
|
animation: enemyCharging 2s ease-in-out;
|
|
}
|
|
|
|
@keyframes enemyCharging {
|
|
0%, 100% {
|
|
background: linear-gradient(45deg, #ff4757, #ff6b7a);
|
|
transform: scale(1);
|
|
}
|
|
50% {
|
|
background: linear-gradient(45deg, #ff0000, #ff3333);
|
|
transform: scale(1.1);
|
|
box-shadow: 0 0 60px rgba(255, 0, 0, 0.8);
|
|
}
|
|
}
|
|
|
|
.victory-screen, .defeat-screen {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.8);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.result-title {
|
|
font-size: 48px;
|
|
margin-bottom: 20px;
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.victory-screen .result-title {
|
|
color: #2ed573;
|
|
}
|
|
|
|
.defeat-screen .result-title {
|
|
color: #ff4757;
|
|
}
|
|
|
|
.game-result-btn {
|
|
margin: 10px;
|
|
padding: 15px 30px;
|
|
border: 2px solid white;
|
|
border-radius: 10px;
|
|
background: transparent;
|
|
color: white;
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.game-result-btn.victory {
|
|
border-color: #2ed573;
|
|
color: #2ed573;
|
|
}
|
|
|
|
.game-result-btn.victory:hover {
|
|
background: #2ed573;
|
|
color: white;
|
|
}
|
|
|
|
.game-result-btn.defeat {
|
|
border-color: #ff4757;
|
|
color: #ff4757;
|
|
}
|
|
|
|
.game-result-btn.defeat:hover {
|
|
background: #ff4757;
|
|
color: white;
|
|
}
|
|
|
|
.fail-message {
|
|
position: fixed;
|
|
top: 30%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(255, 71, 87, 0.9);
|
|
color: white;
|
|
padding: 20px 30px;
|
|
border-radius: 15px;
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
z-index: 1000;
|
|
animation: failMessagePop 2s ease-out;
|
|
text-align: center;
|
|
border: 3px solid #ffd700;
|
|
}
|
|
|
|
@keyframes failMessagePop {
|
|
0% { transform: translateX(-50%) scale(0); opacity: 0; }
|
|
20% { transform: translateX(-50%) scale(1.2); opacity: 1; }
|
|
80% { transform: translateX(-50%) scale(1); opacity: 1; }
|
|
100% { transform: translateX(-50%) scale(0.8); opacity: 0; }
|
|
}
|
|
|
|
.game-error {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
text-align: center;
|
|
padding: 40px;
|
|
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
|
color: white;
|
|
}
|
|
|
|
.game-error h3 {
|
|
font-size: 2rem;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.back-btn {
|
|
padding: 12px 25px;
|
|
background: white;
|
|
color: #ef4444;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.back-btn:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.wizard-game-wrapper {
|
|
padding: 10px;
|
|
}
|
|
|
|
.battle-area {
|
|
height: 50vh;
|
|
padding: 10px;
|
|
}
|
|
|
|
.wizard-character, .enemy-character {
|
|
width: 80px;
|
|
height: 80px;
|
|
font-size: 32px;
|
|
}
|
|
|
|
.spell-selection {
|
|
grid-template-columns: 1fr;
|
|
gap: 10px;
|
|
}
|
|
|
|
.word-bank {
|
|
gap: 5px;
|
|
}
|
|
|
|
.word-tile {
|
|
padding: 6px 12px;
|
|
font-size: 0.9rem;
|
|
}
|
|
}
|
|
`;
|
|
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
_removeCSS() {
|
|
const cssId = `wizard-spell-caster-styles-${this.name}`;
|
|
const existingStyle = document.getElementById(cssId);
|
|
if (existingStyle) {
|
|
existingStyle.remove();
|
|
}
|
|
}
|
|
|
|
_createGameInterface() {
|
|
this._config.container.innerHTML = `
|
|
<div class="wizard-game-wrapper">
|
|
<div class="wizard-hud">
|
|
<div class="wizard-stats">
|
|
<div>
|
|
<div>Wizard HP</div>
|
|
<div class="health-bar">
|
|
<div class="health-fill" id="player-health" style="width: 100%"></div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div>Score: <span id="current-score">0</span></div>
|
|
</div>
|
|
</div>
|
|
<div class="wizard-stats">
|
|
<div>
|
|
<div>Enemy HP</div>
|
|
<div class="health-bar">
|
|
<div class="health-fill" id="enemy-health" style="width: 100%"></div>
|
|
</div>
|
|
</div>
|
|
<button class="exit-wizard-btn" id="exit-wizard">
|
|
<span>←</span> Exit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="battle-area">
|
|
<div class="wizard-side">
|
|
<div class="wizard-character">🧙♂️</div>
|
|
<div>Wizard Master</div>
|
|
</div>
|
|
<div class="enemy-side">
|
|
<div class="enemy-character">👹</div>
|
|
<div>Grammar Demon</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="spell-casting-area">
|
|
<div class="spell-selection" id="spell-selection">
|
|
<!-- Spell cards will be populated here -->
|
|
</div>
|
|
|
|
<div class="sentence-builder" id="sentence-builder">
|
|
<div style="color: #ffd700; margin-bottom: 5px; font-size: 0.9em;">Form your spell incantation:</div>
|
|
<div id="current-sentence" style="font-size: 16px; min-height: 24px;"></div>
|
|
</div>
|
|
|
|
<div class="word-bank" id="word-bank">
|
|
<!-- Words will be populated here -->
|
|
</div>
|
|
|
|
<button class="cast-button" id="cast-button" disabled>🔥 CAST SPELL 🔥</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_setupEventListeners() {
|
|
// Cast button
|
|
document.getElementById('cast-button').addEventListener('click', () => this._castSpell());
|
|
|
|
// Exit button
|
|
const exitButton = document.getElementById('exit-wizard');
|
|
if (exitButton) {
|
|
exitButton.addEventListener('click', () => {
|
|
this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name);
|
|
});
|
|
}
|
|
}
|
|
|
|
_generateNewSpells() {
|
|
this._currentSpells = [];
|
|
|
|
// Get one spell of each type
|
|
if (this._spells.short.length > 0) {
|
|
this._currentSpells.push({
|
|
...this._spells.short[Math.floor(Math.random() * this._spells.short.length)],
|
|
type: 'short',
|
|
name: 'Fireball',
|
|
icon: '🔥'
|
|
});
|
|
}
|
|
|
|
if (this._spells.medium.length > 0) {
|
|
this._currentSpells.push({
|
|
...this._spells.medium[Math.floor(Math.random() * this._spells.medium.length)],
|
|
type: 'medium',
|
|
name: 'Lightning',
|
|
icon: '⚡'
|
|
});
|
|
}
|
|
|
|
if (this._spells.long.length > 0) {
|
|
this._currentSpells.push({
|
|
...this._spells.long[Math.floor(Math.random() * this._spells.long.length)],
|
|
type: 'long',
|
|
name: 'Meteor',
|
|
icon: '☄️'
|
|
});
|
|
}
|
|
|
|
this._renderSpellCards();
|
|
this._selectedSpell = null;
|
|
this._selectedWords = [];
|
|
this._updateWordBank();
|
|
this._updateSentenceBuilder();
|
|
}
|
|
|
|
_renderSpellCards() {
|
|
const container = document.getElementById('spell-selection');
|
|
if (!container) {
|
|
console.warn('Spell selection container not found, skipping render');
|
|
return;
|
|
}
|
|
container.innerHTML = this._currentSpells.map((spell, index) => `
|
|
<div class="spell-card" data-spell-index="${index}">
|
|
<div class="spell-type">${spell.icon} ${spell.name}</div>
|
|
<div style="margin: 10px 0; font-size: 14px;">${spell.translation}</div>
|
|
<div class="spell-damage">${spell.damage} damage</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Add click listeners
|
|
container.querySelectorAll('.spell-card').forEach(card => {
|
|
card.addEventListener('click', (e) => {
|
|
const spellIndex = parseInt(e.currentTarget.dataset.spellIndex);
|
|
this._selectSpell(spellIndex);
|
|
});
|
|
});
|
|
}
|
|
|
|
_selectSpell(index) {
|
|
// Remove previous selection
|
|
document.querySelectorAll('.spell-card').forEach(card => card.classList.remove('selected'));
|
|
|
|
// Select new spell
|
|
this._selectedSpell = this._currentSpells[index];
|
|
document.querySelector(`[data-spell-index="${index}"]`).classList.add('selected');
|
|
|
|
// Start timing for speed bonus
|
|
this._spellStartTime = Date.now();
|
|
|
|
// Reset word selection
|
|
this._selectedWords = [];
|
|
this._updateWordBank();
|
|
this._updateSentenceBuilder();
|
|
}
|
|
|
|
_updateWordBank() {
|
|
const container = document.getElementById('word-bank');
|
|
|
|
if (!this._selectedSpell) {
|
|
container.innerHTML = '<div style="color: #ffd700;">Select a spell first</div>';
|
|
return;
|
|
}
|
|
|
|
// Get words and add punctuation
|
|
const words = [...this._selectedSpell.words];
|
|
|
|
// Shuffle the words including punctuation
|
|
const shuffledWords = [...words].sort(() => Math.random() - 0.5);
|
|
|
|
container.innerHTML = shuffledWords.map((wordData, index) => {
|
|
const uniqueId = wordData.uniqueId || `word_${index}_${wordData.word}`;
|
|
return `
|
|
<div class="word-tile" data-word-index="${index}" data-word="${wordData.word}" data-unique-id="${uniqueId}">
|
|
${wordData.word}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Add click listeners
|
|
container.querySelectorAll('.word-tile').forEach(tile => {
|
|
tile.addEventListener('click', (e) => {
|
|
const word = e.currentTarget.dataset.word;
|
|
const uniqueId = e.currentTarget.dataset.uniqueId;
|
|
this._toggleWord(word, e.currentTarget, uniqueId);
|
|
});
|
|
});
|
|
}
|
|
|
|
_toggleWord(word, element, uniqueId) {
|
|
const wordIndex = this._selectedWords.findIndex(selectedWord =>
|
|
selectedWord.uniqueId === uniqueId
|
|
);
|
|
|
|
if (wordIndex > -1) {
|
|
// Remove word
|
|
this._selectedWords.splice(wordIndex, 1);
|
|
element.classList.remove('selected');
|
|
} else {
|
|
// Add word with unique ID
|
|
this._selectedWords.push({
|
|
word: word,
|
|
uniqueId: uniqueId
|
|
});
|
|
element.classList.add('selected');
|
|
}
|
|
|
|
this._updateSentenceBuilder();
|
|
this._updateCastButton();
|
|
}
|
|
|
|
_updateSentenceBuilder() {
|
|
const container = document.getElementById('current-sentence');
|
|
const sentence = this._buildSentenceFromWords(this._selectedWords);
|
|
container.textContent = sentence;
|
|
}
|
|
|
|
_buildSentenceFromWords(words) {
|
|
let sentence = '';
|
|
for (let i = 0; i < words.length; i++) {
|
|
const wordText = typeof words[i] === 'string' ? words[i] : words[i].word;
|
|
const isPunctuation = ['.', '!', '?', ',', ';', ':'].includes(wordText);
|
|
|
|
if (i === 0) {
|
|
sentence = wordText;
|
|
} else if (isPunctuation) {
|
|
sentence += wordText; // No space before punctuation
|
|
} else {
|
|
sentence += ' ' + wordText; // Space before regular words
|
|
}
|
|
}
|
|
return sentence;
|
|
}
|
|
|
|
_updateCastButton() {
|
|
const button = document.getElementById('cast-button');
|
|
button.disabled = false;
|
|
|
|
if (this._selectedSpell) {
|
|
button.textContent = `🔥 CAST ${this._selectedSpell.name.toUpperCase()} 🔥`;
|
|
} else {
|
|
button.textContent = '🔥 CAST SPELL 🔥';
|
|
}
|
|
}
|
|
|
|
_castSpell() {
|
|
if (!this._selectedSpell) {
|
|
this._showFailEffect('noSpell');
|
|
return;
|
|
}
|
|
|
|
// Check if spell is correctly formed
|
|
const expectedSentence = this._selectedSpell.english;
|
|
const playerSentence = this._buildSentenceFromWords(this._selectedWords);
|
|
const isCorrect = playerSentence === expectedSentence;
|
|
|
|
if (isCorrect) {
|
|
// Capture spell data before it gets reset by _generateNewSpells()
|
|
const spellType = this._selectedSpell.type;
|
|
const spellDamage = this._selectedSpell.damage;
|
|
const spellData = { ...this._selectedSpell };
|
|
|
|
// Successful cast!
|
|
this._showCastingEffect(spellType);
|
|
|
|
setTimeout(() => {
|
|
this._showSpellEffect(spellType);
|
|
}, 500);
|
|
|
|
// Deal damage
|
|
this._enemyHP = Math.max(0, this._enemyHP - spellDamage);
|
|
this._updateEnemyHealth();
|
|
this._showDamageNumber(spellDamage);
|
|
|
|
// Update score with bonuses
|
|
const wordCount = this._selectedWords.length;
|
|
let scoreMultiplier = 10;
|
|
if (wordCount >= 7) scoreMultiplier = 20;
|
|
else if (wordCount >= 5) scoreMultiplier = 15;
|
|
|
|
// Speed bonus
|
|
let speedBonus = 0;
|
|
if (this._spellStartTime) {
|
|
const spellTime = (Date.now() - this._spellStartTime) / 1000;
|
|
this._spellCount++;
|
|
this._averageSpellTime = ((this._averageSpellTime * (this._spellCount - 1)) + spellTime) / this._spellCount;
|
|
|
|
if (spellTime < 10) speedBonus = Math.floor((10 - spellTime) * 50);
|
|
if (spellTime < 5) speedBonus += 300;
|
|
if (spellTime < 3) speedBonus += 500;
|
|
}
|
|
|
|
this._score += (spellDamage * scoreMultiplier) + speedBonus;
|
|
document.getElementById('current-score').textContent = this._score;
|
|
|
|
// Emit spell cast event
|
|
this._eventBus.emit('wizard-spell-caster:spell-cast', {
|
|
gameId: 'wizard-spell-caster',
|
|
instanceId: this.name,
|
|
spell: spellData,
|
|
damage: spellDamage,
|
|
score: this._score,
|
|
speedBonus
|
|
}, this.name);
|
|
|
|
// Check win condition
|
|
if (this._enemyHP <= 0) {
|
|
this._handleVictory();
|
|
return;
|
|
}
|
|
|
|
// Generate new spells for next round
|
|
setTimeout(() => {
|
|
this._generateNewSpells();
|
|
this._spellStartTime = Date.now();
|
|
}, 1000);
|
|
} else {
|
|
// Spell failed!
|
|
this._showFailEffect();
|
|
}
|
|
}
|
|
|
|
_showSpellEffect(type) {
|
|
const enemyChar = document.querySelector('.enemy-character');
|
|
if (!enemyChar) {
|
|
console.warn('Enemy character not found, skipping spell effect');
|
|
return;
|
|
}
|
|
const rect = enemyChar.getBoundingClientRect();
|
|
|
|
// Special animations for different spell types
|
|
if (type === 'short' || type === 'fire') {
|
|
this._launchFireball(rect);
|
|
} else if (type === 'medium' || type === 'lightning') {
|
|
this._launchLightning(rect);
|
|
} else if (type === 'long' || type === 'meteor') {
|
|
this._launchMeteor(rect);
|
|
} else {
|
|
// Main spell effect for other types
|
|
const effect = document.createElement('div');
|
|
effect.className = `spell-effect ${type}-effect`;
|
|
effect.style.position = 'fixed';
|
|
effect.style.left = rect.left + rect.width/2 - 50 + 'px';
|
|
effect.style.top = rect.top + rect.height/2 - 50 + 'px';
|
|
document.body.appendChild(effect);
|
|
|
|
setTimeout(() => {
|
|
effect.remove();
|
|
}, 800);
|
|
}
|
|
|
|
// Enhanced effects based on spell type
|
|
this._createSpellParticles(type, rect);
|
|
this._triggerSpellAnimation(type, enemyChar);
|
|
}
|
|
|
|
_launchFireball(targetRect) {
|
|
const wizardChar = document.querySelector('.wizard-character');
|
|
const wizardRect = wizardChar.getBoundingClientRect();
|
|
|
|
// Create fireball
|
|
const fireball = document.createElement('div');
|
|
fireball.className = 'fireball-projectile';
|
|
|
|
const startX = wizardRect.left + wizardRect.width / 2 - 30;
|
|
const startY = wizardRect.top + wizardRect.height / 2 - 30;
|
|
const endX = targetRect.left + targetRect.width / 2 - 30;
|
|
const endY = targetRect.top + targetRect.height / 2 - 30;
|
|
|
|
fireball.style.left = startX + 'px';
|
|
fireball.style.top = startY + 'px';
|
|
|
|
document.body.appendChild(fireball);
|
|
|
|
// Animation parameters
|
|
const duration = 500; // milliseconds
|
|
const startTime = Date.now();
|
|
const trailInterval = 30; // Create trail every 30ms
|
|
let lastTrailTime = 0;
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Easing function (ease-in-out)
|
|
const eased = progress < 0.5
|
|
? 2 * progress * progress
|
|
: -1 + (4 - 2 * progress) * progress;
|
|
|
|
// Calculate current position
|
|
const currentX = startX + (endX - startX) * eased;
|
|
const currentY = startY + (endY - startY) * eased;
|
|
|
|
fireball.style.left = currentX + 'px';
|
|
fireball.style.top = currentY + 'px';
|
|
|
|
// Create trail effect
|
|
if (elapsed - lastTrailTime > trailInterval) {
|
|
this._createFireballTrail(currentX + 30, currentY + 30);
|
|
this._createFireballSparks(currentX + 30, currentY + 30);
|
|
lastTrailTime = elapsed;
|
|
}
|
|
|
|
// Continue animation or finish
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
// Impact effect
|
|
fireball.remove();
|
|
this._createFireballImpact(endX + 30, endY + 30);
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
_createFireballTrail(x, y) {
|
|
const trail = document.createElement('div');
|
|
trail.className = 'fireball-trail';
|
|
trail.style.left = (x - 20) + 'px';
|
|
trail.style.top = (y - 20) + 'px';
|
|
document.body.appendChild(trail);
|
|
|
|
setTimeout(() => trail.remove(), 500);
|
|
}
|
|
|
|
_createFireballSparks(x, y) {
|
|
const sparkCount = 3;
|
|
for (let i = 0; i < sparkCount; i++) {
|
|
const spark = document.createElement('div');
|
|
spark.className = 'fireball-spark';
|
|
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const distance = 20 + Math.random() * 30;
|
|
const sparkX = x + Math.cos(angle) * distance;
|
|
const sparkY = y + Math.sin(angle) * distance;
|
|
|
|
spark.style.left = sparkX + 'px';
|
|
spark.style.top = sparkY + 'px';
|
|
|
|
document.body.appendChild(spark);
|
|
|
|
// Animate spark
|
|
const duration = 300 + Math.random() * 200;
|
|
const startTime = Date.now();
|
|
const startSparkX = sparkX;
|
|
const startSparkY = sparkY;
|
|
const velocityX = (Math.random() - 0.5) * 100;
|
|
const velocityY = Math.random() * 50 - 100;
|
|
|
|
const animateSpark = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = elapsed / duration;
|
|
|
|
if (progress < 1) {
|
|
const newX = startSparkX + velocityX * progress;
|
|
const newY = startSparkY + velocityY * progress + (progress * progress * 100);
|
|
spark.style.left = newX + 'px';
|
|
spark.style.top = newY + 'px';
|
|
spark.style.opacity = 1 - progress;
|
|
requestAnimationFrame(animateSpark);
|
|
} else {
|
|
spark.remove();
|
|
}
|
|
};
|
|
|
|
animateSpark();
|
|
}
|
|
}
|
|
|
|
_createFireballImpact(x, y) {
|
|
// Main explosion
|
|
const explosion = document.createElement('div');
|
|
explosion.className = 'spell-effect fire-effect';
|
|
explosion.style.position = 'fixed';
|
|
explosion.style.left = (x - 50) + 'px';
|
|
explosion.style.top = (y - 50) + 'px';
|
|
document.body.appendChild(explosion);
|
|
|
|
setTimeout(() => explosion.remove(), 800);
|
|
|
|
// Impact sparks
|
|
const impactSparkCount = 12;
|
|
for (let i = 0; i < impactSparkCount; i++) {
|
|
const angle = (i / impactSparkCount) * Math.PI * 2;
|
|
const spark = document.createElement('div');
|
|
spark.className = 'fireball-spark';
|
|
spark.style.left = x + 'px';
|
|
spark.style.top = y + 'px';
|
|
spark.style.width = '12px';
|
|
spark.style.height = '12px';
|
|
|
|
document.body.appendChild(spark);
|
|
|
|
const distance = 60 + Math.random() * 40;
|
|
const duration = 400;
|
|
const startTime = Date.now();
|
|
|
|
const animateImpactSpark = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = elapsed / duration;
|
|
|
|
if (progress < 1) {
|
|
const currentDistance = distance * progress;
|
|
const newX = x + Math.cos(angle) * currentDistance;
|
|
const newY = y + Math.sin(angle) * currentDistance;
|
|
spark.style.left = newX + 'px';
|
|
spark.style.top = newY + 'px';
|
|
spark.style.opacity = 1 - progress;
|
|
spark.style.transform = `scale(${1 - progress * 0.5})`;
|
|
requestAnimationFrame(animateImpactSpark);
|
|
} else {
|
|
spark.remove();
|
|
}
|
|
};
|
|
|
|
animateImpactSpark();
|
|
}
|
|
}
|
|
|
|
_launchLightning(targetRect) {
|
|
const wizardChar = document.querySelector('.wizard-character');
|
|
const wizardRect = wizardChar.getBoundingClientRect();
|
|
|
|
const startX = wizardRect.left + wizardRect.width / 2;
|
|
const startY = wizardRect.top + wizardRect.height / 2;
|
|
const endX = targetRect.left + targetRect.width / 2;
|
|
const endY = targetRect.top + targetRect.height / 2;
|
|
|
|
// Screen flash
|
|
const flash = document.createElement('div');
|
|
flash.className = 'lightning-flash';
|
|
document.body.appendChild(flash);
|
|
setTimeout(() => flash.remove(), 300);
|
|
|
|
// Create lightning bolt with jagged segments
|
|
const boltContainer = document.createElement('div');
|
|
boltContainer.className = 'lightning-bolt';
|
|
document.body.appendChild(boltContainer);
|
|
|
|
const segments = 8;
|
|
const points = [{ x: startX, y: startY }];
|
|
|
|
// Generate jagged lightning path
|
|
for (let i = 1; i < segments; i++) {
|
|
const progress = i / segments;
|
|
const baseX = startX + (endX - startX) * progress;
|
|
const baseY = startY + (endY - startY) * progress;
|
|
|
|
// Add random offset for jagged effect
|
|
const offsetX = (Math.random() - 0.5) * 60;
|
|
const offsetY = (Math.random() - 0.5) * 60;
|
|
|
|
points.push({
|
|
x: baseX + offsetX,
|
|
y: baseY + offsetY
|
|
});
|
|
}
|
|
|
|
points.push({ x: endX, y: endY });
|
|
|
|
// Draw lightning segments
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const segment = document.createElement('div');
|
|
segment.className = 'lightning-segment';
|
|
|
|
const dx = points[i + 1].x - points[i].x;
|
|
const dy = points[i + 1].y - points[i].y;
|
|
const length = Math.sqrt(dx * dx + dy * dy);
|
|
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
|
|
|
segment.style.left = points[i].x + 'px';
|
|
segment.style.top = points[i].y + 'px';
|
|
segment.style.width = length + 'px';
|
|
segment.style.height = (3 + Math.random() * 3) + 'px';
|
|
segment.style.transform = `rotate(${angle}deg)`;
|
|
|
|
boltContainer.appendChild(segment);
|
|
}
|
|
|
|
// Create electric sparks along the path
|
|
for (let i = 0; i < 15; i++) {
|
|
setTimeout(() => {
|
|
const pointIndex = Math.floor(Math.random() * points.length);
|
|
const point = points[pointIndex];
|
|
this._createLightningSpark(point.x, point.y);
|
|
}, i * 30);
|
|
}
|
|
|
|
// Remove lightning bolt after animation
|
|
setTimeout(() => {
|
|
boltContainer.remove();
|
|
}, 400);
|
|
|
|
// Impact effect
|
|
setTimeout(() => {
|
|
this._createLightningImpact(endX, endY);
|
|
}, 200);
|
|
}
|
|
|
|
_createLightningSpark(x, y) {
|
|
const spark = document.createElement('div');
|
|
spark.className = 'lightning-spark';
|
|
spark.style.left = x + 'px';
|
|
spark.style.top = y + 'px';
|
|
document.body.appendChild(spark);
|
|
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const distance = 30 + Math.random() * 40;
|
|
const duration = 300;
|
|
const startTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = elapsed / duration;
|
|
|
|
if (progress < 1) {
|
|
const currentDist = distance * progress;
|
|
const newX = x + Math.cos(angle) * currentDist;
|
|
const newY = y + Math.sin(angle) * currentDist;
|
|
spark.style.left = newX + 'px';
|
|
spark.style.top = newY + 'px';
|
|
spark.style.opacity = 1 - progress;
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
spark.remove();
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
_createLightningImpact(x, y) {
|
|
// Main impact explosion
|
|
const explosion = document.createElement('div');
|
|
explosion.className = 'spell-effect lightning-effect';
|
|
explosion.style.position = 'fixed';
|
|
explosion.style.left = (x - 50) + 'px';
|
|
explosion.style.top = (y - 50) + 'px';
|
|
document.body.appendChild(explosion);
|
|
|
|
setTimeout(() => explosion.remove(), 800);
|
|
|
|
// Electric burst
|
|
for (let i = 0; i < 20; i++) {
|
|
const spark = document.createElement('div');
|
|
spark.className = 'lightning-spark';
|
|
const angle = (i / 20) * Math.PI * 2;
|
|
const distance = 40 + Math.random() * 60;
|
|
|
|
spark.style.left = x + 'px';
|
|
spark.style.top = y + 'px';
|
|
document.body.appendChild(spark);
|
|
|
|
const duration = 400 + Math.random() * 200;
|
|
const startTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = elapsed / duration;
|
|
|
|
if (progress < 1) {
|
|
const currentDist = distance * progress;
|
|
const newX = x + Math.cos(angle) * currentDist;
|
|
const newY = y + Math.sin(angle) * currentDist;
|
|
spark.style.left = newX + 'px';
|
|
spark.style.top = newY + 'px';
|
|
spark.style.opacity = 1 - progress;
|
|
spark.style.transform = `scale(${1 - progress * 0.7})`;
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
spark.remove();
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
}
|
|
|
|
_launchMeteor(targetRect) {
|
|
// Create meteor starting from top of screen
|
|
const meteor = document.createElement('div');
|
|
meteor.className = 'meteor-projectile';
|
|
|
|
// Random horizontal start position (from above the screen)
|
|
const startX = targetRect.left + targetRect.width / 2 + (Math.random() - 0.5) * 300;
|
|
const startY = -100; // Start above viewport
|
|
const endX = targetRect.left + targetRect.width / 2 - 40;
|
|
const endY = targetRect.top + targetRect.height / 2 - 40;
|
|
|
|
meteor.style.left = startX + 'px';
|
|
meteor.style.top = startY + 'px';
|
|
|
|
document.body.appendChild(meteor);
|
|
|
|
// Animation parameters
|
|
const duration = 800; // milliseconds
|
|
const startTime = Date.now();
|
|
const trailInterval = 25;
|
|
let lastTrailTime = 0;
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Accelerating fall (quadratic easing)
|
|
const eased = progress * progress;
|
|
|
|
// Calculate current position
|
|
const currentX = startX + (endX - startX) * progress;
|
|
const currentY = startY + (endY - startY) * eased;
|
|
|
|
meteor.style.left = currentX + 'px';
|
|
meteor.style.top = currentY + 'px';
|
|
|
|
// Rotate meteor as it falls
|
|
meteor.style.transform = `rotate(${progress * 360}deg) scale(${1 + progress * 0.5})`;
|
|
|
|
// Create trail effect
|
|
if (elapsed - lastTrailTime > trailInterval) {
|
|
this._createMeteorTrail(currentX + 40, currentY + 40);
|
|
this._createMeteorDebris(currentX + 40, currentY + 40);
|
|
lastTrailTime = elapsed;
|
|
}
|
|
|
|
// Continue animation or finish
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
// Impact effect
|
|
meteor.remove();
|
|
this._createMeteorImpact(endX + 40, endY + 40);
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
_createMeteorTrail(x, y) {
|
|
const trail = document.createElement('div');
|
|
trail.className = 'meteor-trail';
|
|
trail.style.left = (x - 30) + 'px';
|
|
trail.style.top = (y - 30) + 'px';
|
|
document.body.appendChild(trail);
|
|
|
|
setTimeout(() => trail.remove(), 600);
|
|
}
|
|
|
|
_createMeteorDebris(x, y) {
|
|
const debrisCount = 2;
|
|
for (let i = 0; i < debrisCount; i++) {
|
|
const debris = document.createElement('div');
|
|
debris.className = 'meteor-debris';
|
|
|
|
// Debris flies off to the sides
|
|
const angle = Math.PI / 2 + (Math.random() - 0.5) * Math.PI;
|
|
const distance = 30 + Math.random() * 40;
|
|
|
|
debris.style.left = x + 'px';
|
|
debris.style.top = y + 'px';
|
|
|
|
document.body.appendChild(debris);
|
|
|
|
const duration = 400 + Math.random() * 200;
|
|
const startTime = Date.now();
|
|
|
|
const animateDebris = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = elapsed / duration;
|
|
|
|
if (progress < 1) {
|
|
const currentDist = distance * progress;
|
|
const newX = x + Math.cos(angle) * currentDist;
|
|
const newY = y + Math.sin(angle) * currentDist + (progress * progress * 80);
|
|
debris.style.left = newX + 'px';
|
|
debris.style.top = newY + 'px';
|
|
debris.style.opacity = 1 - progress;
|
|
debris.style.transform = `rotate(${progress * 720}deg) scale(${1 - progress})`;
|
|
requestAnimationFrame(animateDebris);
|
|
} else {
|
|
debris.remove();
|
|
}
|
|
};
|
|
|
|
animateDebris();
|
|
}
|
|
}
|
|
|
|
_createMeteorImpact(x, y) {
|
|
// Screen shake on impact
|
|
document.body.classList.add('screen-shake');
|
|
setTimeout(() => document.body.classList.remove('screen-shake'), 500);
|
|
|
|
// Main explosion crater effect
|
|
const explosion = document.createElement('div');
|
|
explosion.className = 'spell-effect meteor-effect';
|
|
explosion.style.position = 'fixed';
|
|
explosion.style.left = (x - 50) + 'px';
|
|
explosion.style.top = (y - 50) + 'px';
|
|
document.body.appendChild(explosion);
|
|
|
|
setTimeout(() => explosion.remove(), 800);
|
|
|
|
// Shockwave rings
|
|
for (let i = 0; i < 3; i++) {
|
|
setTimeout(() => {
|
|
const shockwave = document.createElement('div');
|
|
shockwave.className = 'meteor-shockwave';
|
|
shockwave.style.left = x + 'px';
|
|
shockwave.style.top = y + 'px';
|
|
document.body.appendChild(shockwave);
|
|
|
|
setTimeout(() => shockwave.remove(), 800);
|
|
}, i * 100);
|
|
}
|
|
|
|
// Impact debris explosion
|
|
const debrisCount = 20;
|
|
for (let i = 0; i < debrisCount; i++) {
|
|
const debris = document.createElement('div');
|
|
debris.className = 'meteor-debris';
|
|
const angle = (i / debrisCount) * Math.PI * 2;
|
|
const distance = 50 + Math.random() * 80;
|
|
|
|
debris.style.left = x + 'px';
|
|
debris.style.top = y + 'px';
|
|
debris.style.width = (6 + Math.random() * 8) + 'px';
|
|
debris.style.height = (6 + Math.random() * 8) + 'px';
|
|
|
|
document.body.appendChild(debris);
|
|
|
|
const duration = 600 + Math.random() * 400;
|
|
const startTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = elapsed / duration;
|
|
|
|
if (progress < 1) {
|
|
const currentDist = distance * progress;
|
|
const newX = x + Math.cos(angle) * currentDist;
|
|
const newY = y + Math.sin(angle) * currentDist - (50 * progress) + (progress * progress * 100);
|
|
debris.style.left = newX + 'px';
|
|
debris.style.top = newY + 'px';
|
|
debris.style.opacity = 1 - progress;
|
|
debris.style.transform = `rotate(${progress * 1080}deg) scale(${1 - progress * 0.5})`;
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
debris.remove();
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
// Purple impact sparks
|
|
for (let i = 0; i < 12; i++) {
|
|
const spark = document.createElement('div');
|
|
spark.className = 'meteor-spark';
|
|
const angle = (i / 12) * Math.PI * 2;
|
|
const distance = 60 + Math.random() * 50;
|
|
|
|
spark.style.left = x + 'px';
|
|
spark.style.top = y + 'px';
|
|
|
|
document.body.appendChild(spark);
|
|
|
|
const duration = 500;
|
|
const startTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = elapsed / duration;
|
|
|
|
if (progress < 1) {
|
|
const currentDist = distance * (1 - Math.pow(1 - progress, 3));
|
|
const newX = x + Math.cos(angle) * currentDist;
|
|
const newY = y + Math.sin(angle) * currentDist;
|
|
spark.style.left = newX + 'px';
|
|
spark.style.top = newY + 'px';
|
|
spark.style.opacity = 1 - progress;
|
|
spark.style.transform = `scale(${1 - progress * 0.8})`;
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
spark.remove();
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
}
|
|
|
|
_createSpellParticles(type, enemyRect) {
|
|
const particleCount = type === 'meteor' ? 15 : type === 'lightning' ? 12 : 8;
|
|
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const particle = document.createElement('div');
|
|
particle.className = `spell-particle ${type}-particle`;
|
|
|
|
const offsetX = (Math.random() - 0.5) * 200;
|
|
const offsetY = (Math.random() - 0.5) * 200;
|
|
|
|
particle.style.position = 'fixed';
|
|
particle.style.left = enemyRect.left + enemyRect.width/2 + offsetX + 'px';
|
|
particle.style.top = enemyRect.top + enemyRect.height/2 + offsetY + 'px';
|
|
particle.style.width = '6px';
|
|
particle.style.height = '6px';
|
|
particle.style.borderRadius = '50%';
|
|
particle.style.pointerEvents = 'none';
|
|
particle.style.zIndex = '1000';
|
|
|
|
if (type === 'fire') {
|
|
particle.style.background = 'radial-gradient(circle, #ff6b7a, #ff4757)';
|
|
particle.style.boxShadow = '0 0 10px #ff4757';
|
|
} else if (type === 'lightning') {
|
|
particle.style.background = 'radial-gradient(circle, #ffd700, #ffed4e)';
|
|
particle.style.boxShadow = '0 0 15px #ffd700';
|
|
} else if (type === 'meteor') {
|
|
particle.style.background = 'radial-gradient(circle, #a29bfe, #6c5ce7)';
|
|
particle.style.boxShadow = '0 0 20px #6c5ce7';
|
|
}
|
|
|
|
document.body.appendChild(particle);
|
|
|
|
setTimeout(() => {
|
|
particle.remove();
|
|
}, 1500);
|
|
}
|
|
}
|
|
|
|
_triggerSpellAnimation(type, enemyChar) {
|
|
if (type === 'meteor') {
|
|
document.body.classList.add('screen-shake');
|
|
setTimeout(() => document.body.classList.remove('screen-shake'), 500);
|
|
}
|
|
|
|
// Enemy hit reaction
|
|
enemyChar.style.transform = 'scale(1.1)';
|
|
enemyChar.style.filter = type === 'fire' ? 'hue-rotate(30deg)' :
|
|
type === 'lightning' ? 'brightness(1.5)' :
|
|
'contrast(1.3)';
|
|
|
|
setTimeout(() => {
|
|
enemyChar.style.transform = '';
|
|
enemyChar.style.filter = '';
|
|
}, 300);
|
|
}
|
|
|
|
_showCastingEffect(spellType) {
|
|
const wizardChar = document.querySelector('.wizard-character');
|
|
|
|
// Wizard glow effect
|
|
wizardChar.style.filter = 'drop-shadow(0 0 20px #ffd700)';
|
|
wizardChar.style.transform = 'scale(1.05)';
|
|
|
|
setTimeout(() => {
|
|
wizardChar.style.filter = '';
|
|
wizardChar.style.transform = '';
|
|
}, 600);
|
|
}
|
|
|
|
_showDamageNumber(damage) {
|
|
const damageEl = document.createElement('div');
|
|
damageEl.className = 'damage-number';
|
|
damageEl.textContent = `-${damage}`;
|
|
|
|
const enemyChar = document.querySelector('.enemy-character');
|
|
const rect = enemyChar.getBoundingClientRect();
|
|
|
|
damageEl.style.position = 'fixed';
|
|
damageEl.style.left = rect.left + rect.width/2 + 'px';
|
|
damageEl.style.top = rect.top + 'px';
|
|
|
|
document.body.appendChild(damageEl);
|
|
|
|
setTimeout(() => {
|
|
damageEl.remove();
|
|
}, 1500);
|
|
}
|
|
|
|
_showFailEffect(type = 'random') {
|
|
const effects = ['spawnMinion', 'loseHP', 'magicQuirk', 'flyingBirds'];
|
|
const selectedEffect = type === 'random' ?
|
|
effects[Math.floor(Math.random() * effects.length)] :
|
|
type;
|
|
|
|
this._showFailMessage();
|
|
|
|
switch(selectedEffect) {
|
|
case 'spawnMinion':
|
|
this._spawnMiniEnemy();
|
|
break;
|
|
case 'loseHP':
|
|
this._wizardTakesDamage();
|
|
break;
|
|
case 'magicQuirk':
|
|
this._triggerMagicQuirk();
|
|
break;
|
|
case 'flyingBirds':
|
|
this._summonFlyingBirds();
|
|
break;
|
|
case 'noSpell':
|
|
this._showFailMessage('Select a spell first! 🪄');
|
|
break;
|
|
}
|
|
}
|
|
|
|
_showFailMessage(customMessage = null) {
|
|
const messages = [
|
|
"Spell backfired! 💥",
|
|
"Magic went wrong! 🌀",
|
|
"Oops! Wrong incantation! 😅",
|
|
"The magic gods are not pleased! ⚡",
|
|
"Your spell turned into chaos! 🎭",
|
|
"Magic malfunction detected! 🔧"
|
|
];
|
|
|
|
const message = customMessage || messages[Math.floor(Math.random() * messages.length)];
|
|
|
|
const failEl = document.createElement('div');
|
|
failEl.className = 'fail-message';
|
|
failEl.textContent = message;
|
|
|
|
document.body.appendChild(failEl);
|
|
|
|
setTimeout(() => {
|
|
failEl.remove();
|
|
}, 2000);
|
|
}
|
|
|
|
_spawnMiniEnemy() {
|
|
const miniEnemy = document.createElement('div');
|
|
miniEnemy.className = 'mini-enemy';
|
|
miniEnemy.textContent = '👺';
|
|
|
|
const mainEnemy = document.querySelector('.enemy-character');
|
|
const rect = mainEnemy.getBoundingClientRect();
|
|
|
|
miniEnemy.style.position = 'fixed';
|
|
miniEnemy.style.left = (rect.left + Math.random() * 200 - 100) + 'px';
|
|
miniEnemy.style.top = (rect.top + Math.random() * 200 - 100) + 'px';
|
|
|
|
document.body.appendChild(miniEnemy);
|
|
|
|
setTimeout(() => {
|
|
miniEnemy.remove();
|
|
}, 5000);
|
|
|
|
this._enemyHP = Math.min(this._config.maxEnemyHP, this._enemyHP + 5);
|
|
this._updateEnemyHealth();
|
|
}
|
|
|
|
_wizardTakesDamage() {
|
|
this._playerHP = Math.max(0, this._playerHP - 10);
|
|
const playerHealthEl = document.getElementById('player-health');
|
|
if (playerHealthEl) {
|
|
playerHealthEl.style.width = this._playerHP + '%';
|
|
}
|
|
|
|
document.body.classList.add('screen-shake');
|
|
setTimeout(() => {
|
|
document.body.classList.remove('screen-shake');
|
|
}, 500);
|
|
|
|
const damageEl = document.createElement('div');
|
|
damageEl.className = 'damage-number';
|
|
damageEl.textContent = '-10';
|
|
damageEl.style.color = '#ff4757';
|
|
|
|
const wizardChar = document.querySelector('.wizard-character');
|
|
if (!wizardChar) {
|
|
console.warn('Wizard character not found, skipping damage display');
|
|
return;
|
|
}
|
|
const rect = wizardChar.getBoundingClientRect();
|
|
|
|
damageEl.style.position = 'fixed';
|
|
damageEl.style.left = rect.left + rect.width/2 + 'px';
|
|
damageEl.style.top = rect.top + 'px';
|
|
|
|
document.body.appendChild(damageEl);
|
|
|
|
setTimeout(() => {
|
|
damageEl.remove();
|
|
}, 1500);
|
|
|
|
if (this._playerHP <= 0) {
|
|
setTimeout(() => {
|
|
this._handleDefeat();
|
|
}, 1000);
|
|
}
|
|
}
|
|
|
|
_triggerMagicQuirk() {
|
|
const numQuirks = 2 + Math.floor(Math.random() * 2);
|
|
|
|
for (let i = 0; i < numQuirks; i++) {
|
|
setTimeout(() => {
|
|
const quirk = document.createElement('div');
|
|
quirk.className = 'magic-quirk';
|
|
|
|
const x = 20 + Math.random() * 60;
|
|
const y = 20 + Math.random() * 60;
|
|
|
|
quirk.style.left = x + '%';
|
|
quirk.style.top = y + '%';
|
|
quirk.style.transform = 'translate(-50%, -50%)';
|
|
|
|
document.body.appendChild(quirk);
|
|
|
|
setTimeout(() => {
|
|
quirk.remove();
|
|
}, 2000);
|
|
}, i * 300);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
this._updateWordBank();
|
|
}, 1000);
|
|
}
|
|
|
|
_summonFlyingBirds() {
|
|
const birds = ['🐦', '🕊️', '🦅', '🦜', '🐧', '🦆', '🦢', '🐓', '🦃', '🦚'];
|
|
const paths = ['bird-path-1', 'bird-path-2', 'bird-path-3'];
|
|
const numBirds = 3 + Math.floor(Math.random() * 2);
|
|
|
|
for (let i = 0; i < numBirds; i++) {
|
|
setTimeout(() => {
|
|
const bird = document.createElement('div');
|
|
const pathClass = paths[i % paths.length];
|
|
bird.className = `flying-bird ${pathClass}`;
|
|
bird.textContent = birds[Math.floor(Math.random() * birds.length)];
|
|
|
|
document.body.appendChild(bird);
|
|
|
|
setTimeout(() => {
|
|
bird.remove();
|
|
}, 15000);
|
|
|
|
}, i * 500);
|
|
}
|
|
}
|
|
|
|
_updateEnemyHealth() {
|
|
const healthBar = document.getElementById('enemy-health');
|
|
const percentage = (this._enemyHP / this._config.maxEnemyHP) * 100;
|
|
healthBar.style.width = percentage + '%';
|
|
}
|
|
|
|
_getRandomAttackTime() {
|
|
return this._config.enemyAttackInterval.min +
|
|
Math.random() * (this._config.enemyAttackInterval.max - this._config.enemyAttackInterval.min);
|
|
}
|
|
|
|
_startEnemyAttackSystem() {
|
|
this._scheduleNextEnemyAttack();
|
|
}
|
|
|
|
_scheduleNextEnemyAttack() {
|
|
this._enemyAttackTimer = setTimeout(() => {
|
|
this._executeEnemyAttack();
|
|
this._scheduleNextEnemyAttack();
|
|
}, this._getRandomAttackTime());
|
|
}
|
|
|
|
_executeEnemyAttack() {
|
|
const enemyChar = document.querySelector('.enemy-character');
|
|
|
|
this._showEnemyAttackWarning();
|
|
enemyChar.classList.add('enemy-charging');
|
|
|
|
setTimeout(() => {
|
|
enemyChar.classList.remove('enemy-charging');
|
|
this._dealEnemyDamage();
|
|
this._showEnemyAttackEffect();
|
|
}, 2000);
|
|
}
|
|
|
|
_showEnemyAttackWarning() {
|
|
const enemyChar = document.querySelector('.enemy-character');
|
|
|
|
const existingWarning = enemyChar.querySelector('.enemy-attack-warning');
|
|
if (existingWarning) {
|
|
existingWarning.remove();
|
|
}
|
|
|
|
const warning = document.createElement('div');
|
|
warning.className = 'enemy-attack-warning';
|
|
warning.textContent = '⚠️ INCOMING ATTACK!';
|
|
|
|
enemyChar.style.position = 'relative';
|
|
enemyChar.appendChild(warning);
|
|
|
|
setTimeout(() => {
|
|
warning.remove();
|
|
}, 2000);
|
|
}
|
|
|
|
_dealEnemyDamage() {
|
|
const damage = this._config.enemyDamage.min +
|
|
Math.floor(Math.random() * (this._config.enemyDamage.max - this._config.enemyDamage.min + 1));
|
|
|
|
this._playerHP = Math.max(0, this._playerHP - damage);
|
|
const playerHealthEl = document.getElementById('player-health');
|
|
if (playerHealthEl) {
|
|
playerHealthEl.style.width = this._playerHP + '%';
|
|
}
|
|
|
|
document.body.classList.add('screen-shake');
|
|
setTimeout(() => {
|
|
document.body.classList.remove('screen-shake');
|
|
}, 500);
|
|
|
|
const damageEl = document.createElement('div');
|
|
damageEl.className = 'damage-number';
|
|
damageEl.textContent = `-${damage}`;
|
|
damageEl.style.color = '#ff4757';
|
|
|
|
const wizardChar = document.querySelector('.wizard-character');
|
|
if (!wizardChar) {
|
|
console.warn('Wizard character not found, skipping damage display');
|
|
return;
|
|
}
|
|
const rect = wizardChar.getBoundingClientRect();
|
|
|
|
damageEl.style.position = 'fixed';
|
|
damageEl.style.left = rect.left + rect.width/2 + 'px';
|
|
damageEl.style.top = rect.top + 'px';
|
|
|
|
document.body.appendChild(damageEl);
|
|
|
|
setTimeout(() => {
|
|
damageEl.remove();
|
|
}, 1500);
|
|
|
|
// Emit enemy attack event
|
|
this._eventBus.emit('wizard-spell-caster:enemy-attack', {
|
|
gameId: 'wizard-spell-caster',
|
|
instanceId: this.name,
|
|
damage,
|
|
playerHP: this._playerHP
|
|
}, this.name);
|
|
|
|
if (this._playerHP <= 0) {
|
|
setTimeout(() => {
|
|
this._handleDefeat();
|
|
}, 1000);
|
|
}
|
|
}
|
|
|
|
_showEnemyAttackEffect() {
|
|
const effect = document.createElement('div');
|
|
effect.className = 'enemy-attack-effect';
|
|
|
|
const wizardChar = document.querySelector('.wizard-character');
|
|
const rect = wizardChar.getBoundingClientRect();
|
|
|
|
effect.style.position = 'fixed';
|
|
effect.style.left = rect.left + rect.width/2 - 75 + 'px';
|
|
effect.style.top = rect.top + rect.height/2 - 75 + 'px';
|
|
|
|
document.body.appendChild(effect);
|
|
|
|
setTimeout(() => {
|
|
effect.remove();
|
|
}, 1000);
|
|
}
|
|
|
|
_handleVictory() {
|
|
if (this._enemyAttackTimer) {
|
|
clearTimeout(this._enemyAttackTimer);
|
|
this._enemyAttackTimer = null;
|
|
}
|
|
|
|
const bonusScore = 1000;
|
|
this._score += bonusScore;
|
|
|
|
const victoryScreen = document.createElement('div');
|
|
victoryScreen.className = 'victory-screen';
|
|
victoryScreen.innerHTML = `
|
|
<div class="result-title">🎉 VICTORY! 🎉</div>
|
|
<div style="font-size: 24px; margin-bottom: 10px;">You defeated the Grammar Demon!</div>
|
|
<div style="font-size: 18px; margin-bottom: 10px;">Final Score: ${this._score}</div>
|
|
<div style="font-size: 16px; margin-bottom: 20px;">Victory Bonus: +${bonusScore}</div>
|
|
<div>
|
|
<button class="game-result-btn victory" id="play-again-btn">🔄 Play Again</button>
|
|
<button class="game-result-btn victory" id="exit-victory-btn">🏠 Back to Games</button>
|
|
</div>
|
|
`;
|
|
|
|
this._config.container.appendChild(victoryScreen);
|
|
|
|
// Add event listeners
|
|
victoryScreen.querySelector('#play-again-btn').addEventListener('click', () => {
|
|
victoryScreen.remove();
|
|
this._restartGame();
|
|
});
|
|
|
|
victoryScreen.querySelector('#exit-victory-btn').addEventListener('click', () => {
|
|
this._eventBus.emit('navigation:navigate', { path: '/games' }, 'Bootstrap');
|
|
});
|
|
|
|
// Emit victory event
|
|
this._eventBus.emit('game:completed', {
|
|
gameId: 'wizard-spell-caster',
|
|
instanceId: this.name,
|
|
score: this._score,
|
|
spellsCast: this._spellCount,
|
|
duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0
|
|
}, this.name);
|
|
}
|
|
|
|
_handleDefeat() {
|
|
if (this._enemyAttackTimer) {
|
|
clearTimeout(this._enemyAttackTimer);
|
|
this._enemyAttackTimer = null;
|
|
}
|
|
|
|
const defeatScreen = document.createElement('div');
|
|
defeatScreen.className = 'defeat-screen';
|
|
defeatScreen.innerHTML = `
|
|
<div class="result-title">💀 DEFEATED 💀</div>
|
|
<div style="font-size: 24px; margin-bottom: 10px;">The Grammar Demon proved too strong!</div>
|
|
<div style="font-size: 18px; margin-bottom: 20px;">Final Score: ${this._score}</div>
|
|
<div>
|
|
<button class="game-result-btn defeat" id="try-again-btn">🔄 Try Again</button>
|
|
<button class="game-result-btn defeat" id="exit-defeat-btn">🏠 Back to Games</button>
|
|
</div>
|
|
`;
|
|
|
|
this._config.container.appendChild(defeatScreen);
|
|
|
|
// Add event listeners
|
|
defeatScreen.querySelector('#try-again-btn').addEventListener('click', () => {
|
|
defeatScreen.remove();
|
|
this._restartGame();
|
|
});
|
|
|
|
defeatScreen.querySelector('#exit-defeat-btn').addEventListener('click', () => {
|
|
this._eventBus.emit('navigation:navigate', { path: '/games' }, 'Bootstrap');
|
|
});
|
|
|
|
// Emit defeat event
|
|
this._eventBus.emit('game:completed', {
|
|
gameId: 'wizard-spell-caster',
|
|
instanceId: this.name,
|
|
score: this._score,
|
|
result: 'defeat',
|
|
spellsCast: this._spellCount,
|
|
duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0
|
|
}, this.name);
|
|
}
|
|
|
|
_restartGame() {
|
|
// Reset game state
|
|
this._score = 0;
|
|
this._enemyHP = this._config.maxEnemyHP;
|
|
this._playerHP = this._config.maxPlayerHP;
|
|
this._spellStartTime = Date.now();
|
|
this._averageSpellTime = 0;
|
|
this._spellCount = 0;
|
|
this._selectedSpell = null;
|
|
this._selectedWords = [];
|
|
|
|
// Update UI
|
|
const currentScoreEl = document.getElementById('current-score');
|
|
if (currentScoreEl) {
|
|
currentScoreEl.textContent = this._score;
|
|
}
|
|
const playerHealthEl = document.getElementById('player-health');
|
|
if (playerHealthEl) {
|
|
playerHealthEl.style.width = '100%';
|
|
}
|
|
const enemyHealthEl = document.getElementById('enemy-health');
|
|
if (enemyHealthEl) {
|
|
enemyHealthEl.style.width = '100%';
|
|
}
|
|
|
|
// Restart enemy attacks
|
|
this._startEnemyAttackSystem();
|
|
|
|
// Generate new spells
|
|
this._generateNewSpells();
|
|
}
|
|
|
|
_showError(message) {
|
|
if (this._config.container) {
|
|
this._config.container.innerHTML = `
|
|
<div class="game-error">
|
|
<h3>❌ Wizard Spell Caster Error</h3>
|
|
<p>${message}</p>
|
|
<p>This game requires sentences for spell construction.</p>
|
|
<button class="back-btn" onclick="history.back()">← Go Back</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
_handlePause() {
|
|
if (this._enemyAttackTimer) {
|
|
clearTimeout(this._enemyAttackTimer);
|
|
this._enemyAttackTimer = null;
|
|
}
|
|
this._eventBus.emit('game:paused', { instanceId: this.name }, this.name);
|
|
}
|
|
|
|
_handleResume() {
|
|
this._startEnemyAttackSystem();
|
|
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
|
|
}
|
|
}
|
|
|
|
export default WizardSpellCaster; |