Class_generator/src/games/AdventureReader.js
StillHammer 8ebc0b2334 Add TTS service, deployment docs, and refactor game modules
- 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>
2025-10-18 23:41:12 +08:00

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;