- 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>
1142 lines
37 KiB
JavaScript
1142 lines
37 KiB
JavaScript
import Module from '../core/Module.js';
|
|
import ttsService from '../services/TTSService.js';
|
|
|
|
/**
|
|
* ThematicQuestionsGame - Listening and speaking practice with self-assessment
|
|
* Students listen to questions (TTS), answer them, can reveal text if needed,
|
|
* view example responses, and self-assess their spoken answers
|
|
*/
|
|
class ThematicQuestionsGame extends Module {
|
|
constructor(name, dependencies, config = {}) {
|
|
super(name, ['eventBus']);
|
|
|
|
// Validate dependencies
|
|
if (!dependencies.eventBus || !dependencies.content) {
|
|
throw new Error('ThematicQuestionsGame requires eventBus and content dependencies');
|
|
}
|
|
|
|
this._eventBus = dependencies.eventBus;
|
|
this._content = dependencies.content;
|
|
this._config = {
|
|
container: null,
|
|
autoPlayTTS: false, // Auto-play question on load
|
|
...config
|
|
};
|
|
|
|
// Game state
|
|
this._questions = [];
|
|
this._currentIndex = 0;
|
|
this._score = 0;
|
|
this._correctCount = 0;
|
|
this._incorrectCount = 0;
|
|
this._showingExamples = false;
|
|
this._hasAnswered = false;
|
|
this._gameStartTime = null;
|
|
this._questionStartTime = null;
|
|
|
|
Object.seal(this);
|
|
}
|
|
|
|
/**
|
|
* Get game metadata
|
|
* @returns {Object} Game metadata
|
|
*/
|
|
static getMetadata() {
|
|
return {
|
|
name: 'Thematic Questions',
|
|
description: 'Listen to questions and practice speaking with self-assessment',
|
|
difficulty: 'beginner',
|
|
category: 'listening',
|
|
estimatedTime: 10, // minutes
|
|
skills: ['listening', 'speaking', 'comprehension', 'self-assessment']
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate compatibility score with content
|
|
* @param {Object} content - Content to check compatibility with
|
|
* @returns {Object} Compatibility score and details
|
|
*/
|
|
static getCompatibilityScore(content) {
|
|
const thematicQuestions = content?.thematic_questions || {};
|
|
|
|
// Count total questions across all themes
|
|
let totalQuestions = 0;
|
|
for (const theme of Object.values(thematicQuestions)) {
|
|
if (Array.isArray(theme)) {
|
|
totalQuestions += theme.length;
|
|
}
|
|
}
|
|
|
|
if (totalQuestions < 5) {
|
|
return {
|
|
score: 0,
|
|
reason: `Insufficient questions (${totalQuestions}/5 required)`,
|
|
requirements: ['thematic_questions'],
|
|
minQuestions: 5,
|
|
details: 'Thematic Questions needs at least 5 questions to play'
|
|
};
|
|
}
|
|
|
|
// Perfect score at 20+ questions, partial score for 5-19
|
|
const score = Math.min(totalQuestions / 20, 1);
|
|
|
|
return {
|
|
score,
|
|
reason: `${totalQuestions} thematic questions available`,
|
|
requirements: ['thematic_questions'],
|
|
minQuestions: 5,
|
|
optimalQuestions: 20,
|
|
details: `Can create practice session with ${totalQuestions} questions`
|
|
};
|
|
}
|
|
|
|
async init() {
|
|
this._validateNotDestroyed();
|
|
|
|
try {
|
|
// Validate container
|
|
if (!this._config.container) {
|
|
throw new Error('Game container is required');
|
|
}
|
|
|
|
// Extract and validate questions
|
|
this._questions = this._extractQuestions();
|
|
if (this._questions.length === 0) {
|
|
throw new Error('No thematic questions found in content');
|
|
}
|
|
|
|
// Set up event listeners
|
|
this._eventBus.on('game:pause', this._handlePause.bind(this), this.name);
|
|
this._eventBus.on('game:resume', this._handleResume.bind(this), this.name);
|
|
|
|
// Inject CSS
|
|
this._injectCSS();
|
|
|
|
// Initialize game interface
|
|
this._createGameInterface();
|
|
this._setupEventListeners();
|
|
|
|
// Start the game
|
|
this._gameStartTime = Date.now();
|
|
this._showQuestion();
|
|
|
|
// Emit game ready event
|
|
this._eventBus.emit('game:ready', {
|
|
gameId: 'thematic-questions',
|
|
instanceId: this.name,
|
|
questionsCount: this._questions.length
|
|
}, this.name);
|
|
|
|
this._setInitialized();
|
|
|
|
} catch (error) {
|
|
this._showError(error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async destroy() {
|
|
this._validateNotDestroyed();
|
|
|
|
// 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: 'thematic-questions',
|
|
instanceId: this.name,
|
|
score: this._score,
|
|
questionsAnswered: this._currentIndex,
|
|
totalQuestions: this._questions.length,
|
|
correctCount: this._correctCount,
|
|
incorrectCount: this._incorrectCount,
|
|
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,
|
|
currentQuestion: this._currentIndex,
|
|
totalQuestions: this._questions.length,
|
|
correctCount: this._correctCount,
|
|
incorrectCount: this._incorrectCount,
|
|
isComplete: this._currentIndex >= this._questions.length,
|
|
duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0
|
|
};
|
|
}
|
|
|
|
// Private methods
|
|
|
|
_extractQuestions() {
|
|
const thematicQuestions = this._content?.thematic_questions || {};
|
|
const allQuestions = [];
|
|
|
|
// Flatten all themes into single array
|
|
for (const [themeName, questions] of Object.entries(thematicQuestions)) {
|
|
if (Array.isArray(questions)) {
|
|
questions.forEach(q => {
|
|
allQuestions.push({
|
|
...q,
|
|
themeName: themeName
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// Shuffle questions randomly (Fisher-Yates algorithm)
|
|
return this._shuffleArray(allQuestions);
|
|
}
|
|
|
|
_shuffleArray(array) {
|
|
const shuffled = [...array];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
return shuffled;
|
|
}
|
|
|
|
_injectCSS() {
|
|
const cssId = `thematic-questions-styles-${this.name}`;
|
|
if (document.getElementById(cssId)) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.id = cssId;
|
|
style.textContent = `
|
|
.thematic-questions-game {
|
|
padding: 20px;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
|
|
.tq-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 30px;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
border-radius: 12px;
|
|
color: white;
|
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
|
}
|
|
|
|
.tq-stats {
|
|
display: flex;
|
|
gap: 30px;
|
|
}
|
|
|
|
.tq-stat {
|
|
text-align: center;
|
|
}
|
|
|
|
.tq-stat-label {
|
|
display: block;
|
|
font-size: 0.8rem;
|
|
opacity: 0.9;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.tq-stat-value {
|
|
display: block;
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.question-card {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 40px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
|
margin-bottom: 20px;
|
|
min-height: 400px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.question-theme {
|
|
display: inline-block;
|
|
padding: 6px 12px;
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
border-radius: 20px;
|
|
font-size: 0.85rem;
|
|
margin-bottom: 20px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.question-progress {
|
|
text-align: center;
|
|
color: #6c757d;
|
|
font-size: 0.9rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.question-display {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.listening-prompt {
|
|
font-size: 1.5rem;
|
|
color: #667eea;
|
|
margin-bottom: 40px;
|
|
font-weight: 600;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
|
|
border-radius: 12px;
|
|
animation: pulse 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.02); }
|
|
}
|
|
|
|
.question-text {
|
|
font-size: 2rem;
|
|
color: #333;
|
|
margin-bottom: 15px;
|
|
font-weight: 600;
|
|
line-height: 1.4;
|
|
padding: 20px;
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
border-left: 4px solid #28a745;
|
|
transition: all 0.4s ease;
|
|
}
|
|
|
|
.question-text.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.question-text.visible {
|
|
display: block;
|
|
animation: slideDown 0.4s ease;
|
|
}
|
|
|
|
.question-translation {
|
|
font-size: 1.2rem;
|
|
color: #6c757d;
|
|
font-style: italic;
|
|
margin-bottom: 25px;
|
|
padding: 15px;
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
border-left: 4px solid #dc3545;
|
|
transition: all 0.4s ease;
|
|
}
|
|
|
|
.question-translation.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.question-translation.visible {
|
|
display: block;
|
|
animation: slideDown 0.4s ease;
|
|
}
|
|
|
|
@keyframes slideDown {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.reveal-controls {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.btn-reveal {
|
|
padding: 12px 24px;
|
|
background: #17a2b8;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: all 0.3s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.btn-reveal:hover:not(:disabled) {
|
|
background: #138496;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
|
|
}
|
|
|
|
.btn-reveal:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.tts-controls {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 15px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.btn-tts {
|
|
padding: 12px 24px;
|
|
background: #667eea;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: all 0.3s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.btn-tts:hover {
|
|
background: #5568d3;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
|
}
|
|
|
|
.btn-tts:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.btn-show-examples {
|
|
padding: 12px 24px;
|
|
background: #28a745;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-show-examples:hover {
|
|
background: #218838;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.btn-show-examples.active {
|
|
background: #ffc107;
|
|
color: #000;
|
|
}
|
|
|
|
.examples-container {
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 30px;
|
|
border-left: 4px solid #667eea;
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height 0.3s ease, padding 0.3s ease, margin 0.3s ease;
|
|
}
|
|
|
|
.examples-container.visible {
|
|
max-height: 500px;
|
|
margin-bottom: 30px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.examples-container.hidden {
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.examples-title {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
color: #333;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.example-item {
|
|
padding: 10px 15px;
|
|
background: white;
|
|
border-radius: 6px;
|
|
margin-bottom: 10px;
|
|
font-size: 1rem;
|
|
color: #495057;
|
|
border-left: 3px solid #667eea;
|
|
}
|
|
|
|
.example-item:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.assessment-section {
|
|
text-align: center;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.assessment-title {
|
|
font-size: 1.1rem;
|
|
color: #333;
|
|
margin-bottom: 15px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.assessment-buttons {
|
|
display: flex;
|
|
gap: 15px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.btn-correct {
|
|
padding: 15px 40px;
|
|
background: #28a745;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
transition: all 0.3s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.btn-correct:hover {
|
|
background: #218838;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 20px rgba(40, 167, 69, 0.3);
|
|
}
|
|
|
|
.btn-incorrect {
|
|
padding: 15px 40px;
|
|
background: #dc3545;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
transition: all 0.3s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.btn-incorrect:hover {
|
|
background: #c82333;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 20px rgba(220, 53, 69, 0.3);
|
|
}
|
|
|
|
.btn-next {
|
|
display: block;
|
|
margin: 20px auto 0;
|
|
padding: 12px 30px;
|
|
background: #667eea;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-next:hover {
|
|
background: #5568d3;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.feedback-message {
|
|
text-align: center;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
font-size: 1.1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.feedback-message.correct {
|
|
background: #d4edda;
|
|
color: #155724;
|
|
border: 1px solid #c3e6cb;
|
|
}
|
|
|
|
.feedback-message.incorrect {
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
border: 1px solid #f5c6cb;
|
|
}
|
|
|
|
.tq-error {
|
|
text-align: center;
|
|
padding: 40px;
|
|
background: #f8d7da;
|
|
border: 1px solid #f5c6cb;
|
|
border-radius: 12px;
|
|
color: #721c24;
|
|
}
|
|
|
|
.error-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.tq-header {
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.tq-stats {
|
|
gap: 20px;
|
|
}
|
|
|
|
.question-card {
|
|
padding: 20px;
|
|
}
|
|
|
|
.question-text {
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.question-translation {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.assessment-buttons {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.btn-correct, .btn-incorrect {
|
|
width: 100%;
|
|
}
|
|
}
|
|
`;
|
|
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
_removeCSS() {
|
|
const cssId = `thematic-questions-styles-${this.name}`;
|
|
const existingStyle = document.getElementById(cssId);
|
|
if (existingStyle) {
|
|
existingStyle.remove();
|
|
}
|
|
}
|
|
|
|
_createGameInterface() {
|
|
this._config.container.innerHTML = `
|
|
<div class="thematic-questions-game">
|
|
<div class="tq-header">
|
|
<div class="tq-stats">
|
|
<div class="tq-stat">
|
|
<span class="tq-stat-label">✅ Correct</span>
|
|
<span class="tq-stat-value" id="tq-correct">0</span>
|
|
</div>
|
|
<div class="tq-stat">
|
|
<span class="tq-stat-label">❌ Incorrect</span>
|
|
<span class="tq-stat-value" id="tq-incorrect">0</span>
|
|
</div>
|
|
<div class="tq-stat">
|
|
<span class="tq-stat-label">Progress</span>
|
|
<span class="tq-stat-value">
|
|
<span id="tq-current">0</span>/${this._questions.length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-outline btn-sm" id="exit-game">
|
|
<span class="btn-icon">←</span>
|
|
<span class="btn-text">Exit</span>
|
|
</button>
|
|
</div>
|
|
<div id="tq-content"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_setupEventListeners() {
|
|
// Exit button
|
|
const exitButton = this._config.container.querySelector('#exit-game');
|
|
if (exitButton) {
|
|
exitButton.addEventListener('click', () => {
|
|
this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name);
|
|
});
|
|
}
|
|
|
|
// Event delegation for dynamic buttons
|
|
this._config.container.addEventListener('click', (event) => {
|
|
if (event.target.matches('#tts-btn') || event.target.closest('#tts-btn')) {
|
|
this._handleTTS();
|
|
}
|
|
|
|
if (event.target.matches('#reveal-english-btn') || event.target.closest('#reveal-english-btn')) {
|
|
this._revealText('english');
|
|
}
|
|
|
|
if (event.target.matches('#reveal-chinese-btn') || event.target.closest('#reveal-chinese-btn')) {
|
|
this._revealText('chinese');
|
|
}
|
|
|
|
if (event.target.matches('#show-examples-btn') || event.target.closest('#show-examples-btn')) {
|
|
this._toggleExamples();
|
|
}
|
|
|
|
if (event.target.matches('#btn-correct') || event.target.closest('#btn-correct')) {
|
|
this._handleSelfAssessment(true);
|
|
}
|
|
|
|
if (event.target.matches('#btn-incorrect') || event.target.closest('#btn-incorrect')) {
|
|
this._handleSelfAssessment(false);
|
|
}
|
|
|
|
if (event.target.matches('#next-btn') || event.target.closest('#next-btn')) {
|
|
this._nextQuestion();
|
|
}
|
|
});
|
|
}
|
|
|
|
_showQuestion() {
|
|
if (this._currentIndex >= this._questions.length) {
|
|
this._showResults();
|
|
return;
|
|
}
|
|
|
|
const question = this._questions[this._currentIndex];
|
|
const content = document.getElementById('tq-content');
|
|
|
|
this._showingExamples = false;
|
|
this._hasAnswered = false;
|
|
this._questionStartTime = Date.now();
|
|
|
|
content.innerHTML = `
|
|
<div class="question-card">
|
|
<div class="question-progress">
|
|
Question ${this._currentIndex + 1} of ${this._questions.length}
|
|
</div>
|
|
|
|
<div class="question-theme">${this._formatThemeName(question.themeName)}</div>
|
|
|
|
<div class="question-display">
|
|
<div class="listening-prompt">
|
|
🎧 Listen to the question and answer it
|
|
</div>
|
|
|
|
<div class="question-text hidden" id="question-text-en">
|
|
${question.question}
|
|
</div>
|
|
<div class="question-translation hidden" id="question-text-zh">
|
|
${question.question_user_language}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="reveal-controls">
|
|
<button class="btn-reveal" id="reveal-english-btn">
|
|
<span class="btn-icon">🇬🇧</span>
|
|
<span class="btn-text">Show English</span>
|
|
</button>
|
|
<button class="btn-reveal" id="reveal-chinese-btn">
|
|
<span class="btn-icon">🇨🇳</span>
|
|
<span class="btn-text">Show Chinese</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="tts-controls">
|
|
<button class="btn-tts" id="tts-btn">
|
|
<span class="btn-icon">🔊</span>
|
|
<span class="btn-text">Listen Again</span>
|
|
</button>
|
|
<button class="btn-show-examples" id="show-examples-btn">
|
|
<span class="btn-icon">💡</span>
|
|
<span class="btn-text">Show Examples</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="examples-container hidden" id="examples-container">
|
|
<div class="examples-title">Example Responses:</div>
|
|
${question.example_responses.map(example => `
|
|
<div class="example-item">${example}</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div class="assessment-section" id="assessment-section">
|
|
<div class="assessment-title">Was your answer correct?</div>
|
|
<div class="assessment-buttons">
|
|
<button class="btn-correct" id="btn-correct">
|
|
<span class="btn-icon">✅</span>
|
|
<span class="btn-text">Correct</span>
|
|
</button>
|
|
<button class="btn-incorrect" id="btn-incorrect">
|
|
<span class="btn-icon">❌</span>
|
|
<span class="btn-text">Incorrect</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="feedback-container"></div>
|
|
</div>
|
|
`;
|
|
|
|
// Auto-play TTS on question load for listening exercise
|
|
if (question.tts_enabled) {
|
|
setTimeout(() => this._handleTTS(), 200);
|
|
}
|
|
}
|
|
|
|
_formatThemeName(theme) {
|
|
return theme
|
|
.split('_')
|
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
_revealText(language) {
|
|
if (language === 'english') {
|
|
const textElement = document.getElementById('question-text-en');
|
|
const btn = document.getElementById('reveal-english-btn');
|
|
if (textElement && btn) {
|
|
textElement.classList.remove('hidden');
|
|
textElement.classList.add('visible');
|
|
btn.disabled = true;
|
|
btn.style.opacity = '0.5';
|
|
}
|
|
} else if (language === 'chinese') {
|
|
const textElement = document.getElementById('question-text-zh');
|
|
const btn = document.getElementById('reveal-chinese-btn');
|
|
if (textElement && btn) {
|
|
textElement.classList.remove('hidden');
|
|
textElement.classList.add('visible');
|
|
btn.disabled = true;
|
|
btn.style.opacity = '0.5';
|
|
}
|
|
}
|
|
}
|
|
|
|
_toggleExamples() {
|
|
const container = document.getElementById('examples-container');
|
|
const btn = document.getElementById('show-examples-btn');
|
|
|
|
if (!container || !btn) return;
|
|
|
|
this._showingExamples = !this._showingExamples;
|
|
|
|
if (this._showingExamples) {
|
|
container.classList.remove('hidden');
|
|
container.classList.add('visible');
|
|
btn.classList.add('active');
|
|
btn.innerHTML = `
|
|
<span class="btn-icon">👁️</span>
|
|
<span class="btn-text">Hide Examples</span>
|
|
`;
|
|
} else {
|
|
container.classList.remove('visible');
|
|
container.classList.add('hidden');
|
|
btn.classList.remove('active');
|
|
btn.innerHTML = `
|
|
<span class="btn-icon">💡</span>
|
|
<span class="btn-text">Show Examples</span>
|
|
`;
|
|
}
|
|
}
|
|
|
|
_handleSelfAssessment(isCorrect) {
|
|
if (this._hasAnswered) return;
|
|
|
|
this._hasAnswered = true;
|
|
const question = this._questions[this._currentIndex];
|
|
|
|
// Update stats
|
|
if (isCorrect) {
|
|
this._correctCount++;
|
|
this._score += 100;
|
|
} else {
|
|
this._incorrectCount++;
|
|
}
|
|
|
|
// Show feedback
|
|
this._showFeedback(isCorrect);
|
|
this._updateStats();
|
|
|
|
// Record time spent
|
|
const timeSpent = this._questionStartTime ? Date.now() - this._questionStartTime : 0;
|
|
|
|
// Emit answer event
|
|
this._eventBus.emit('thematic-questions:answer', {
|
|
gameId: 'thematic-questions',
|
|
instanceId: this.name,
|
|
questionNumber: this._currentIndex + 1,
|
|
question: question.question,
|
|
isCorrect,
|
|
score: this._score,
|
|
timeSpent
|
|
}, this.name);
|
|
}
|
|
|
|
_showFeedback(isCorrect) {
|
|
const assessmentSection = document.getElementById('assessment-section');
|
|
const feedbackContainer = document.getElementById('feedback-container');
|
|
|
|
if (!assessmentSection || !feedbackContainer) return;
|
|
|
|
// Hide assessment buttons
|
|
assessmentSection.style.display = 'none';
|
|
|
|
// Show feedback
|
|
const feedbackClass = isCorrect ? 'correct' : 'incorrect';
|
|
const feedbackIcon = isCorrect ? '🎉' : '💪';
|
|
const feedbackText = isCorrect
|
|
? 'Great job! Your answer was correct!'
|
|
: 'Keep practicing! Try to review the examples.';
|
|
|
|
feedbackContainer.innerHTML = `
|
|
<div class="feedback-message ${feedbackClass}">
|
|
${feedbackIcon} ${feedbackText}
|
|
</div>
|
|
<button class="btn-next" id="next-btn">
|
|
${this._currentIndex + 1 >= this._questions.length ? 'View Results' : 'Next Question →'}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
_nextQuestion() {
|
|
this._currentIndex++;
|
|
this._showQuestion();
|
|
}
|
|
|
|
_showResults() {
|
|
const accuracy = this._questions.length > 0
|
|
? Math.round((this._correctCount / this._questions.length) * 100)
|
|
: 0;
|
|
const totalTime = this._gameStartTime ? Date.now() - this._gameStartTime : 0;
|
|
|
|
// Store best score
|
|
const gameKey = 'thematic-questions';
|
|
const currentScore = this._score;
|
|
const bestScore = parseInt(localStorage.getItem(`${gameKey}-best-score`) || '0');
|
|
const isNewBest = currentScore > bestScore;
|
|
|
|
if (isNewBest) {
|
|
localStorage.setItem(`${gameKey}-best-score`, currentScore.toString());
|
|
}
|
|
|
|
// Show victory popup
|
|
this._showVictoryPopup({
|
|
gameTitle: 'Thematic Questions',
|
|
currentScore,
|
|
bestScore: isNewBest ? currentScore : bestScore,
|
|
isNewBest,
|
|
stats: {
|
|
'Questions': `${this._questions.length}`,
|
|
'Correct': `${this._correctCount}`,
|
|
'Incorrect': `${this._incorrectCount}`,
|
|
'Accuracy': `${accuracy}%`,
|
|
'Total Time': `${Math.round(totalTime / 1000)}s`
|
|
}
|
|
});
|
|
|
|
// Emit completion event
|
|
this._eventBus.emit('game:completed', {
|
|
gameId: 'thematic-questions',
|
|
instanceId: this.name,
|
|
score: this._score,
|
|
correctCount: this._correctCount,
|
|
incorrectCount: this._incorrectCount,
|
|
totalQuestions: this._questions.length,
|
|
accuracy,
|
|
duration: totalTime
|
|
}, this.name);
|
|
}
|
|
|
|
_updateStats() {
|
|
const correctElement = document.getElementById('tq-correct');
|
|
const incorrectElement = document.getElementById('tq-incorrect');
|
|
const currentElement = document.getElementById('tq-current');
|
|
|
|
if (correctElement) correctElement.textContent = this._correctCount;
|
|
if (incorrectElement) incorrectElement.textContent = this._incorrectCount;
|
|
if (currentElement) currentElement.textContent = this._currentIndex + 1;
|
|
}
|
|
|
|
_handleTTS() {
|
|
const question = this._questions[this._currentIndex];
|
|
if (question && question.tts_enabled && question.question) {
|
|
this._playAudio(question.question);
|
|
}
|
|
}
|
|
|
|
async _playAudio(text) {
|
|
// Get language from chapter content, fallback to en-US
|
|
const chapterLanguage = this._content?.language || 'en-US';
|
|
|
|
// Visual feedback
|
|
const ttsBtn = document.getElementById('tts-btn');
|
|
let originalHTML = '';
|
|
if (ttsBtn) {
|
|
originalHTML = ttsBtn.innerHTML;
|
|
ttsBtn.innerHTML = '<span class="btn-icon">🔄</span><span class="btn-text">Speaking...</span>';
|
|
ttsBtn.disabled = true;
|
|
}
|
|
|
|
try {
|
|
await ttsService.speak(text, chapterLanguage, { rate: 0.85, volume: 1.0 });
|
|
} catch (error) {
|
|
console.warn('🔊 Speech Synthesis error:', error);
|
|
} finally {
|
|
if (ttsBtn) {
|
|
ttsBtn.innerHTML = originalHTML;
|
|
ttsBtn.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
_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-item">
|
|
<div class="stat-label">${key}</div>
|
|
<div class="stat-value">${value}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div class="victory-actions">
|
|
<button class="victory-btn victory-btn-primary" id="play-again-btn">
|
|
<span class="btn-icon">🔄</span>
|
|
<span class="btn-text">Play Again</span>
|
|
</button>
|
|
<button class="victory-btn victory-btn-secondary" id="different-game-btn">
|
|
<span class="btn-icon">🎮</span>
|
|
<span class="btn-text">Different Game</span>
|
|
</button>
|
|
<button class="victory-btn victory-btn-outline" id="main-menu-btn">
|
|
<span class="btn-icon">🏠</span>
|
|
<span class="btn-text">Main Menu</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(popup);
|
|
|
|
requestAnimationFrame(() => {
|
|
popup.classList.add('show');
|
|
});
|
|
|
|
// Event listeners
|
|
popup.querySelector('#play-again-btn').addEventListener('click', () => {
|
|
popup.remove();
|
|
this._restartGame();
|
|
});
|
|
|
|
popup.querySelector('#different-game-btn').addEventListener('click', () => {
|
|
popup.remove();
|
|
if (window.app && window.app.getCore().router) {
|
|
window.app.getCore().router.navigate('/games');
|
|
} else {
|
|
window.location.href = '/#/games';
|
|
}
|
|
});
|
|
|
|
popup.querySelector('#main-menu-btn').addEventListener('click', () => {
|
|
popup.remove();
|
|
if (window.app && window.app.getCore().router) {
|
|
window.app.getCore().router.navigate('/');
|
|
} else {
|
|
window.location.href = '/';
|
|
}
|
|
});
|
|
|
|
popup.addEventListener('click', (e) => {
|
|
if (e.target === popup) {
|
|
popup.remove();
|
|
if (window.app && window.app.getCore().router) {
|
|
window.app.getCore().router.navigate('/games');
|
|
} else {
|
|
window.location.href = '/#/games';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
_restartGame() {
|
|
this._currentIndex = 0;
|
|
this._score = 0;
|
|
this._correctCount = 0;
|
|
this._incorrectCount = 0;
|
|
this._showingExamples = false;
|
|
this._hasAnswered = false;
|
|
this._gameStartTime = Date.now();
|
|
this._updateStats();
|
|
this._showQuestion();
|
|
}
|
|
|
|
_showError(message) {
|
|
if (this._config.container) {
|
|
this._config.container.innerHTML = `
|
|
<div class="tq-error">
|
|
<div class="error-icon">❌</div>
|
|
<h3>Game Error</h3>
|
|
<p>${message}</p>
|
|
<button class="btn btn-primary" onclick="history.back()">Go Back</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
_handlePause() {
|
|
this._eventBus.emit('game:paused', { instanceId: this.name }, this.name);
|
|
}
|
|
|
|
_handleResume() {
|
|
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
|
|
}
|
|
}
|
|
|
|
export default ThematicQuestionsGame;
|