Major architectural update to replace fixed 50%/100% scoring with true dynamic percentages based on content volume: • Replace old interpolation system with Math.min(100, (count/optimal)*100) formula • Add embedded compatibility methods to all 14 game modules with static requirements • Remove compatibility cache system for real-time calculation • Fix content loading to pass complete modules with vocabulary (not just metadata) • Clean up duplicate syntax errors in adventure-reader and grammar-discovery • Update navigation.js module mapping to match actual exported class names Examples of new dynamic scoring: - 15 words / 20 optimal = 75% (was 87.5% with old interpolation) - 5 words / 10 minimum = 50% (was 25% with old linear system) - 30 words / 20 optimal = 100% (unchanged) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1195 lines
44 KiB
JavaScript
1195 lines
44 KiB
JavaScript
class WordDiscovery {
|
||
constructor({ container, content, onScoreUpdate, onGameEnd }) {
|
||
this.container = container;
|
||
this.content = content;
|
||
this.onScoreUpdate = onScoreUpdate;
|
||
this.onGameEnd = onGameEnd;
|
||
|
||
// Expose content globally for SettingsManager TTS language detection
|
||
window.currentGameContent = content;
|
||
|
||
this.currentWordIndex = 0;
|
||
this.discoveredWords = [];
|
||
this.currentPhase = 'discovery'; // discovery, practice
|
||
this.score = 0;
|
||
this.lives = 3;
|
||
this.wordsToLearn = [];
|
||
|
||
// Practice system - Global practice after all words discovered
|
||
this.practiceLevel = 1; // 1=Easy, 2=Medium, 3=Hard, 4=Expert
|
||
this.practiceRound = 0;
|
||
this.maxPracticeRounds = 6; // More rounds for mixed practice
|
||
this.practiceCorrectAnswers = 0;
|
||
this.practiceErrors = 0;
|
||
this.currentPracticeWords = []; // Mixed selection of all discovered words
|
||
|
||
this.injectCSS();
|
||
this.extractContent();
|
||
this.init();
|
||
}
|
||
|
||
injectCSS() {
|
||
if (document.getElementById('word-discovery-styles')) return;
|
||
|
||
const styleSheet = document.createElement('style');
|
||
styleSheet.id = 'word-discovery-styles';
|
||
styleSheet.textContent = `
|
||
.word-discovery-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
}
|
||
|
||
.discovery-hud {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 15px 20px;
|
||
background: rgba(0,0,0,0.2);
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.discovery-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
}
|
||
|
||
.progress-bar {
|
||
width: 200px;
|
||
height: 8px;
|
||
background: rgba(255,255,255,0.2);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #00ff88, #00cc6a);
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.discovery-main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 20px;
|
||
position: relative;
|
||
}
|
||
|
||
.word-card {
|
||
background: white;
|
||
border-radius: 20px;
|
||
padding: 40px;
|
||
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||
text-align: center;
|
||
max-width: 500px;
|
||
width: 100%;
|
||
color: #333;
|
||
transform: scale(0.9);
|
||
opacity: 0;
|
||
animation: cardAppear 0.5s ease forwards;
|
||
}
|
||
|
||
@keyframes cardAppear {
|
||
to {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.word-image {
|
||
width: 200px;
|
||
height: 200px;
|
||
object-fit: cover;
|
||
border-radius: 15px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.word-text {
|
||
font-size: 2.5em;
|
||
font-weight: bold;
|
||
color: #2c3e50;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.word-pronunciation {
|
||
font-size: 1.2em;
|
||
color: #7f8c8d;
|
||
margin-bottom: 15px;
|
||
font-style: italic;
|
||
}
|
||
|
||
.word-translation {
|
||
font-size: 1.8em;
|
||
color: #e74c3c;
|
||
font-weight: 600;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.discovery-controls {
|
||
display: flex;
|
||
gap: 15px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.discovery-btn {
|
||
padding: 12px 25px;
|
||
border: none;
|
||
border-radius: 25px;
|
||
font-size: 1.1em;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||
color: white;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: linear-gradient(45deg, #f093fb, #f5576c);
|
||
color: white;
|
||
}
|
||
|
||
.btn-success {
|
||
background: linear-gradient(45deg, #4facfe, #00f2fe);
|
||
color: white;
|
||
}
|
||
|
||
.discovery-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.association-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 30px;
|
||
max-width: 800px;
|
||
width: 100%;
|
||
}
|
||
|
||
.association-item {
|
||
background: white;
|
||
border-radius: 15px;
|
||
padding: 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
color: #333;
|
||
border: 3px solid transparent;
|
||
}
|
||
|
||
.association-item:hover {
|
||
transform: translateY(-5px);
|
||
box-shadow: 0 15px 30px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.association-item.selected {
|
||
border-color: #667eea;
|
||
background: #f8f9ff;
|
||
}
|
||
|
||
.association-item.correct {
|
||
border-color: #00ff88;
|
||
background: #f0fff4;
|
||
}
|
||
|
||
.association-item.incorrect {
|
||
border-color: #ff4757;
|
||
background: #fff0f0;
|
||
}
|
||
|
||
.association-image {
|
||
width: 120px;
|
||
height: 120px;
|
||
object-fit: cover;
|
||
border-radius: 10px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.association-text {
|
||
font-size: 1.4em;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.phase-indicator {
|
||
position: absolute;
|
||
top: 20px;
|
||
left: 20px;
|
||
background: rgba(255,255,255,0.2);
|
||
padding: 8px 16px;
|
||
border-radius: 20px;
|
||
font-weight: 600;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.practice-progress {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: rgba(255,255,255,0.2);
|
||
padding: 8px 16px;
|
||
border-radius: 20px;
|
||
font-weight: 600;
|
||
backdrop-filter: blur(10px);
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.difficulty-badge {
|
||
display: inline-block;
|
||
padding: 4px 12px;
|
||
border-radius: 15px;
|
||
font-size: 0.8em;
|
||
font-weight: bold;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.difficulty-easy { background: #4CAF50; color: white; }
|
||
.difficulty-medium { background: #FF9800; color: white; }
|
||
.difficulty-hard { background: #F44336; color: white; }
|
||
.difficulty-expert { background: #9C27B0; color: white; }
|
||
|
||
.practice-grid-6 {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 20px;
|
||
max-width: 900px;
|
||
width: 100%;
|
||
}
|
||
|
||
.practice-grid-8 {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 15px;
|
||
max-width: 1000px;
|
||
width: 100%;
|
||
}
|
||
|
||
.practice-challenge {
|
||
text-align: center;
|
||
margin-bottom: 30px;
|
||
padding: 20px;
|
||
background: rgba(255,255,255,0.1);
|
||
border-radius: 15px;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.challenge-timer {
|
||
font-size: 2em;
|
||
font-weight: bold;
|
||
color: #FFD700;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.challenge-text {
|
||
font-size: 1.2em;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.practice-stats {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
margin-top: 20px;
|
||
font-size: 1.1em;
|
||
}
|
||
|
||
.stat-item {
|
||
text-align: center;
|
||
padding: 10px;
|
||
background: rgba(255,255,255,0.1);
|
||
border-radius: 10px;
|
||
backdrop-filter: blur(5px);
|
||
}
|
||
|
||
.association-item.time-pressure {
|
||
animation: timePressure 0.5s ease-in-out infinite alternate;
|
||
}
|
||
|
||
@keyframes timePressure {
|
||
from { box-shadow: 0 0 10px rgba(255,215,0,0.5); }
|
||
to { box-shadow: 0 0 20px rgba(255,215,0,0.8); }
|
||
}
|
||
|
||
.audio-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 2em;
|
||
cursor: pointer;
|
||
color: #667eea;
|
||
margin-left: 10px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.audio-btn:hover {
|
||
transform: scale(1.2);
|
||
color: #764ba2;
|
||
}
|
||
|
||
.completion-message {
|
||
text-align: center;
|
||
padding: 40px;
|
||
background: rgba(255,255,255,0.1);
|
||
border-radius: 20px;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.completion-title {
|
||
font-size: 2.5em;
|
||
margin-bottom: 20px;
|
||
color: #00ff88;
|
||
}
|
||
|
||
.completion-stats {
|
||
font-size: 1.3em;
|
||
margin-bottom: 30px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.content-warning {
|
||
background: rgba(255, 193, 7, 0.2);
|
||
border: 2px solid #FFC107;
|
||
border-radius: 10px;
|
||
padding: 15px;
|
||
margin: 20px 0;
|
||
color: #856404;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.feature-missing {
|
||
opacity: 0.6;
|
||
position: relative;
|
||
}
|
||
|
||
.feature-missing::after {
|
||
content: '📵';
|
||
position: absolute;
|
||
top: 5px;
|
||
right: 5px;
|
||
font-size: 0.8em;
|
||
}
|
||
`;
|
||
document.head.appendChild(styleSheet);
|
||
}
|
||
|
||
extractContent() {
|
||
if (!this.content || !this.content.vocabulary) {
|
||
this.wordsToLearn = [];
|
||
return;
|
||
}
|
||
|
||
this.wordsToLearn = Object.entries(this.content.vocabulary).map(([word, data]) => ({
|
||
word: word,
|
||
translation: typeof data === 'string' ? data : data.translation,
|
||
pronunciation: typeof data === 'object' ? data.pronunciation : null,
|
||
image: typeof data === 'object' ? data.image : null,
|
||
type: typeof data === 'object' ? data.type : 'word'
|
||
})).filter(item => item.translation);
|
||
|
||
// Shuffle words for variety
|
||
this.wordsToLearn = this.shuffleArray([...this.wordsToLearn]);
|
||
}
|
||
|
||
shuffleArray(array) {
|
||
for (let i = array.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[array[i], array[j]] = [array[j], array[i]];
|
||
}
|
||
return array;
|
||
}
|
||
|
||
init() {
|
||
this.container.innerHTML = `
|
||
<div class="word-discovery-wrapper">
|
||
<div class="discovery-hud">
|
||
<div class="discovery-progress">
|
||
<span>Progress:</span>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" style="width: 0%"></div>
|
||
</div>
|
||
<span class="progress-text">0/${this.wordsToLearn.length}</span>
|
||
</div>
|
||
<div class="discovery-stats">
|
||
<span>Score: <span class="score-display">0</span></span>
|
||
<span style="margin-left: 20px;">❤️ <span class="lives-display">3</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="discovery-main">
|
||
<div class="phase-indicator">Discovery Phase</div>
|
||
<div class="game-content"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
if (this.wordsToLearn.length === 0) {
|
||
this.showNoContent();
|
||
return;
|
||
}
|
||
|
||
this.updateHUD();
|
||
this.startDiscoveryPhase();
|
||
}
|
||
|
||
updateHUD() {
|
||
const progressFill = this.container.querySelector('.progress-fill');
|
||
const progressText = this.container.querySelector('.progress-text');
|
||
const scoreDisplay = this.container.querySelector('.score-display');
|
||
const livesDisplay = this.container.querySelector('.lives-display');
|
||
|
||
const progressPercent = (this.currentWordIndex / this.wordsToLearn.length) * 100;
|
||
progressFill.style.width = `${progressPercent}%`;
|
||
progressText.textContent = `${this.currentWordIndex}/${this.wordsToLearn.length}`;
|
||
scoreDisplay.textContent = this.score;
|
||
livesDisplay.textContent = this.lives;
|
||
}
|
||
|
||
startDiscoveryPhase() {
|
||
this.currentPhase = 'discovery';
|
||
this.container.querySelector('.phase-indicator').textContent = 'Discovery Phase';
|
||
this.showWordCard();
|
||
}
|
||
|
||
showWordCard() {
|
||
if (this.currentWordIndex >= this.wordsToLearn.length) {
|
||
// All words discovered - start global practice phase
|
||
this.startGlobalPractice();
|
||
return;
|
||
}
|
||
|
||
const word = this.wordsToLearn[this.currentWordIndex];
|
||
const gameContent = this.container.querySelector('.game-content');
|
||
|
||
// Check what features are missing for this word
|
||
const missingFeatures = [];
|
||
if (!word.image) missingFeatures.push('image');
|
||
if (!word.pronunciation) missingFeatures.push('pronunciation');
|
||
|
||
gameContent.innerHTML = `
|
||
<div class="word-card">
|
||
${word.image ?
|
||
`<img src="${word.image}" alt="${word.word}" class="word-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">` :
|
||
`<div class="word-image feature-missing" style="background: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #999;">📷 No Image</div>`
|
||
}
|
||
<div class="word-text">${word.word}</div>
|
||
${word.pronunciation ?
|
||
`<div class="word-pronunciation">/${word.pronunciation}/</div>` :
|
||
`<div class="word-pronunciation feature-missing" style="color: #999; font-style: normal;">No pronunciation guide</div>`
|
||
}
|
||
<div class="word-translation">${word.translation}</div>
|
||
${missingFeatures.length > 0 ?
|
||
`<div class="content-warning">
|
||
⚠️ Missing: ${missingFeatures.join(', ')}. Practice questions will be adapted accordingly.
|
||
</div>` : ''
|
||
}
|
||
<div class="discovery-controls">
|
||
<button class="discovery-btn btn-primary ${!word.pronunciation ? 'feature-missing' : ''}" onclick="this.closest('.word-discovery-wrapper').game.hearPronunciation()">
|
||
🔊 Listen ${!word.pronunciation ? '(TTS)' : ''}
|
||
</button>
|
||
<button class="discovery-btn btn-success" onclick="this.closest('.word-discovery-wrapper').game.markAsLearned()">
|
||
Got It!
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Store game reference for button access
|
||
this.container.querySelector('.word-discovery-wrapper').game = this;
|
||
|
||
// Auto-play TTS when new word appears (with delay for card animation)
|
||
setTimeout(() => {
|
||
this.hearPronunciation();
|
||
}, 800);
|
||
}
|
||
|
||
async hearPronunciation(options = {}) {
|
||
let wordToSpeak;
|
||
|
||
if (this.currentPhase === 'practice') {
|
||
// In practice phase, use current practice word
|
||
wordToSpeak = this.currentPracticeWords[this.practiceRound % this.currentPracticeWords.length];
|
||
} else {
|
||
// In discovery phase, use current word being learned
|
||
wordToSpeak = this.wordsToLearn[this.currentWordIndex];
|
||
}
|
||
|
||
if (!wordToSpeak) return;
|
||
|
||
// Try to play audio file first if available
|
||
if (wordToSpeak.audioFile || wordToSpeak.pronunciation) {
|
||
const audioPath = wordToSpeak.audioFile;
|
||
if (audioPath) {
|
||
try {
|
||
const audio = new Audio(audioPath);
|
||
|
||
// Handle audio loading errors
|
||
audio.onerror = () => {
|
||
console.warn(`Audio file not found: ${audioPath}, falling back to TTS`);
|
||
this.fallbackToTTS(wordToSpeak, options);
|
||
};
|
||
|
||
// Handle successful audio loading
|
||
audio.oncanplaythrough = () => {
|
||
// Adjust playback rate if supported
|
||
if (options.rate && audio.playbackRate !== undefined) {
|
||
audio.playbackRate = options.rate;
|
||
}
|
||
audio.play().catch(error => {
|
||
console.warn('Audio playback failed:', error);
|
||
this.fallbackToTTS(wordToSpeak, options);
|
||
});
|
||
};
|
||
|
||
// Load the audio
|
||
audio.load();
|
||
|
||
// Timeout fallback if audio takes too long
|
||
setTimeout(() => {
|
||
if (audio.readyState === 0) {
|
||
console.warn('Audio loading timeout, falling back to TTS');
|
||
this.fallbackToTTS(wordToSpeak, options);
|
||
}
|
||
}, 2000);
|
||
|
||
return; // Don't proceed to TTS if we're trying audio
|
||
} catch (error) {
|
||
console.warn('Audio creation failed:', error);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback to TTS immediately if no audio file
|
||
this.fallbackToTTS(wordToSpeak, options);
|
||
}
|
||
|
||
fallbackToTTS(wordToSpeak, options = {}) {
|
||
// Use SettingsManager if available, otherwise fallback to basic TTS
|
||
if (window.SettingsManager) {
|
||
// Pass custom rate if specified
|
||
const ttsOptions = {};
|
||
if (options.rate) {
|
||
ttsOptions.rate = options.rate;
|
||
}
|
||
|
||
window.SettingsManager.speak(wordToSpeak.word, ttsOptions)
|
||
.catch(error => {
|
||
console.warn('SettingsManager TTS failed:', error);
|
||
this.basicTTS(wordToSpeak, options);
|
||
});
|
||
} else {
|
||
this.basicTTS(wordToSpeak, options);
|
||
}
|
||
}
|
||
|
||
basicTTS(wordToSpeak, options = {}) {
|
||
// Try to speak the word using Web Speech API
|
||
if ('speechSynthesis' in window && wordToSpeak) {
|
||
const utterance = new SpeechSynthesisUtterance(wordToSpeak.word);
|
||
utterance.lang = 'en-US';
|
||
utterance.rate = options.rate || 0.8;
|
||
speechSynthesis.speak(utterance);
|
||
} else {
|
||
// Last resort: show pronunciation text if available
|
||
if (wordToSpeak.pronunciation) {
|
||
alert(`Pronunciation: /${wordToSpeak.pronunciation}/`);
|
||
} else {
|
||
alert(`Word: ${wordToSpeak.word}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
updatePhaseIndicator() {
|
||
const phaseIndicator = this.container.querySelector('.phase-indicator');
|
||
const difficultyNames = ['', 'Easy', 'Medium', 'Hard', 'Expert'];
|
||
const difficultyClasses = ['', 'difficulty-easy', 'difficulty-medium', 'difficulty-hard', 'difficulty-expert'];
|
||
|
||
if (this.currentPhase === 'practice') {
|
||
phaseIndicator.innerHTML = `Mixed Practice <span class="difficulty-badge ${difficultyClasses[this.practiceLevel]}">${difficultyNames[this.practiceLevel]}</span>`;
|
||
} else {
|
||
phaseIndicator.textContent = 'Discovery Phase';
|
||
}
|
||
|
||
// Update or create practice progress indicator
|
||
let progressIndicator = this.container.querySelector('.practice-progress');
|
||
if (!progressIndicator) {
|
||
progressIndicator = document.createElement('div');
|
||
progressIndicator.className = 'practice-progress';
|
||
this.container.querySelector('.discovery-main').appendChild(progressIndicator);
|
||
}
|
||
|
||
if (this.currentPhase === 'practice') {
|
||
progressIndicator.textContent = `Round ${this.practiceRound + 1}/${this.maxPracticeRounds}`;
|
||
} else {
|
||
progressIndicator.textContent = '';
|
||
}
|
||
}
|
||
|
||
showMixedPracticeChallenge() {
|
||
// Get a random word from discovered words for this challenge
|
||
const currentWord = this.currentPracticeWords[this.practiceRound % this.currentPracticeWords.length];
|
||
const gameContent = this.container.querySelector('.game-content');
|
||
|
||
// Check available content features
|
||
const hasImages = this.discoveredWords.some(word => word.image);
|
||
const hasPronunciation = this.discoveredWords.some(word => word.pronunciation);
|
||
const hasAudioFiles = this.discoveredWords.some(word => word.audioFile);
|
||
const currentWordHasImage = currentWord.image;
|
||
const currentWordHasPronunciation = currentWord.pronunciation;
|
||
const currentWordHasAudio = currentWord.audioFile;
|
||
|
||
// Determine challenge based on practice level and available content
|
||
const challenges = {
|
||
1: { options: 4, time: null, question: 'translation' }, // Easy: 4 options, no timer
|
||
2: { options: 6, time: 15, question: 'translation' }, // Medium: 6 options, 15s timer
|
||
3: { options: 6, time: 10, question: hasImages ? 'mixed' : 'translation' }, // Hard: mixed if images available
|
||
4: { options: 8, time: 8, question: (hasAudioFiles || hasPronunciation) ? 'audio' : 'translation' } // Expert: audio if available
|
||
};
|
||
|
||
const challenge = challenges[this.practiceLevel];
|
||
const numOptions = challenge.options;
|
||
|
||
// Create options: current word + random others from ALL discovered words
|
||
const options = [currentWord];
|
||
const otherWords = this.discoveredWords.filter(word => word.word !== currentWord.word);
|
||
const randomOthers = this.shuffleArray([...otherWords]).slice(0, numOptions - 1);
|
||
options.push(...randomOthers);
|
||
|
||
// Shuffle the options
|
||
const shuffledOptions = this.shuffleArray([...options]);
|
||
|
||
// Determine question type - TEST FOREIGN WORD KNOWLEDGE, NOT NATIVE LANGUAGE
|
||
let questionText = '';
|
||
let showImages = true;
|
||
let showText = true;
|
||
|
||
if (challenge.question === 'translation') {
|
||
// Test: Show foreign word, find translation/image
|
||
questionText = `Which one means "${currentWord.word}"?`;
|
||
} else if (challenge.question === 'mixed') {
|
||
// Build available question types based on content
|
||
const questionTypes = [`Which one means "${currentWord.word}"?`];
|
||
|
||
// Add pronunciation question if available (text or audio)
|
||
if (currentWordHasPronunciation || currentWordHasAudio) {
|
||
questionTypes.push(`Find the word that sounds like "${currentWord.pronunciation || currentWord.word}"`);
|
||
}
|
||
|
||
// Add image question if current word has image AND other words have images for comparison
|
||
if (currentWordHasImage && hasImages) {
|
||
questionTypes.push(`Which image represents "${currentWord.word}"?`);
|
||
}
|
||
|
||
questionText = questionTypes[Math.floor(Math.random() * questionTypes.length)];
|
||
if (questionText.includes('image')) {
|
||
showText = false;
|
||
// Ensure we only show options that have images
|
||
const imageOptions = [currentWord];
|
||
const otherWordsWithImages = this.discoveredWords.filter(word =>
|
||
word.word !== currentWord.word && word.image
|
||
);
|
||
if (otherWordsWithImages.length >= numOptions - 1) {
|
||
const randomOthers = this.shuffleArray([...otherWordsWithImages]).slice(0, numOptions - 1);
|
||
options.length = 1; // Reset to just current word
|
||
options.push(...randomOthers);
|
||
}
|
||
}
|
||
} else if (challenge.question === 'audio') {
|
||
if (currentWordHasPronunciation || currentWordHasAudio) {
|
||
questionText = 'Listen and find the correct word!';
|
||
showImages = false;
|
||
// Auto-play pronunciation
|
||
setTimeout(() => this.hearPronunciation(), 500);
|
||
} else {
|
||
// Fallback to translation if no audio
|
||
questionText = `Which one means "${currentWord.word}"?`;
|
||
}
|
||
}
|
||
|
||
const gridClass = numOptions <= 4 ? 'association-grid' :
|
||
numOptions <= 6 ? 'practice-grid-6' : 'practice-grid-8';
|
||
|
||
gameContent.innerHTML = `
|
||
<div class="practice-challenge">
|
||
${challenge.time ? `<div class="challenge-timer" id="practice-timer">${challenge.time}</div>` : ''}
|
||
<div class="challenge-text">${questionText}</div>
|
||
<div class="practice-stats">
|
||
<div class="stat-item">Correct: ${this.practiceCorrectAnswers}</div>
|
||
<div class="stat-item">Errors: ${this.practiceErrors}</div>
|
||
<div class="stat-item">Level: ${this.practiceLevel}/4</div>
|
||
</div>
|
||
</div>
|
||
<div class="${gridClass}">
|
||
${shuffledOptions.map((option, index) => `
|
||
<div class="association-item${challenge.time ? ' time-pressure' : ''}" onclick="this.closest('.word-discovery-wrapper').game.selectMixedPractice(${shuffledOptions.indexOf(option)}, '${option.word}')">
|
||
${showImages && option.image ? `<img src="${option.image}" alt="${option.word}" class="association-image" onerror="this.style.display='none'">` : ''}
|
||
${showText ? `<div class="association-text">${option.translation}</div>` : ''}
|
||
${!showText && !showImages ? `<div class="association-text">?</div>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// Start timer if needed
|
||
if (challenge.time) {
|
||
this.startPracticeTimer(challenge.time);
|
||
}
|
||
|
||
// Auto-play TTS based on practice level with appropriate speed
|
||
setTimeout(() => {
|
||
let ttsSpeed;
|
||
switch (this.practiceLevel) {
|
||
case 1: // Easy - 0.7 speed
|
||
ttsSpeed = 0.7;
|
||
break;
|
||
case 2: // Medium - 0.9 speed
|
||
ttsSpeed = 0.9;
|
||
break;
|
||
case 3: // Hard - 1.0 speed
|
||
ttsSpeed = 1.0;
|
||
break;
|
||
case 4: // Expert - 1.1 speed (already has audio auto-play)
|
||
ttsSpeed = 1.1;
|
||
break;
|
||
default:
|
||
ttsSpeed = 0.8; // Fallback
|
||
}
|
||
|
||
// Don't auto-play if it's an audio-only challenge (Expert mode already handles this)
|
||
if (challenge.question !== 'audio') {
|
||
this.hearPronunciation({ rate: ttsSpeed });
|
||
}
|
||
}, 1000); // Delay to let interface render
|
||
}
|
||
|
||
startPracticeTimer(seconds) {
|
||
this.practiceTimer = seconds;
|
||
this.practiceTimerInterval = setInterval(() => {
|
||
this.practiceTimer--;
|
||
const timerElement = document.getElementById('practice-timer');
|
||
if (timerElement) {
|
||
timerElement.textContent = this.practiceTimer;
|
||
if (this.practiceTimer <= 3) {
|
||
timerElement.style.color = '#FF4444';
|
||
timerElement.style.animation = 'pulse 0.5s infinite';
|
||
}
|
||
}
|
||
|
||
if (this.practiceTimer <= 0) {
|
||
this.clearPracticeTimer();
|
||
this.selectPractice(-1, 'TIMEOUT'); // Handle timeout
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
clearPracticeTimer() {
|
||
if (this.practiceTimerInterval) {
|
||
clearInterval(this.practiceTimerInterval);
|
||
this.practiceTimerInterval = null;
|
||
}
|
||
}
|
||
|
||
selectMixedPractice(selectedIndex, selectedWord) {
|
||
this.clearPracticeTimer();
|
||
|
||
const currentWord = this.currentPracticeWords[this.practiceRound % this.currentPracticeWords.length];
|
||
const items = this.container.querySelectorAll('.association-item');
|
||
|
||
let isCorrect = false;
|
||
|
||
if (selectedWord === 'TIMEOUT') {
|
||
// Timer expired
|
||
this.practiceErrors++;
|
||
// Show correct answer
|
||
items.forEach((item) => {
|
||
const text = item.querySelector('.association-text');
|
||
if (text && text.textContent === currentWord.word) {
|
||
item.classList.add('correct');
|
||
}
|
||
});
|
||
} else if (selectedWord === currentWord.word) {
|
||
// Correct answer
|
||
isCorrect = true;
|
||
items[selectedIndex].classList.add('correct');
|
||
this.practiceCorrectAnswers++;
|
||
|
||
// Score based on difficulty level
|
||
const scoreBonus = [0, 5, 10, 15, 25][this.practiceLevel];
|
||
this.score += scoreBonus;
|
||
this.onScoreUpdate(this.score);
|
||
} else {
|
||
// Wrong answer
|
||
items[selectedIndex].classList.add('incorrect');
|
||
this.practiceErrors++;
|
||
|
||
// Show correct answer
|
||
items.forEach((item) => {
|
||
const text = item.querySelector('.association-text');
|
||
if (text && text.textContent === currentWord.word) {
|
||
item.classList.add('correct');
|
||
}
|
||
});
|
||
}
|
||
|
||
this.updateHUD();
|
||
|
||
// Continue to next practice round or advance
|
||
setTimeout(() => {
|
||
this.practiceRound++;
|
||
|
||
if (this.practiceRound >= this.maxPracticeRounds) {
|
||
// Check if ready for next level
|
||
const accuracy = this.practiceCorrectAnswers / this.maxPracticeRounds;
|
||
|
||
if (accuracy >= 0.75 && this.practiceLevel < 4) {
|
||
// Advance to next difficulty level
|
||
this.practiceLevel++;
|
||
this.practiceRound = 0;
|
||
this.practiceCorrectAnswers = 0;
|
||
this.practiceErrors = 0;
|
||
|
||
// SHUFFLE words again for new difficulty level
|
||
this.currentPracticeWords = this.shuffleArray([...this.discoveredWords]);
|
||
console.log(`🔀 Shuffled words for Level ${this.practiceLevel} - new variation order`);
|
||
|
||
this.updatePhaseIndicator();
|
||
|
||
setTimeout(() => {
|
||
this.showLevelUpMessage();
|
||
}, 500);
|
||
} else if (accuracy >= 0.5) {
|
||
// Passed all practice levels - show completion
|
||
this.showCompletion();
|
||
} else {
|
||
// Failed practice - retry current level
|
||
this.practiceRound = Math.max(0, this.practiceRound - 2); // Go back 2 rounds
|
||
this.practiceCorrectAnswers = 0;
|
||
this.practiceErrors = 0;
|
||
this.lives--;
|
||
if (this.lives <= 0) {
|
||
this.endGame();
|
||
return;
|
||
}
|
||
}
|
||
} else {
|
||
// Continue current difficulty level with next random word
|
||
this.updatePhaseIndicator();
|
||
this.showMixedPracticeChallenge();
|
||
}
|
||
}, 1500);
|
||
}
|
||
|
||
showLevelUpMessage() {
|
||
const gameContent = this.container.querySelector('.game-content');
|
||
const difficultyNames = ['', 'Easy', 'Medium', 'Hard', 'Expert'];
|
||
|
||
gameContent.innerHTML = `
|
||
<div class="completion-message">
|
||
<div class="completion-title">🎉 Level Up!</div>
|
||
<div class="completion-stats">
|
||
Advanced to ${difficultyNames[this.practiceLevel]} difficulty!<br>
|
||
Keep practicing to master this word!
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
setTimeout(() => {
|
||
this.showMixedPracticeChallenge();
|
||
}, 2000);
|
||
}
|
||
|
||
markAsLearned() {
|
||
this.discoveredWords.push(this.wordsToLearn[this.currentWordIndex]);
|
||
this.currentWordIndex++;
|
||
this.score += 5;
|
||
this.onScoreUpdate(this.score);
|
||
this.updateHUD();
|
||
|
||
setTimeout(() => {
|
||
this.startDiscoveryPhase();
|
||
}, 300);
|
||
}
|
||
|
||
startGlobalPractice() {
|
||
// Transition message
|
||
const gameContent = this.container.querySelector('.game-content');
|
||
gameContent.innerHTML = `
|
||
<div class="completion-message">
|
||
<div class="completion-title">🏆 Discovery Complete!</div>
|
||
<div class="completion-stats">
|
||
You've discovered all ${this.discoveredWords.length} words!<br>
|
||
Now let's practice with mixed vocabulary challenges!
|
||
</div>
|
||
<div class="discovery-controls">
|
||
<button class="discovery-btn btn-primary" onclick="this.closest('.word-discovery-wrapper').game.startMixedPractice()">
|
||
Start Practice
|
||
</button>
|
||
<button class="discovery-btn btn-secondary" onclick="this.closest('.word-discovery-wrapper').game.skipToCompletion()">
|
||
Skip Practice
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
startMixedPractice() {
|
||
this.currentPhase = 'practice';
|
||
this.practiceLevel = 1;
|
||
this.practiceRound = 0;
|
||
this.practiceCorrectAnswers = 0;
|
||
this.practiceErrors = 0;
|
||
|
||
// SHUFFLE discovered words for varied practice order
|
||
this.currentPracticeWords = this.shuffleArray([...this.discoveredWords]);
|
||
console.log(`🔀 Shuffled ${this.currentPracticeWords.length} words for practice variation`);
|
||
|
||
this.updatePhaseIndicator();
|
||
this.showMixedPracticeChallenge();
|
||
}
|
||
|
||
skipToCompletion() {
|
||
this.showCompletion();
|
||
}
|
||
|
||
showCompletion() {
|
||
const gameContent = this.container.querySelector('.game-content');
|
||
const accuracy = Math.round((this.discoveredWords.length / this.wordsToLearn.length) * 100);
|
||
const practiceAccuracy = this.practiceRound > 0 ? Math.round((this.practiceCorrectAnswers / this.practiceRound) * 100) : 0;
|
||
|
||
gameContent.innerHTML = `
|
||
<div class="completion-message">
|
||
<div class="completion-title">🏆 Vocabulary Mastered!</div>
|
||
<div class="completion-stats">
|
||
Words Discovered: ${this.discoveredWords.length}/${this.wordsToLearn.length}<br>
|
||
Practice Accuracy: ${practiceAccuracy}%<br>
|
||
Final Score: ${this.score}<br>
|
||
Practice Level Reached: ${this.practiceLevel}/4
|
||
</div>
|
||
<div class="discovery-controls">
|
||
<button class="discovery-btn btn-primary" onclick="this.closest('.word-discovery-wrapper').game.restart()">
|
||
Learn Again
|
||
</button>
|
||
<button class="discovery-btn btn-secondary" onclick="this.closest('.word-discovery-wrapper').game.endGame()">
|
||
Back to Games
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
showNoContent() {
|
||
const gameContent = this.container.querySelector('.game-content');
|
||
gameContent.innerHTML = `
|
||
<div class="completion-message">
|
||
<div class="completion-title">📚 No Vocabulary Found</div>
|
||
<div class="completion-stats">
|
||
This content doesn't have vocabulary for the Word Discovery game.<br>
|
||
<small>Note: Images and audio are optional but enhance the experience!</small>
|
||
</div>
|
||
<div class="discovery-controls">
|
||
<button class="discovery-btn btn-secondary" onclick="this.closest('.word-discovery-wrapper').game.endGame()">
|
||
Back to Games
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
start() {
|
||
// Game starts automatically in constructor
|
||
}
|
||
|
||
restart() {
|
||
this.currentWordIndex = 0;
|
||
this.discoveredWords = [];
|
||
this.score = 0;
|
||
this.lives = 3;
|
||
this.practiceLevel = 1;
|
||
this.practiceRound = 0;
|
||
this.practiceCorrectAnswers = 0;
|
||
this.practiceErrors = 0;
|
||
this.currentPracticeWords = [];
|
||
this.clearPracticeTimer();
|
||
this.wordsToLearn = this.shuffleArray([...this.wordsToLearn]);
|
||
this.updateHUD();
|
||
this.startDiscoveryPhase();
|
||
}
|
||
|
||
endGame() {
|
||
this.onGameEnd(this.score);
|
||
}
|
||
|
||
destroy() {
|
||
this.clearPracticeTimer();
|
||
|
||
// Clean up global content reference
|
||
if (window.currentGameContent === this.content) {
|
||
window.currentGameContent = null;
|
||
}
|
||
|
||
const styleSheet = document.getElementById('word-discovery-styles');
|
||
if (styleSheet) {
|
||
styleSheet.remove();
|
||
}
|
||
}
|
||
|
||
// === COMPATIBILITY SYSTEM ===
|
||
static getCompatibilityRequirements() {
|
||
return {
|
||
minimum: {
|
||
vocabulary: 10
|
||
},
|
||
optimal: {
|
||
vocabulary: 20
|
||
},
|
||
name: "Word Discovery",
|
||
description: "Progressive vocabulary learning with discovery and practice phases"
|
||
};
|
||
}
|
||
|
||
static checkContentCompatibility(content) {
|
||
const requirements = WordDiscovery.getCompatibilityRequirements();
|
||
|
||
// Extract vocabulary using same method as instance
|
||
const vocabulary = WordDiscovery.extractVocabularyStatic(content);
|
||
const vocabCount = vocabulary.length;
|
||
|
||
// Dynamic percentage based on optimal volume (10 min → 20 optimal)
|
||
// 0 words = 0%, 10 words = 50%, 20 words = 100%
|
||
const score = Math.min(100, (vocabCount / requirements.optimal.vocabulary) * 100);
|
||
|
||
const recommendations = [];
|
||
if (vocabCount < requirements.optimal.vocabulary) {
|
||
recommendations.push(`Add ${requirements.optimal.vocabulary - vocabCount} more vocabulary words for optimal experience`);
|
||
}
|
||
|
||
// Count multimedia features for bonus recommendations
|
||
const hasImages = vocabulary.some(word => word.image);
|
||
const hasAudio = vocabulary.some(word => word.audioFile || word.pronunciation);
|
||
|
||
if (!hasImages) {
|
||
recommendations.push("Add images to vocabulary for visual learning challenges");
|
||
}
|
||
if (!hasAudio) {
|
||
recommendations.push("Add audio files or pronunciation guides for audio challenges");
|
||
}
|
||
|
||
return {
|
||
score: Math.round(score),
|
||
details: {
|
||
vocabulary: {
|
||
found: vocabCount,
|
||
minimum: requirements.minimum.vocabulary,
|
||
optimal: requirements.optimal.vocabulary,
|
||
status: vocabCount >= requirements.minimum.vocabulary ? 'sufficient' : 'insufficient'
|
||
},
|
||
multimedia: {
|
||
images: hasImages ? 'available' : 'missing',
|
||
audio: hasAudio ? 'available' : 'missing'
|
||
}
|
||
},
|
||
recommendations: recommendations
|
||
};
|
||
}
|
||
|
||
static extractVocabularyStatic(content) {
|
||
let vocabulary = [];
|
||
|
||
// Priority 1: Use raw module content (simple format)
|
||
if (content.rawContent) {
|
||
return WordDiscovery.extractVocabularyFromRawStatic(content.rawContent);
|
||
}
|
||
|
||
// Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported
|
||
if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) {
|
||
vocabulary = Object.entries(content.vocabulary).map(([word, data]) => {
|
||
// Support ultra-modular format ONLY
|
||
if (typeof data === 'object' && data.user_language) {
|
||
return {
|
||
word: word,
|
||
translation: data.user_language.split(';')[0],
|
||
fullTranslation: data.user_language,
|
||
type: data.type || 'general',
|
||
image: data.image,
|
||
audioFile: data.audio,
|
||
pronunciation: data.pronunciation,
|
||
category: data.type || 'general'
|
||
};
|
||
}
|
||
// Legacy fallback - simple string (temporary, will be removed)
|
||
else if (typeof data === 'string') {
|
||
return {
|
||
word: word,
|
||
translation: data.split(';')[0],
|
||
fullTranslation: data,
|
||
type: 'general',
|
||
category: 'general'
|
||
};
|
||
}
|
||
return null;
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
return WordDiscovery.finalizeVocabularyStatic(vocabulary);
|
||
}
|
||
|
||
static extractVocabularyFromRawStatic(rawContent) {
|
||
let vocabulary = [];
|
||
|
||
// Ultra-modular format (vocabulary object) - ONLY format supported
|
||
if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) {
|
||
vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => {
|
||
// Support ultra-modular format ONLY
|
||
if (typeof data === 'object' && data.user_language) {
|
||
return {
|
||
word: word,
|
||
translation: data.user_language.split(';')[0],
|
||
fullTranslation: data.user_language,
|
||
type: data.type || 'general',
|
||
image: data.image,
|
||
audioFile: data.audio,
|
||
pronunciation: data.pronunciation,
|
||
category: data.type || 'general'
|
||
};
|
||
}
|
||
// Legacy fallback - simple string (temporary, will be removed)
|
||
else if (typeof data === 'string') {
|
||
return {
|
||
word: word,
|
||
translation: data.split(';')[0],
|
||
fullTranslation: data,
|
||
type: 'general',
|
||
category: 'general'
|
||
};
|
||
}
|
||
return null;
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
return WordDiscovery.finalizeVocabularyStatic(vocabulary);
|
||
}
|
||
|
||
static finalizeVocabularyStatic(vocabulary) {
|
||
// Validation and cleanup for ultra-modular format
|
||
vocabulary = vocabulary.filter(word =>
|
||
word &&
|
||
typeof word.word === 'string' &&
|
||
typeof word.translation === 'string' &&
|
||
word.word.trim() !== '' &&
|
||
word.translation.trim() !== ''
|
||
);
|
||
|
||
return vocabulary;
|
||
}
|
||
}
|
||
|
||
// Register the game module
|
||
window.GameModules = window.GameModules || {};
|
||
window.GameModules.WordDiscovery = WordDiscovery; |