- Add TTSService.js for text-to-speech functionality - Add comprehensive deployment documentation (guides, checklists, diagnostics) - Add new SBS content (chapters 8 & 9) - Refactor 14 game modules for better maintainability (-947 lines) - Enhance SettingsDebug.js with improved debugging capabilities - Update configuration files and startup scripts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2153 lines
72 KiB
JavaScript
2153 lines
72 KiB
JavaScript
import Module from '../core/Module.js';
|
|
import ttsService from '../services/TTSService.js';
|
|
|
|
/**
|
|
* AdventureReader - Zelda-style RPG adventure with vocabulary and sentence reading
|
|
* Players move around a map, click pots for vocabulary and defeat enemies for reading content
|
|
*/
|
|
class AdventureReader extends Module {
|
|
constructor(name, dependencies, config = {}) {
|
|
super(name, ['eventBus']);
|
|
|
|
// Validate dependencies
|
|
if (!dependencies.eventBus || !dependencies.content) {
|
|
throw new Error('AdventureReader requires eventBus and content dependencies');
|
|
}
|
|
|
|
this._eventBus = dependencies.eventBus;
|
|
this._content = dependencies.content;
|
|
this._config = {
|
|
container: null,
|
|
autoPlayTTS: true,
|
|
ttsEnabled: true,
|
|
maxPots: 8,
|
|
maxEnemies: 8,
|
|
...config
|
|
};
|
|
|
|
// Game state
|
|
this._score = 0;
|
|
this._currentSentenceIndex = 0;
|
|
this._currentVocabIndex = 0;
|
|
this._potsDestroyed = 0;
|
|
this._enemiesDefeated = 0;
|
|
this._isGamePaused = false;
|
|
this._gameStartTime = null;
|
|
|
|
// Game objects
|
|
this._pots = [];
|
|
this._enemies = [];
|
|
this._player = { x: 0, y: 0 };
|
|
this._isPlayerMoving = false;
|
|
this._isPlayerInvulnerable = false;
|
|
this._invulnerabilityTimeout = null;
|
|
|
|
// Content
|
|
this._vocabulary = null;
|
|
this._sentences = null;
|
|
this._stories = null;
|
|
this._dialogues = null;
|
|
|
|
Object.seal(this);
|
|
}
|
|
|
|
/**
|
|
* Get game metadata
|
|
* @returns {Object} Game metadata
|
|
*/
|
|
static getMetadata() {
|
|
return {
|
|
name: 'Adventure Reader',
|
|
description: 'Zelda-style RPG adventure with vocabulary discovery and reading quests',
|
|
difficulty: 'intermediate',
|
|
category: 'adventure',
|
|
estimatedTime: 12, // minutes
|
|
skills: ['vocabulary', 'reading', 'exploration', 'comprehension']
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate compatibility score with content
|
|
* @param {Object} content - Content to check compatibility with
|
|
* @returns {Object} Compatibility score and details
|
|
*/
|
|
static getCompatibilityScore(content) {
|
|
const vocab = content?.vocabulary || {};
|
|
const dialogues = content?.dialogues || [];
|
|
const stories = content?.story?.chapters || content?.texts || [];
|
|
|
|
const vocabCount = Object.keys(vocab).length;
|
|
const dialogueCount = dialogues.length;
|
|
const storyCount = stories.length;
|
|
|
|
// Count sentences from ALL possible sources (matching _extractSentences logic)
|
|
let sentenceCount = 0;
|
|
|
|
// From story chapters
|
|
if (content?.story?.chapters) {
|
|
content.story.chapters.forEach(chapter => {
|
|
if (chapter.sentences) {
|
|
sentenceCount += chapter.sentences.filter(s => s.original && s.translation).length;
|
|
}
|
|
});
|
|
}
|
|
|
|
// From direct sentences array
|
|
if (content?.sentences) {
|
|
sentenceCount += content.sentences.length;
|
|
}
|
|
|
|
// From phrases (array or object format)
|
|
if (content?.phrases) {
|
|
if (Array.isArray(content.phrases)) {
|
|
sentenceCount += content.phrases.filter(p => p.chinese && p.english).length;
|
|
} else if (typeof content.phrases === 'object') {
|
|
sentenceCount += Object.keys(content.phrases).length;
|
|
}
|
|
}
|
|
|
|
// From lessons
|
|
if (content?.lessons) {
|
|
content.lessons.forEach(lesson => {
|
|
if (lesson.sentences) {
|
|
sentenceCount += lesson.sentences.filter(s => s.chinese && s.english).length;
|
|
}
|
|
});
|
|
}
|
|
|
|
const totalContent = vocabCount + sentenceCount + storyCount + dialogueCount;
|
|
|
|
if (totalContent < 5) {
|
|
return {
|
|
score: 0,
|
|
reason: `Insufficient adventure content (${totalContent}/5 required)`,
|
|
requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'],
|
|
minContent: 5,
|
|
details: 'Adventure Reader needs vocabulary, sentences, stories, or dialogues for exploration'
|
|
};
|
|
}
|
|
|
|
// Calculate weighted score based on content diversity and quantity
|
|
let score = 0;
|
|
|
|
// Vocabulary: 0.3 points max (reach 100% at 8+ items)
|
|
if (vocabCount > 0) score += Math.min(vocabCount / 8, 1) * 0.3;
|
|
|
|
// Sentences: 0.4 points max (reach 100% at 8+ items) - most important for gameplay
|
|
if (sentenceCount > 0) score += Math.min(sentenceCount / 8, 1) * 0.4;
|
|
|
|
// Stories: 0.15 points max (reach 100% at 3+ items)
|
|
if (storyCount > 0) score += Math.min(storyCount / 3, 1) * 0.15;
|
|
|
|
// Dialogues: 0.15 points max (reach 100% at 3+ items)
|
|
if (dialogueCount > 0) score += Math.min(dialogueCount / 3, 1) * 0.15;
|
|
|
|
return {
|
|
score: Math.min(score, 1),
|
|
reason: `Adventure content: ${vocabCount} vocab, ${sentenceCount} sentences, ${storyCount} stories, ${dialogueCount} dialogues`,
|
|
requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'],
|
|
optimalContent: { vocab: 8, sentences: 8, stories: 3, dialogues: 3 },
|
|
details: `Rich adventure content with ${totalContent} total elements`
|
|
};
|
|
}
|
|
|
|
async init() {
|
|
this._validateNotDestroyed();
|
|
|
|
try {
|
|
// Validate container
|
|
if (!this._config.container) {
|
|
throw new Error('Game container is required');
|
|
}
|
|
|
|
// Extract content
|
|
this._extractContent();
|
|
|
|
// Validate content
|
|
if (!this._hasValidContent()) {
|
|
throw new Error('No compatible adventure content found');
|
|
}
|
|
|
|
// 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();
|
|
|
|
// Wait for DOM to render before initializing player
|
|
requestAnimationFrame(() => {
|
|
this._initializePlayer();
|
|
this._setupEventListeners();
|
|
this._updateContentInfo();
|
|
this._generateGameObjects();
|
|
this._generateDecorations();
|
|
this._startGameLoop();
|
|
});
|
|
|
|
// Start the game
|
|
this._gameStartTime = Date.now();
|
|
|
|
// Emit game ready event
|
|
this._eventBus.emit('game:ready', {
|
|
gameId: 'adventure-reader',
|
|
instanceId: this.name,
|
|
vocabulary: this._vocabulary.length,
|
|
sentences: this._sentences.length,
|
|
stories: this._stories.length,
|
|
dialogues: this._dialogues.length
|
|
}, this.name);
|
|
|
|
this._setInitialized();
|
|
|
|
} catch (error) {
|
|
this._showError(error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async destroy() {
|
|
this._validateNotDestroyed();
|
|
|
|
// Clear timeouts
|
|
if (this._invulnerabilityTimeout) {
|
|
clearTimeout(this._invulnerabilityTimeout);
|
|
this._invulnerabilityTimeout = null;
|
|
}
|
|
|
|
// Cancel any ongoing TTS
|
|
ttsService.cancel();
|
|
|
|
// 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: 'adventure-reader',
|
|
instanceId: this.name,
|
|
score: this._score,
|
|
potsDestroyed: this._potsDestroyed,
|
|
enemiesDefeated: this._enemiesDefeated,
|
|
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,
|
|
potsDestroyed: this._potsDestroyed,
|
|
enemiesDefeated: this._enemiesDefeated,
|
|
totalPots: this._pots.length,
|
|
totalEnemies: this._enemies.length,
|
|
isComplete: this._isGameComplete(),
|
|
isPaused: this._isGamePaused,
|
|
duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0
|
|
};
|
|
}
|
|
|
|
// Private methods
|
|
_extractContent() {
|
|
this._vocabulary = this._extractVocabulary();
|
|
this._sentences = this._extractSentences();
|
|
this._stories = this._extractStories();
|
|
this._dialogues = this._extractDialogues();
|
|
}
|
|
|
|
_extractVocabulary() {
|
|
const vocab = this._content?.vocabulary || {};
|
|
const vocabulary = [];
|
|
|
|
for (const [word, data] of Object.entries(vocab)) {
|
|
if (data.user_language || (typeof data === 'string')) {
|
|
vocabulary.push({
|
|
original_language: word,
|
|
user_language: data.user_language || data,
|
|
type: data.type || 'unknown',
|
|
pronunciation: data.pronunciation
|
|
});
|
|
}
|
|
}
|
|
|
|
return vocabulary;
|
|
}
|
|
|
|
_extractSentences() {
|
|
let sentences = [];
|
|
|
|
console.log('AdventureReader: Extracting sentences from content', this._content);
|
|
|
|
// Support for Dragon's Pearl structure
|
|
if (this._content.story?.chapters) {
|
|
this._content.story.chapters.forEach(chapter => {
|
|
if (chapter.sentences) {
|
|
chapter.sentences.forEach(sentence => {
|
|
if (sentence.original && sentence.translation) {
|
|
sentences.push({
|
|
original_language: sentence.original,
|
|
user_language: sentence.translation,
|
|
pronunciation: sentence.pronunciation,
|
|
chapter: chapter.title || ''
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Support for modular format
|
|
if (this._content.sentences) {
|
|
this._content.sentences.forEach(sentence => {
|
|
sentences.push({
|
|
original_language: sentence.english || sentence.original_language || sentence.target_language,
|
|
user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation,
|
|
pronunciation: sentence.pronunciation || sentence.prononciation
|
|
});
|
|
});
|
|
}
|
|
|
|
// Support for LEDU format with phrases/lessons
|
|
if (this._content.phrases) {
|
|
// Check if phrases is an array or object
|
|
if (Array.isArray(this._content.phrases)) {
|
|
this._content.phrases.forEach(phrase => {
|
|
if (phrase.chinese && phrase.english) {
|
|
sentences.push({
|
|
original_language: phrase.chinese,
|
|
user_language: phrase.english,
|
|
pronunciation: phrase.pinyin
|
|
});
|
|
}
|
|
});
|
|
} else if (typeof this._content.phrases === 'object') {
|
|
// Handle object format (key-value pairs)
|
|
Object.entries(this._content.phrases).forEach(([phraseText, phraseData]) => {
|
|
const translation = typeof phraseData === 'object' ? phraseData.user_language : phraseData;
|
|
const pronunciation = typeof phraseData === 'object' ? phraseData.pronunciation : undefined;
|
|
|
|
if (phraseText && translation) {
|
|
sentences.push({
|
|
original_language: phraseText,
|
|
user_language: translation,
|
|
pronunciation: pronunciation
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Support for lessons with sentences
|
|
if (this._content.lessons) {
|
|
this._content.lessons.forEach(lesson => {
|
|
if (lesson.sentences) {
|
|
lesson.sentences.forEach(sentence => {
|
|
if (sentence.chinese && sentence.english) {
|
|
sentences.push({
|
|
original_language: sentence.chinese,
|
|
user_language: sentence.english,
|
|
pronunciation: sentence.pinyin
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('AdventureReader: Extracted sentences:', sentences.length);
|
|
return sentences.filter(s => s.original_language && s.user_language);
|
|
}
|
|
|
|
_extractStories() {
|
|
let stories = [];
|
|
|
|
// Support for Dragon's Pearl structure
|
|
if (this._content.story?.chapters) {
|
|
stories.push({
|
|
title: this._content.story.title || this._content.name || "Adventure Story",
|
|
chapters: this._content.story.chapters
|
|
});
|
|
}
|
|
|
|
// Support for modular texts
|
|
if (this._content.texts) {
|
|
stories = stories.concat(this._content.texts.filter(text =>
|
|
text.original_language && text.user_language
|
|
));
|
|
}
|
|
|
|
return stories;
|
|
}
|
|
|
|
_extractDialogues() {
|
|
let dialogues = [];
|
|
|
|
if (this._content.dialogues) {
|
|
dialogues = this._content.dialogues.filter(dialogue =>
|
|
dialogue.conversation && dialogue.conversation.length > 0
|
|
);
|
|
}
|
|
|
|
return dialogues;
|
|
}
|
|
|
|
_hasValidContent() {
|
|
const hasVocab = this._vocabulary.length > 0;
|
|
const hasSentences = this._sentences.length > 0;
|
|
const hasStories = this._stories.length > 0;
|
|
const hasDialogues = this._dialogues.length > 0;
|
|
|
|
return hasVocab || hasSentences || hasStories || hasDialogues;
|
|
}
|
|
|
|
_injectCSS() {
|
|
const cssId = `adventure-reader-styles-${this.name}`;
|
|
if (document.getElementById(cssId)) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.id = cssId;
|
|
style.textContent = `
|
|
.adventure-reader-wrapper {
|
|
height: 75vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.adventure-hud {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 15px 20px;
|
|
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
|
color: white;
|
|
border-bottom: 3px solid rgba(255, 255, 255, 0.1);
|
|
z-index: 100;
|
|
}
|
|
|
|
.hud-section {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
}
|
|
|
|
.stat-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
padding: 8px 12px;
|
|
border-radius: 20px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.stat-icon {
|
|
font-size: 0.72rem; /* 1.2 / 1.66 */
|
|
}
|
|
|
|
.progress-info {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
padding: 8px 15px;
|
|
border-radius: 15px;
|
|
font-size: 0.54rem; /* 0.9 / 1.66 */
|
|
}
|
|
|
|
.game-map {
|
|
flex: 1;
|
|
position: relative;
|
|
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
|
|
overflow: hidden;
|
|
cursor: crosshair;
|
|
}
|
|
|
|
.player {
|
|
position: absolute;
|
|
font-size: 1.51rem; /* 2.5 / 1.66 */
|
|
z-index: 50;
|
|
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
|
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
|
|
user-select: none;
|
|
}
|
|
|
|
.pot, .enemy {
|
|
position: absolute;
|
|
font-size: 1.2rem; /* 2 / 1.66 */
|
|
cursor: pointer;
|
|
z-index: 30;
|
|
transition: all 0.3s ease;
|
|
filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3));
|
|
user-select: none;
|
|
}
|
|
|
|
.pot:hover, .enemy:hover {
|
|
transform: scale(1.1);
|
|
filter: drop-shadow(2px 2px 6px rgba(0,0,0,0.5));
|
|
}
|
|
|
|
.pot.destroyed, .enemy.defeated {
|
|
pointer-events: none;
|
|
transition: all 0.5s ease;
|
|
}
|
|
|
|
.decoration {
|
|
position: absolute;
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.decoration.tree {
|
|
filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.2));
|
|
}
|
|
|
|
.decoration.grass {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.decoration.rock {
|
|
filter: drop-shadow(1px 1px 3px rgba(0,0,0,0.3));
|
|
}
|
|
|
|
.adventure-controls {
|
|
padding: 15px 20px;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 15px;
|
|
}
|
|
|
|
.instructions {
|
|
font-size: 0.54rem; /* 0.9 / 1.66 */
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.content-summary {
|
|
font-size: 0.51rem; /* 0.85 / 1.66 */
|
|
background: rgba(255, 255, 255, 0.1);
|
|
padding: 8px 12px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.control-btn {
|
|
padding: 8px 15px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.54rem; /* 0.9 / 1.66 */
|
|
font-weight: 500;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.control-btn.primary {
|
|
background: #3b82f6;
|
|
color: white;
|
|
}
|
|
|
|
.control-btn.primary:hover {
|
|
background: #2563eb;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.control-btn.secondary {
|
|
background: #10b981;
|
|
color: white;
|
|
}
|
|
|
|
.control-btn.secondary:hover {
|
|
background: #059669;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.reading-modal, .vocab-popup {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.reading-modal.show, .vocab-popup.show {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 15px;
|
|
max-width: 600px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
transform: translateY(20px);
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
.reading-modal.show .modal-content {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.modal-header {
|
|
padding: 20px 25px 15px;
|
|
border-bottom: 2px solid #e5e7eb;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border-radius: 15px 15px 0 0;
|
|
}
|
|
|
|
.modal-header h3 {
|
|
margin: 0;
|
|
font-size: 0.78rem; /* 1.3 / 1.66 */
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 25px;
|
|
}
|
|
|
|
.sentence-content {
|
|
text-align: center;
|
|
}
|
|
|
|
.sentence-content.dialogue-content {
|
|
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
|
padding: 20px;
|
|
border-radius: 12px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.speaker-info, .story-title, .emotion-info {
|
|
font-weight: 600;
|
|
margin-bottom: 10px;
|
|
color: #374151;
|
|
}
|
|
|
|
.text-content {
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.original-text {
|
|
font-size: 0.84rem; /* 1.4 / 1.66 */
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
margin-bottom: 15px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.translation-text {
|
|
font-size: 0.66rem; /* 1.1 / 1.66 */
|
|
color: #6b7280;
|
|
margin-bottom: 10px;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.pronunciation-text {
|
|
font-size: 0.60rem; /* 1.0 / 1.66 */
|
|
color: #7c3aed;
|
|
font-style: italic;
|
|
}
|
|
|
|
.modal-footer {
|
|
padding: 15px 25px 25px;
|
|
text-align: center;
|
|
}
|
|
|
|
.popup-content {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 20px 25px;
|
|
border-radius: 15px;
|
|
text-align: center;
|
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
|
transform: scale(0.9);
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
.vocab-popup.show .popup-content {
|
|
transform: scale(1);
|
|
}
|
|
|
|
.vocab-word {
|
|
font-size: 1.2rem; /* 2.0 / 1.66 */
|
|
font-weight: bold;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.vocab-translation {
|
|
font-size: 0.78rem; /* 1.3 / 1.66 */
|
|
margin-bottom: 10px;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.vocab-pronunciation {
|
|
font-size: 0.60rem; /* 1.0 / 1.66 */
|
|
opacity: 0.8;
|
|
font-style: italic;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.game-error ul {
|
|
text-align: left;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.back-btn:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
/* Animations */
|
|
@keyframes protectionFloat {
|
|
0% {
|
|
transform: translate(-50%, -50%) scale(0.8);
|
|
opacity: 0;
|
|
}
|
|
20% {
|
|
transform: translate(-50%, -50%) scale(1.2);
|
|
opacity: 1;
|
|
}
|
|
80% {
|
|
transform: translate(-50%, -50%) scale(1);
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
transform: translate(-50%, -50%) scale(0.8);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
@keyframes damageFloat {
|
|
0% {
|
|
transform: translate(-50%, -50%) scale(1);
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
transform: translate(-50%, -80%) scale(1.2);
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
transform: translate(-50%, -120%) scale(0.8);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.adventure-hud {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
padding: 12px 15px;
|
|
}
|
|
|
|
.hud-section {
|
|
gap: 15px;
|
|
}
|
|
|
|
.stat-item {
|
|
padding: 6px 10px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.player {
|
|
font-size: 2rem;
|
|
}
|
|
|
|
.pot, .enemy {
|
|
font-size: 1.8rem;
|
|
}
|
|
|
|
.adventure-controls {
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
padding: 12px 15px;
|
|
}
|
|
|
|
.modal-content {
|
|
width: 95%;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 20px 15px;
|
|
}
|
|
}
|
|
|
|
/* Victory Popup Styles */
|
|
.victory-popup {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 10000;
|
|
animation: fadeIn 0.3s ease-out;
|
|
}
|
|
|
|
.victory-content {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
border-radius: 20px;
|
|
padding: 40px;
|
|
text-align: center;
|
|
color: white;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
animation: slideUp 0.4s ease-out;
|
|
}
|
|
|
|
.victory-header {
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.victory-icon {
|
|
font-size: 4rem;
|
|
margin-bottom: 15px;
|
|
animation: bounce 0.6s ease-out;
|
|
}
|
|
|
|
.victory-title {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
margin: 0 0 10px 0;
|
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.new-best-badge {
|
|
background: linear-gradient(45deg, #f093fb 0%, #f5576c 100%);
|
|
color: white;
|
|
padding: 8px 20px;
|
|
border-radius: 25px;
|
|
font-size: 0.9rem;
|
|
font-weight: bold;
|
|
display: inline-block;
|
|
margin-top: 10px;
|
|
animation: glow 1s ease-in-out infinite alternate;
|
|
}
|
|
|
|
.victory-scores {
|
|
display: flex;
|
|
justify-content: space-around;
|
|
margin: 30px 0;
|
|
gap: 20px;
|
|
}
|
|
|
|
.score-display {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 15px;
|
|
padding: 20px;
|
|
flex: 1;
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.score-label {
|
|
font-size: 0.9rem;
|
|
opacity: 0.9;
|
|
margin-bottom: 8px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.score-value {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.victory-stats {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 15px;
|
|
padding: 20px;
|
|
margin: 30px 0;
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.stat-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.stat-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.stat-name {
|
|
font-size: 0.95rem;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.stat-value {
|
|
font-weight: bold;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.victory-buttons {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
margin-top: 30px;
|
|
}
|
|
|
|
.victory-btn {
|
|
padding: 15px 30px;
|
|
border: none;
|
|
border-radius: 25px;
|
|
font-size: 1rem;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.victory-btn.primary {
|
|
background: linear-gradient(45deg, #4facfe 0%, #00f2fe 100%);
|
|
color: white;
|
|
box-shadow: 0 8px 25px rgba(79, 172, 254, 0.3);
|
|
}
|
|
|
|
.victory-btn.primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 12px 35px rgba(79, 172, 254, 0.4);
|
|
}
|
|
|
|
.victory-btn.secondary {
|
|
background: linear-gradient(45deg, #a8edea 0%, #fed6e3 100%);
|
|
color: #333;
|
|
box-shadow: 0 8px 25px rgba(168, 237, 234, 0.3);
|
|
}
|
|
|
|
.victory-btn.secondary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 12px 35px rgba(168, 237, 234, 0.4);
|
|
}
|
|
|
|
.victory-btn.tertiary {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
color: white;
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.victory-btn.tertiary:hover {
|
|
background: rgba(255, 255, 255, 0.3);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(30px) scale(0.9);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
|
|
@keyframes bounce {
|
|
0%, 20%, 50%, 80%, 100% {
|
|
transform: translateY(0);
|
|
}
|
|
40% {
|
|
transform: translateY(-10px);
|
|
}
|
|
60% {
|
|
transform: translateY(-5px);
|
|
}
|
|
}
|
|
|
|
@keyframes glow {
|
|
from {
|
|
box-shadow: 0 0 20px rgba(245, 87, 108, 0.5);
|
|
}
|
|
to {
|
|
box-shadow: 0 0 30px rgba(245, 87, 108, 0.8);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.victory-content {
|
|
padding: 30px 20px;
|
|
width: 95%;
|
|
}
|
|
|
|
.victory-scores {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
}
|
|
|
|
.victory-icon {
|
|
font-size: 3rem;
|
|
}
|
|
|
|
.victory-title {
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.victory-buttons {
|
|
gap: 10px;
|
|
}
|
|
|
|
.victory-btn {
|
|
padding: 12px 25px;
|
|
font-size: 0.9rem;
|
|
}
|
|
}
|
|
`;
|
|
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
_removeCSS() {
|
|
const cssId = `adventure-reader-styles-${this.name}`;
|
|
const existingStyle = document.getElementById(cssId);
|
|
if (existingStyle) {
|
|
existingStyle.remove();
|
|
}
|
|
}
|
|
|
|
_createGameInterface() {
|
|
this._config.container.innerHTML = `
|
|
<div class="adventure-reader-wrapper">
|
|
<!-- Game HUD -->
|
|
<div class="adventure-hud">
|
|
<div class="hud-section">
|
|
<div class="stat-item">
|
|
<span class="stat-icon">🏆</span>
|
|
<span id="score-display">0</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-icon">🏺</span>
|
|
<span id="pots-counter">0</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-icon">⚔️</span>
|
|
<span id="enemies-counter">0</span>
|
|
</div>
|
|
</div>
|
|
<div class="hud-section">
|
|
<div class="progress-info">
|
|
<span id="progress-text">Start your adventure!</span>
|
|
</div>
|
|
<button class="btn btn-outline btn-sm" id="exit-adventure">
|
|
<span class="btn-icon">←</span>
|
|
<span class="btn-text">Exit</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Game Map -->
|
|
<div class="game-map" id="game-map">
|
|
<!-- Player -->
|
|
<div class="player" id="player">🧙♂️</div>
|
|
|
|
<!-- Game objects will be generated here -->
|
|
</div>
|
|
|
|
<!-- Game Controls -->
|
|
<div class="adventure-controls">
|
|
<div class="instructions" id="game-instructions">
|
|
Click 🏺 pots for vocabulary • Click 👹 enemies for sentences
|
|
</div>
|
|
<div class="content-info" id="content-info">
|
|
<!-- Content type info will be populated here -->
|
|
</div>
|
|
<button class="control-btn secondary" id="restart-btn">🔄 Restart Adventure</button>
|
|
</div>
|
|
|
|
<!-- Reading Modal -->
|
|
<div class="reading-modal" id="reading-modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 id="modal-title">Enemy Defeated!</h3>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="reading-content" id="reading-content">
|
|
<!-- Sentence content -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Vocab Popup -->
|
|
<div class="vocab-popup" id="vocab-popup">
|
|
<div class="popup-content">
|
|
<div class="vocab-word" id="vocab-word"></div>
|
|
<div class="vocab-translation" id="vocab-translation"></div>
|
|
<div class="vocab-pronunciation" id="vocab-pronunciation"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_initializePlayer() {
|
|
const gameMap = document.getElementById('game-map');
|
|
if (!gameMap) {
|
|
console.error('AdventureReader: game-map element not found for player initialization');
|
|
return;
|
|
}
|
|
|
|
const mapRect = gameMap.getBoundingClientRect();
|
|
this._player.x = mapRect.width / 2 - 20;
|
|
this._player.y = mapRect.height / 2 - 20;
|
|
|
|
const playerElement = document.getElementById('player');
|
|
if (!playerElement) {
|
|
console.error('AdventureReader: player element not found for positioning');
|
|
return;
|
|
}
|
|
|
|
playerElement.style.left = this._player.x + 'px';
|
|
playerElement.style.top = this._player.y + 'px';
|
|
}
|
|
|
|
_setupEventListeners() {
|
|
// Control buttons
|
|
document.getElementById('restart-btn').addEventListener('click', () => this._restart());
|
|
|
|
// Exit button
|
|
const exitButton = document.getElementById('exit-adventure');
|
|
if (exitButton) {
|
|
exitButton.addEventListener('click', () => {
|
|
this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name);
|
|
});
|
|
}
|
|
|
|
// Map click handler
|
|
const gameMap = document.getElementById('game-map');
|
|
gameMap.addEventListener('click', (e) => this._handleMapClick(e));
|
|
|
|
// Window resize handler
|
|
window.addEventListener('resize', () => {
|
|
setTimeout(() => {
|
|
if (!this._isDestroyed) {
|
|
this._initializePlayer();
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
_updateContentInfo() {
|
|
const contentInfoEl = document.getElementById('content-info');
|
|
if (!contentInfoEl) return;
|
|
|
|
const contentTypes = [];
|
|
|
|
if (this._stories.length > 0) {
|
|
contentTypes.push(`📚 ${this._stories.length} stories`);
|
|
}
|
|
if (this._dialogues.length > 0) {
|
|
contentTypes.push(`💬 ${this._dialogues.length} dialogues`);
|
|
}
|
|
if (this._vocabulary.length > 0) {
|
|
contentTypes.push(`📝 ${this._vocabulary.length} words`);
|
|
}
|
|
if (this._sentences.length > 0) {
|
|
contentTypes.push(`📖 ${this._sentences.length} sentences`);
|
|
}
|
|
|
|
if (contentTypes.length > 0) {
|
|
contentInfoEl.innerHTML = `
|
|
<div class="content-summary">
|
|
<strong>Adventure Content:</strong> ${contentTypes.join(' • ')}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
_generateGameObjects() {
|
|
const gameMap = document.getElementById('game-map');
|
|
|
|
// Clear existing objects
|
|
gameMap.querySelectorAll('.pot, .enemy').forEach(el => el.remove());
|
|
|
|
this._pots = [];
|
|
this._enemies = [];
|
|
|
|
// Generate pots (for vocabulary)
|
|
const numPots = Math.min(this._config.maxPots, this._vocabulary.length);
|
|
for (let i = 0; i < numPots; i++) {
|
|
const pot = this._createPot();
|
|
this._pots.push(pot);
|
|
gameMap.appendChild(pot.element);
|
|
}
|
|
|
|
// Generate enemies (for sentences)
|
|
const numEnemies = Math.min(this._config.maxEnemies, this._sentences.length);
|
|
for (let i = 0; i < numEnemies; i++) {
|
|
const enemy = this._createEnemy();
|
|
this._enemies.push(enemy);
|
|
gameMap.appendChild(enemy.element);
|
|
}
|
|
|
|
this._updateHUD();
|
|
}
|
|
|
|
_createPot() {
|
|
const pot = document.createElement('div');
|
|
pot.className = 'pot';
|
|
pot.innerHTML = '🏺';
|
|
|
|
const position = this._getRandomPosition();
|
|
pot.style.left = position.x + 'px';
|
|
pot.style.top = position.y + 'px';
|
|
|
|
return {
|
|
element: pot,
|
|
x: position.x,
|
|
y: position.y,
|
|
destroyed: false
|
|
};
|
|
}
|
|
|
|
_createEnemy() {
|
|
const enemy = document.createElement('div');
|
|
enemy.className = 'enemy';
|
|
enemy.innerHTML = '👹';
|
|
|
|
const position = this._getRandomPosition(true);
|
|
enemy.style.left = position.x + 'px';
|
|
enemy.style.top = position.y + 'px';
|
|
|
|
const patterns = ['patrol', 'chase', 'wander', 'circle'];
|
|
const pattern = patterns[Math.floor(Math.random() * patterns.length)];
|
|
|
|
return {
|
|
element: enemy,
|
|
x: position.x,
|
|
y: position.y,
|
|
defeated: false,
|
|
moveDirection: Math.random() * Math.PI * 2,
|
|
speed: 1.2 + Math.random() * 1.2, // 2x faster (was 0.6 + 0.6)
|
|
pattern: pattern,
|
|
patrolStartX: position.x,
|
|
patrolStartY: position.y,
|
|
patrolDistance: 80 + Math.random() * 60,
|
|
circleCenter: { x: position.x, y: position.y },
|
|
circleRadius: 60 + Math.random() * 40,
|
|
circleAngle: Math.random() * Math.PI * 2,
|
|
changeDirectionTimer: 0,
|
|
dashCooldown: 0,
|
|
isDashing: false,
|
|
dashDuration: 0
|
|
};
|
|
}
|
|
|
|
_getRandomPosition(forceAwayFromPlayer = false) {
|
|
const gameMap = document.getElementById('game-map');
|
|
const mapRect = gameMap.getBoundingClientRect();
|
|
const mapWidth = mapRect.width;
|
|
const mapHeight = mapRect.height;
|
|
const margin = 40;
|
|
|
|
let x, y;
|
|
let tooClose;
|
|
const minDistance = forceAwayFromPlayer ? 150 : 80;
|
|
|
|
do {
|
|
x = margin + Math.random() * (mapWidth - margin * 2);
|
|
y = margin + Math.random() * (mapHeight - margin * 2);
|
|
|
|
const distFromPlayer = Math.sqrt(
|
|
Math.pow(x - this._player.x, 2) + Math.pow(y - this._player.y, 2)
|
|
);
|
|
tooClose = distFromPlayer < minDistance;
|
|
|
|
} while (tooClose);
|
|
|
|
return { x, y };
|
|
}
|
|
|
|
_generateDecorations() {
|
|
const gameMap = document.getElementById('game-map');
|
|
const mapRect = gameMap.getBoundingClientRect();
|
|
const mapWidth = mapRect.width;
|
|
const mapHeight = mapRect.height;
|
|
|
|
// Remove existing decorations
|
|
gameMap.querySelectorAll('.decoration').forEach(el => el.remove());
|
|
|
|
// Generate trees
|
|
const numTrees = 4 + Math.floor(Math.random() * 4);
|
|
for (let i = 0; i < numTrees; i++) {
|
|
const tree = document.createElement('div');
|
|
tree.className = 'decoration tree';
|
|
tree.innerHTML = Math.random() < 0.5 ? '🌳' : '🌲';
|
|
|
|
const position = this._getDecorationPosition(mapWidth, mapHeight, 60);
|
|
tree.style.left = position.x + 'px';
|
|
tree.style.top = position.y + 'px';
|
|
tree.style.fontSize = ((25 + Math.random() * 15) / 1.66) + 'px'; // Reduced by 1.66
|
|
|
|
gameMap.appendChild(tree);
|
|
}
|
|
|
|
// Generate grass patches
|
|
const numGrass = 15 + Math.floor(Math.random() * 10);
|
|
for (let i = 0; i < numGrass; i++) {
|
|
const grass = document.createElement('div');
|
|
grass.className = 'decoration grass';
|
|
const grassTypes = ['🌿', '🌱', '🍀', '🌾'];
|
|
grass.innerHTML = grassTypes[Math.floor(Math.random() * grassTypes.length)];
|
|
|
|
const position = this._getDecorationPosition(mapWidth, mapHeight, 30);
|
|
grass.style.left = position.x + 'px';
|
|
grass.style.top = position.y + 'px';
|
|
grass.style.fontSize = ((15 + Math.random() * 8) / 1.66) + 'px'; // Reduced by 1.66
|
|
|
|
gameMap.appendChild(grass);
|
|
}
|
|
|
|
// Generate rocks
|
|
const numRocks = 3 + Math.floor(Math.random() * 3);
|
|
for (let i = 0; i < numRocks; i++) {
|
|
const rock = document.createElement('div');
|
|
rock.className = 'decoration rock';
|
|
rock.innerHTML = Math.random() < 0.5 ? '🪨' : '⛰️';
|
|
|
|
const position = this._getDecorationPosition(mapWidth, mapHeight, 40);
|
|
rock.style.left = position.x + 'px';
|
|
rock.style.top = position.y + 'px';
|
|
rock.style.fontSize = ((20 + Math.random() * 10) / 1.66) + 'px'; // Reduced by 1.66
|
|
|
|
gameMap.appendChild(rock);
|
|
}
|
|
}
|
|
|
|
_getDecorationPosition(mapWidth, mapHeight, keepAwayDistance) {
|
|
const margin = 20;
|
|
let x, y;
|
|
let attempts = 0;
|
|
let validPosition = false;
|
|
|
|
do {
|
|
x = margin + Math.random() * (mapWidth - margin * 2);
|
|
y = margin + Math.random() * (mapHeight - margin * 2);
|
|
|
|
const distFromPlayer = Math.sqrt(
|
|
Math.pow(x - this._player.x, 2) + Math.pow(y - this._player.y, 2)
|
|
);
|
|
|
|
let tooClose = distFromPlayer < keepAwayDistance;
|
|
|
|
if (!tooClose) {
|
|
this._pots.forEach(pot => {
|
|
const dist = Math.sqrt(Math.pow(x - pot.x, 2) + Math.pow(y - pot.y, 2));
|
|
if (dist < keepAwayDistance) tooClose = true;
|
|
});
|
|
}
|
|
|
|
if (!tooClose) {
|
|
this._enemies.forEach(enemy => {
|
|
const dist = Math.sqrt(Math.pow(x - enemy.x, 2) + Math.pow(y - enemy.y, 2));
|
|
if (dist < keepAwayDistance) tooClose = true;
|
|
});
|
|
}
|
|
|
|
validPosition = !tooClose;
|
|
attempts++;
|
|
|
|
} while (!validPosition && attempts < 50);
|
|
|
|
return { x, y };
|
|
}
|
|
|
|
_startGameLoop() {
|
|
const animate = () => {
|
|
if (this._isDestroyed) return; // Stop animation if game is destroyed
|
|
|
|
if (!this._isGamePaused) {
|
|
this._moveEnemies();
|
|
}
|
|
requestAnimationFrame(animate);
|
|
};
|
|
animate();
|
|
}
|
|
|
|
_moveEnemies() {
|
|
const gameMap = document.getElementById('game-map');
|
|
if (!gameMap) return; // Exit if game map doesn't exist
|
|
|
|
const mapRect = gameMap.getBoundingClientRect();
|
|
const mapWidth = mapRect.width;
|
|
const mapHeight = mapRect.height;
|
|
|
|
this._enemies.forEach(enemy => {
|
|
if (enemy.defeated) return;
|
|
|
|
this._applyMovementPattern(enemy, mapWidth, mapHeight);
|
|
|
|
// Bounce off walls
|
|
if (enemy.x < 10 || enemy.x > mapWidth - 50) {
|
|
enemy.moveDirection = Math.PI - enemy.moveDirection;
|
|
enemy.x = Math.max(10, Math.min(mapWidth - 50, enemy.x));
|
|
}
|
|
if (enemy.y < 10 || enemy.y > mapHeight - 50) {
|
|
enemy.moveDirection = -enemy.moveDirection;
|
|
enemy.y = Math.max(10, Math.min(mapHeight - 50, enemy.y));
|
|
}
|
|
|
|
enemy.element.style.left = enemy.x + 'px';
|
|
enemy.element.style.top = enemy.y + 'px';
|
|
|
|
// Add red shadow effect during dash
|
|
if (enemy.isDashing) {
|
|
enemy.element.style.filter = 'drop-shadow(0 0 10px rgba(255, 0, 0, 0.8)) drop-shadow(0 0 20px rgba(255, 0, 0, 0.5))';
|
|
enemy.element.style.transform = 'scale(1.1)'; // Slightly larger during dash
|
|
} else {
|
|
enemy.element.style.filter = '';
|
|
enemy.element.style.transform = '';
|
|
}
|
|
|
|
this._checkPlayerEnemyCollision(enemy);
|
|
});
|
|
}
|
|
|
|
_applyMovementPattern(enemy, mapWidth, mapHeight) {
|
|
enemy.changeDirectionTimer++;
|
|
|
|
switch (enemy.pattern) {
|
|
case 'patrol':
|
|
const distanceFromStart = Math.sqrt(
|
|
Math.pow(enemy.x - enemy.patrolStartX, 2) + Math.pow(enemy.y - enemy.patrolStartY, 2)
|
|
);
|
|
|
|
if (distanceFromStart > enemy.patrolDistance) {
|
|
const angleToStart = Math.atan2(
|
|
enemy.patrolStartY - enemy.y,
|
|
enemy.patrolStartX - enemy.x
|
|
);
|
|
enemy.moveDirection = angleToStart;
|
|
}
|
|
|
|
if (enemy.changeDirectionTimer > 120) {
|
|
enemy.moveDirection += (Math.random() - 0.5) * Math.PI * 0.5;
|
|
enemy.changeDirectionTimer = 0;
|
|
}
|
|
|
|
enemy.x += Math.cos(enemy.moveDirection) * enemy.speed;
|
|
enemy.y += Math.sin(enemy.moveDirection) * enemy.speed;
|
|
break;
|
|
|
|
case 'chase':
|
|
const angleToPlayer = Math.atan2(
|
|
this._player.y - enemy.y,
|
|
this._player.x - enemy.x
|
|
);
|
|
const distanceToPlayer = Math.sqrt(
|
|
Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2)
|
|
);
|
|
|
|
// Decrease dash cooldown
|
|
if (enemy.dashCooldown > 0) {
|
|
enemy.dashCooldown--;
|
|
}
|
|
|
|
// Trigger dash if close enough and cooldown is ready
|
|
if (!enemy.isDashing && enemy.dashCooldown <= 0 && distanceToPlayer < 300 && distanceToPlayer > 80) {
|
|
enemy.isDashing = true;
|
|
enemy.dashDuration = 30; // 30 frames of dash
|
|
enemy.dashCooldown = 120; // 120 frames cooldown (~2 seconds)
|
|
|
|
// Choose perpendicular direction (90° or -90° randomly)
|
|
const perpendicularOffset = Math.random() < 0.5 ? Math.PI / 2 : -Math.PI / 2;
|
|
enemy.dashAngle = angleToPlayer + perpendicularOffset;
|
|
}
|
|
|
|
// Handle dashing (perpendicular to player direction - evasive maneuver)
|
|
if (enemy.isDashing) {
|
|
// Use stored dash angle (perpendicular to player at dash start)
|
|
enemy.moveDirection = enemy.dashAngle;
|
|
enemy.x += Math.cos(enemy.dashAngle) * (enemy.speed * 3.5); // 3.5x speed during dash
|
|
enemy.y += Math.sin(enemy.dashAngle) * (enemy.speed * 3.5);
|
|
|
|
enemy.dashDuration--;
|
|
if (enemy.dashDuration <= 0) {
|
|
enemy.isDashing = false;
|
|
}
|
|
} else {
|
|
// Normal chase movement
|
|
enemy.moveDirection = angleToPlayer + (Math.random() - 0.5) * 0.3;
|
|
enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 0.8);
|
|
enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 0.8);
|
|
}
|
|
break;
|
|
|
|
case 'wander':
|
|
if (enemy.changeDirectionTimer > 60 + Math.random() * 60) {
|
|
enemy.moveDirection += (Math.random() - 0.5) * Math.PI;
|
|
enemy.changeDirectionTimer = 0;
|
|
}
|
|
|
|
enemy.x += Math.cos(enemy.moveDirection) * enemy.speed;
|
|
enemy.y += Math.sin(enemy.moveDirection) * enemy.speed;
|
|
break;
|
|
|
|
case 'circle':
|
|
enemy.circleAngle += 0.03 + (enemy.speed * 0.01);
|
|
|
|
enemy.x = enemy.circleCenter.x + Math.cos(enemy.circleAngle) * enemy.circleRadius;
|
|
enemy.y = enemy.circleCenter.y + Math.sin(enemy.circleAngle) * enemy.circleRadius;
|
|
break;
|
|
}
|
|
}
|
|
|
|
_handleMapClick(e) {
|
|
if (this._isGamePaused || this._isPlayerMoving) return;
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const clickX = e.clientX - rect.left;
|
|
const clickY = e.clientY - rect.top;
|
|
|
|
// Check pot clicks
|
|
let targetFound = false;
|
|
this._pots.forEach(pot => {
|
|
if (!pot.destroyed && this._isNearPosition(clickX, clickY, pot)) {
|
|
this._movePlayerToTarget(pot, 'pot');
|
|
targetFound = true;
|
|
}
|
|
});
|
|
|
|
// Check enemy clicks
|
|
if (!targetFound) {
|
|
this._enemies.forEach(enemy => {
|
|
if (!enemy.defeated && this._isNearPosition(clickX, clickY, enemy)) {
|
|
this._movePlayerToTarget(enemy, 'enemy');
|
|
targetFound = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Move to empty area
|
|
if (!targetFound) {
|
|
this._movePlayerToPosition(clickX, clickY);
|
|
}
|
|
}
|
|
|
|
_isNearPosition(clickX, clickY, object) {
|
|
const distance = Math.sqrt(
|
|
Math.pow(clickX - (object.x + 20), 2) + Math.pow(clickY - (object.y + 20), 2)
|
|
);
|
|
return distance < 60;
|
|
}
|
|
|
|
_movePlayerToTarget(target, type) {
|
|
this._isPlayerMoving = true;
|
|
const playerElement = document.getElementById('player');
|
|
|
|
if (type === 'enemy') {
|
|
this._grantAttackInvulnerability();
|
|
}
|
|
|
|
const targetX = target.x;
|
|
const targetY = target.y;
|
|
|
|
this._player.x = targetX;
|
|
this._player.y = targetY;
|
|
|
|
playerElement.style.left = targetX + 'px';
|
|
playerElement.style.top = targetY + 'px';
|
|
playerElement.style.transform = 'scale(1.1)';
|
|
|
|
setTimeout(() => {
|
|
playerElement.style.transform = 'scale(1)';
|
|
this._isPlayerMoving = false;
|
|
|
|
if (type === 'pot') {
|
|
this._destroyPot(target);
|
|
} else if (type === 'enemy') {
|
|
this._defeatEnemy(target);
|
|
}
|
|
}, 800);
|
|
}
|
|
|
|
_movePlayerToPosition(targetX, targetY) {
|
|
this._isPlayerMoving = true;
|
|
const playerElement = document.getElementById('player');
|
|
|
|
this._player.x = targetX - 20;
|
|
this._player.y = targetY - 20;
|
|
|
|
const gameMap = document.getElementById('game-map');
|
|
const mapRect = gameMap.getBoundingClientRect();
|
|
const margin = 20;
|
|
|
|
this._player.x = Math.max(margin, Math.min(mapRect.width - 60, this._player.x));
|
|
this._player.y = Math.max(margin, Math.min(mapRect.height - 60, this._player.y));
|
|
|
|
playerElement.style.left = this._player.x + 'px';
|
|
playerElement.style.top = this._player.y + 'px';
|
|
playerElement.style.transform = 'scale(1.1)';
|
|
|
|
setTimeout(() => {
|
|
playerElement.style.transform = 'scale(1)';
|
|
this._isPlayerMoving = false;
|
|
}, 800);
|
|
}
|
|
|
|
_destroyPot(pot) {
|
|
pot.destroyed = true;
|
|
pot.element.classList.add('destroyed');
|
|
|
|
pot.element.innerHTML = '💥';
|
|
setTimeout(() => {
|
|
pot.element.style.opacity = '0.3';
|
|
pot.element.innerHTML = '💨';
|
|
}, 200);
|
|
|
|
this._potsDestroyed++;
|
|
this._score += 10;
|
|
|
|
if (this._currentVocabIndex < this._vocabulary.length) {
|
|
this._showVocabPopup(this._vocabulary[this._currentVocabIndex]);
|
|
this._currentVocabIndex++;
|
|
}
|
|
|
|
this._updateHUD();
|
|
this._checkGameComplete();
|
|
}
|
|
|
|
_defeatEnemy(enemy) {
|
|
// CRITICAL: Mark enemy as defeated FIRST to prevent any further damage
|
|
enemy.defeated = true;
|
|
enemy.element.classList.add('defeated');
|
|
|
|
enemy.element.innerHTML = '☠️';
|
|
setTimeout(() => {
|
|
enemy.element.style.opacity = '0.3';
|
|
}, 300);
|
|
|
|
this._enemiesDefeated++;
|
|
this._score += 25;
|
|
|
|
// Clear any existing invulnerability timeout to prevent conflicts
|
|
// The reading modal will provide protection via pause,
|
|
// and post-reading invulnerability will be granted after modal closes
|
|
if (this._invulnerabilityTimeout) {
|
|
clearTimeout(this._invulnerabilityTimeout);
|
|
this._invulnerabilityTimeout = null;
|
|
}
|
|
|
|
// Keep player invulnerable until modal shows
|
|
this._isPlayerInvulnerable = true;
|
|
|
|
if (this._currentSentenceIndex < this._sentences.length) {
|
|
this._showReadingModal(this._sentences[this._currentSentenceIndex]);
|
|
this._currentSentenceIndex++;
|
|
}
|
|
|
|
this._updateHUD();
|
|
}
|
|
|
|
_showVocabPopup(vocab) {
|
|
const popup = document.getElementById('vocab-popup');
|
|
const wordEl = document.getElementById('vocab-word');
|
|
const translationEl = document.getElementById('vocab-translation');
|
|
const pronunciationEl = document.getElementById('vocab-pronunciation');
|
|
|
|
wordEl.textContent = vocab.original_language;
|
|
translationEl.textContent = vocab.user_language;
|
|
|
|
if (vocab.pronunciation) {
|
|
pronunciationEl.textContent = `🗣️ ${vocab.pronunciation}`;
|
|
pronunciationEl.style.display = 'block';
|
|
} else {
|
|
pronunciationEl.style.display = 'none';
|
|
}
|
|
|
|
popup.style.display = 'flex';
|
|
popup.classList.add('show');
|
|
|
|
if (this._config.autoPlayTTS && this._config.ttsEnabled) {
|
|
setTimeout(() => {
|
|
this._speakText(vocab.original_language, { rate: 0.8 });
|
|
}, 400);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
popup.classList.remove('show');
|
|
setTimeout(() => {
|
|
popup.style.display = 'none';
|
|
}, 300);
|
|
}, 2000);
|
|
}
|
|
|
|
_showReadingModal(sentence) {
|
|
this._isGamePaused = true;
|
|
const modal = document.getElementById('reading-modal');
|
|
const content = document.getElementById('reading-content');
|
|
const modalTitle = document.getElementById('modal-title');
|
|
|
|
let modalTitleText = 'Adventure Text';
|
|
if (sentence.speaker) {
|
|
modalTitleText = `💬 ${sentence.speaker} says...`;
|
|
} else if (sentence.title) {
|
|
modalTitleText = `📚 ${sentence.title}`;
|
|
}
|
|
|
|
modalTitle.textContent = modalTitleText;
|
|
|
|
const speakerInfo = sentence.speaker ? `<div class="speaker-info">🎭 ${sentence.speaker}</div>` : '';
|
|
const titleInfo = sentence.title && !sentence.speaker ? `<div class="story-title">📖 ${sentence.title}</div>` : '';
|
|
|
|
content.innerHTML = `
|
|
<div class="sentence-content ${sentence.speaker ? 'dialogue-content' : 'story-content'}">
|
|
${titleInfo}
|
|
${speakerInfo}
|
|
<div class="text-content">
|
|
<p class="original-text">${sentence.original_language}</p>
|
|
<p class="translation-text">${sentence.user_language}</p>
|
|
${sentence.pronunciation ? `<p class="pronunciation-text">🗣️ ${sentence.pronunciation}</p>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
modal.style.display = 'flex';
|
|
modal.classList.add('show');
|
|
|
|
// Calculate reading time based on text length and TTS
|
|
const textLength = sentence.original_language.length;
|
|
// Average reading speed: ~5 chars/second at 0.8 rate
|
|
// Add base delay of 800ms (600ms initial + 200ms buffer)
|
|
const ttsDelay = 600; // Initial delay before TTS starts
|
|
const readingTime = (textLength / 5) * 1000; // Characters to milliseconds
|
|
const bufferTime = 500; // Extra buffer after TTS ends
|
|
const totalTime = ttsDelay + readingTime + bufferTime;
|
|
|
|
if (this._config.autoPlayTTS && this._config.ttsEnabled) {
|
|
setTimeout(() => {
|
|
this._speakText(sentence.original_language, { rate: 0.8 });
|
|
}, ttsDelay);
|
|
}
|
|
|
|
// Auto-close modal after TTS completes
|
|
setTimeout(() => {
|
|
this._closeModal();
|
|
}, totalTime);
|
|
}
|
|
|
|
_closeModal() {
|
|
const modal = document.getElementById('reading-modal');
|
|
modal.classList.remove('show');
|
|
setTimeout(() => {
|
|
modal.style.display = 'none';
|
|
this._isGamePaused = false;
|
|
|
|
// Grant 1 second invulnerability after closing reading modal
|
|
this._grantPostReadingInvulnerability();
|
|
}, 300);
|
|
|
|
this._checkGameComplete();
|
|
}
|
|
|
|
_checkGameComplete() {
|
|
const allPotsDestroyed = this._pots.every(pot => pot.destroyed);
|
|
const allEnemiesDefeated = this._enemies.every(enemy => enemy.defeated);
|
|
|
|
if (allPotsDestroyed && allEnemiesDefeated) {
|
|
setTimeout(() => {
|
|
this._gameComplete();
|
|
}, 1000);
|
|
}
|
|
}
|
|
|
|
_gameComplete() {
|
|
this._score += 100;
|
|
this._updateHUD();
|
|
|
|
document.getElementById('progress-text').textContent = '🏆 Adventure Complete!';
|
|
|
|
// Calculate duration
|
|
const duration = Math.round((Date.now() - this._gameStartTime) / 1000);
|
|
|
|
// Handle localStorage best score
|
|
const currentScore = this._score;
|
|
const bestScore = parseInt(localStorage.getItem('adventure-reader-best-score') || '0');
|
|
const isNewBest = currentScore > bestScore;
|
|
|
|
if (isNewBest) {
|
|
localStorage.setItem('adventure-reader-best-score', currentScore.toString());
|
|
}
|
|
|
|
setTimeout(() => {
|
|
this._showVictoryPopup({
|
|
gameTitle: 'Adventure Reader',
|
|
currentScore,
|
|
bestScore: isNewBest ? currentScore : bestScore,
|
|
isNewBest,
|
|
stats: {
|
|
'Pots Destroyed': this._potsDestroyed,
|
|
'Enemies Defeated': this._enemiesDefeated,
|
|
'Duration': `${duration}s`,
|
|
'Bonus Score': '100'
|
|
}
|
|
});
|
|
}, 2000);
|
|
}
|
|
|
|
_updateHUD() {
|
|
document.getElementById('score-display').textContent = this._score;
|
|
document.getElementById('pots-counter').textContent = this._potsDestroyed;
|
|
document.getElementById('enemies-counter').textContent = this._enemiesDefeated;
|
|
|
|
const totalObjects = this._pots.length + this._enemies.length;
|
|
const destroyedObjects = this._potsDestroyed + this._enemiesDefeated;
|
|
|
|
document.getElementById('progress-text').textContent =
|
|
`Progress: ${destroyedObjects}/${totalObjects} objects`;
|
|
}
|
|
|
|
_checkPlayerEnemyCollision(enemy) {
|
|
// CRITICAL SAFETY CHECKS - Skip collision in ANY of these conditions:
|
|
// 1. Game is paused (reading modal open)
|
|
// 2. Player is invulnerable
|
|
// 3. Enemy is defeated
|
|
// 4. Player is currently moving (attacking)
|
|
if (this._isGamePaused || this._isPlayerInvulnerable || enemy.defeated || this._isPlayerMoving) {
|
|
return;
|
|
}
|
|
|
|
const distance = Math.sqrt(
|
|
Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2)
|
|
);
|
|
|
|
if (distance < 35) {
|
|
this._takeDamage();
|
|
}
|
|
}
|
|
|
|
_takeDamage() {
|
|
if (this._isPlayerInvulnerable) return;
|
|
|
|
this._score = Math.max(0, this._score - 20);
|
|
this._updateHUD();
|
|
|
|
if (this._invulnerabilityTimeout) {
|
|
clearTimeout(this._invulnerabilityTimeout);
|
|
}
|
|
|
|
this._isPlayerInvulnerable = true;
|
|
const playerElement = document.getElementById('player');
|
|
|
|
// Blinking animation (visual only)
|
|
let blinkCount = 0;
|
|
const blinkInterval = setInterval(() => {
|
|
playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3';
|
|
blinkCount++;
|
|
|
|
if (blinkCount >= 8) {
|
|
clearInterval(blinkInterval);
|
|
playerElement.style.opacity = '1';
|
|
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
|
|
}
|
|
}, 250);
|
|
|
|
// Actual invulnerability duration (independent of blink animation)
|
|
this._invulnerabilityTimeout = setTimeout(() => {
|
|
this._isPlayerInvulnerable = false;
|
|
}, 2000); // 2 seconds of actual invulnerability
|
|
|
|
this._showDamagePopup();
|
|
}
|
|
|
|
_grantAttackInvulnerability() {
|
|
this._isPlayerInvulnerable = true;
|
|
const playerElement = document.getElementById('player');
|
|
|
|
if (this._invulnerabilityTimeout) {
|
|
clearTimeout(this._invulnerabilityTimeout);
|
|
}
|
|
|
|
playerElement.style.filter = 'drop-shadow(0 0 15px gold) brightness(1.4)';
|
|
|
|
this._invulnerabilityTimeout = setTimeout(() => {
|
|
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
|
|
this._isPlayerInvulnerable = false;
|
|
}, 2000);
|
|
|
|
this._showInvulnerabilityPopup();
|
|
}
|
|
|
|
_grantPostReadingInvulnerability() {
|
|
this._isPlayerInvulnerable = true;
|
|
const playerElement = document.getElementById('player');
|
|
|
|
if (this._invulnerabilityTimeout) {
|
|
clearTimeout(this._invulnerabilityTimeout);
|
|
}
|
|
|
|
// Brief blue glow to indicate post-reading protection
|
|
playerElement.style.filter = 'drop-shadow(0 0 10px rgba(100, 150, 255, 0.8)) brightness(1.2)';
|
|
|
|
this._invulnerabilityTimeout = setTimeout(() => {
|
|
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
|
|
this._isPlayerInvulnerable = false;
|
|
}, 1000); // 1 second protection
|
|
}
|
|
|
|
_refreshAttackInvulnerability() {
|
|
if (this._invulnerabilityTimeout) {
|
|
clearTimeout(this._invulnerabilityTimeout);
|
|
}
|
|
|
|
const playerElement = document.getElementById('player');
|
|
this._isPlayerInvulnerable = true;
|
|
|
|
this._invulnerabilityTimeout = setTimeout(() => {
|
|
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
|
|
this._isPlayerInvulnerable = false;
|
|
}, 2000);
|
|
}
|
|
|
|
_showInvulnerabilityPopup() {
|
|
const popup = document.createElement('div');
|
|
popup.className = 'invulnerability-popup';
|
|
popup.innerHTML = 'Protected!';
|
|
popup.style.cssText = `
|
|
position: fixed;
|
|
left: 50%;
|
|
top: 25%;
|
|
transform: translate(-50%, -50%);
|
|
color: #FFD700;
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
z-index: 999;
|
|
pointer-events: none;
|
|
animation: protectionFloat 2s ease-out forwards;
|
|
`;
|
|
|
|
document.body.appendChild(popup);
|
|
|
|
setTimeout(() => {
|
|
popup.remove();
|
|
}, 2000);
|
|
}
|
|
|
|
_showDamagePopup() {
|
|
const damagePopup = document.createElement('div');
|
|
damagePopup.className = 'damage-popup';
|
|
damagePopup.innerHTML = '-20';
|
|
damagePopup.style.cssText = `
|
|
position: fixed;
|
|
left: 50%;
|
|
top: 30%;
|
|
transform: translate(-50%, -50%);
|
|
color: #EF4444;
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
z-index: 999;
|
|
pointer-events: none;
|
|
animation: damageFloat 1.5s ease-out forwards;
|
|
`;
|
|
|
|
document.body.appendChild(damagePopup);
|
|
|
|
setTimeout(() => {
|
|
damagePopup.remove();
|
|
}, 1500);
|
|
}
|
|
|
|
_restart() {
|
|
this._score = 0;
|
|
this._currentSentenceIndex = 0;
|
|
this._currentVocabIndex = 0;
|
|
this._potsDestroyed = 0;
|
|
this._enemiesDefeated = 0;
|
|
this._isGamePaused = false;
|
|
this._isPlayerMoving = false;
|
|
this._isPlayerInvulnerable = false;
|
|
|
|
if (this._invulnerabilityTimeout) {
|
|
clearTimeout(this._invulnerabilityTimeout);
|
|
this._invulnerabilityTimeout = null;
|
|
}
|
|
|
|
this._generateGameObjects();
|
|
this._initializePlayer();
|
|
this._generateDecorations();
|
|
|
|
document.getElementById('progress-text').textContent = 'Click objects to begin your adventure!';
|
|
}
|
|
|
|
_isGameComplete() {
|
|
const allPotsDestroyed = this._pots.every(pot => pot.destroyed);
|
|
const allEnemiesDefeated = this._enemies.every(enemy => enemy.defeated);
|
|
return allPotsDestroyed && allEnemiesDefeated;
|
|
}
|
|
|
|
_speakText(text, options = {}) {
|
|
if (!text || !this._config.ttsEnabled) return;
|
|
|
|
const language = this._getContentLanguage();
|
|
const rate = options.rate || 0.8;
|
|
|
|
ttsService.speak(text, language, { rate, volume: 1.0 });
|
|
}
|
|
|
|
_getContentLanguage() {
|
|
if (this._content.language) {
|
|
const langMap = {
|
|
'chinese': 'zh-CN',
|
|
'english': 'en-US',
|
|
'french': 'fr-FR',
|
|
'spanish': 'es-ES'
|
|
};
|
|
return langMap[this._content.language] || this._content.language;
|
|
}
|
|
return 'en-US';
|
|
}
|
|
|
|
_showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) {
|
|
const popup = document.createElement('div');
|
|
popup.className = 'victory-popup';
|
|
popup.innerHTML = `
|
|
<div class="victory-content">
|
|
<div class="victory-header">
|
|
<div class="victory-icon">🏰</div>
|
|
<h2 class="victory-title">${gameTitle} Complete!</h2>
|
|
${isNewBest ? '<div class="new-best-badge">🎉 New Best Score!</div>' : ''}
|
|
</div>
|
|
|
|
<div class="victory-scores">
|
|
<div class="score-display">
|
|
<div class="score-label">Your Score</div>
|
|
<div class="score-value">${currentScore}</div>
|
|
</div>
|
|
<div class="score-display best-score">
|
|
<div class="score-label">Best Score</div>
|
|
<div class="score-value">${bestScore}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="victory-stats">
|
|
${Object.entries(stats).map(([key, value]) => `
|
|
<div class="stat-row">
|
|
<span class="stat-name">${key}</span>
|
|
<span class="stat-value">${value}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div class="victory-buttons">
|
|
<button class="victory-btn primary" onclick="this.closest('.victory-popup').remove(); window.location.reload();">🔄 Play Again</button>
|
|
<button class="victory-btn secondary" onclick="this.closest('.victory-popup').remove(); window.app.getCore().router.navigate('/games');">🎮 Different Game</button>
|
|
<button class="victory-btn tertiary" onclick="this.closest('.victory-popup').remove(); window.app.getCore().router.navigate('/');">🏠 Main Menu</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(popup);
|
|
|
|
// Emit completion event after showing popup
|
|
this._eventBus.emit('game:completed', {
|
|
gameId: 'adventure-reader',
|
|
instanceId: this.name,
|
|
score: currentScore,
|
|
potsDestroyed: stats['Pots Destroyed'],
|
|
enemiesDefeated: stats['Enemies Defeated'],
|
|
duration: parseInt(stats['Duration'].replace('s', '')) * 1000
|
|
}, this.name);
|
|
}
|
|
|
|
_showError(message) {
|
|
if (this._config.container) {
|
|
this._config.container.innerHTML = `
|
|
<div class="game-error">
|
|
<h3>❌ Adventure Reader Error</h3>
|
|
<p>${message}</p>
|
|
<p>This content module needs adventure-compatible content:</p>
|
|
<ul style="text-align: left; margin: 1rem 0;">
|
|
<li><strong>📚 stories:</strong> Adventure texts with original and translated content</li>
|
|
<li><strong>💬 dialogues:</strong> Character conversations</li>
|
|
<li><strong>📝 vocabulary:</strong> Words with translations for discovery</li>
|
|
<li><strong>📖 sentences:</strong> Individual phrases for reading practice</li>
|
|
</ul>
|
|
<button class="back-btn" onclick="history.back()">← Go Back</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
_handlePause() {
|
|
this._isGamePaused = true;
|
|
this._eventBus.emit('game:paused', { instanceId: this.name }, this.name);
|
|
}
|
|
|
|
_handleResume() {
|
|
this._isGamePaused = false;
|
|
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
|
|
}
|
|
}
|
|
|
|
export default AdventureReader; |