- Add AIReportSystem.js for detailed AI response capture and report generation - Add AIReportInterface.js UI component for report access and export - Integrate AI reporting into LLMValidator and SmartPreviewOrchestrator - Add missing modules to Application.js configuration (unifiedDRS, smartPreviewOrchestrator) - Create missing content/chapters/sbs.json for book metadata - Enhance Application.js with debug logging for module loading - Add multi-format export capabilities (text, HTML, JSON) - Implement automatic learning insights extraction from AI feedback - Add session management and performance tracking for AI reports 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1058 lines
34 KiB
JavaScript
1058 lines
34 KiB
JavaScript
import Module from '../core/Module.js';
|
|
|
|
/**
|
|
* QuizGame - Educational vocabulary quiz with multiple choice questions
|
|
* Supports bidirectional quizzing (English to translation or translation to English)
|
|
*/
|
|
class QuizGame extends Module {
|
|
constructor(name, dependencies, config = {}) {
|
|
super(name, ['eventBus']);
|
|
|
|
// Validate dependencies
|
|
if (!dependencies.eventBus || !dependencies.content) {
|
|
throw new Error('QuizGame requires eventBus and content dependencies');
|
|
}
|
|
|
|
this._eventBus = dependencies.eventBus;
|
|
this._content = dependencies.content;
|
|
this._config = {
|
|
container: null,
|
|
questionCount: 10,
|
|
optionsCount: 6,
|
|
timeLimit: 30, // seconds per question
|
|
...config
|
|
};
|
|
|
|
// Game state
|
|
this._vocabulary = null;
|
|
this._currentQuestion = 0;
|
|
this._score = 0;
|
|
this._questions = [];
|
|
this._currentQuestionData = null;
|
|
this._isAnswering = false;
|
|
this._gameStartTime = null;
|
|
this._questionStartTime = null;
|
|
this._quizDirection = 'en-to-translation'; // or 'translation-to-en'
|
|
this._timeRemaining = 0;
|
|
this._timer = null;
|
|
|
|
Object.seal(this);
|
|
}
|
|
|
|
/**
|
|
* Get game metadata
|
|
* @returns {Object} Game metadata
|
|
*/
|
|
static getMetadata() {
|
|
return {
|
|
name: 'Vocabulary Quiz',
|
|
description: 'Multiple choice vocabulary quiz with bidirectional testing',
|
|
difficulty: 'intermediate',
|
|
category: 'quiz',
|
|
estimatedTime: 8, // minutes
|
|
skills: ['vocabulary', 'comprehension', 'recognition', 'speed']
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 vocabCount = Object.keys(vocab).length;
|
|
|
|
if (vocabCount < 6) {
|
|
return {
|
|
score: 0,
|
|
reason: `Insufficient vocabulary (${vocabCount}/6 required)`,
|
|
requirements: ['vocabulary'],
|
|
minWords: 6,
|
|
details: 'Quiz Game needs at least 6 vocabulary words for multiple choice options'
|
|
};
|
|
}
|
|
|
|
// Perfect score at 20+ words, partial score for 6-19
|
|
const score = Math.min(vocabCount / 20, 1);
|
|
|
|
return {
|
|
score,
|
|
reason: `${vocabCount} vocabulary words available`,
|
|
requirements: ['vocabulary'],
|
|
minWords: 6,
|
|
optimalWords: 20,
|
|
details: `Can create quiz with ${Math.min(vocabCount, 10)} questions`
|
|
};
|
|
}
|
|
|
|
async init() {
|
|
this._validateNotDestroyed();
|
|
|
|
try {
|
|
// Validate container
|
|
if (!this._config.container) {
|
|
throw new Error('Game container is required');
|
|
}
|
|
|
|
// Extract and validate vocabulary
|
|
this._vocabulary = this._extractVocabulary();
|
|
if (this._vocabulary.length < this._config.optionsCount) {
|
|
throw new Error(`Insufficient vocabulary: need ${this._config.optionsCount}, got ${this._vocabulary.length}`);
|
|
}
|
|
|
|
// 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._generateQuestions();
|
|
this._setupEventListeners();
|
|
|
|
// Start the game
|
|
this._gameStartTime = Date.now();
|
|
this._showQuizDirectionChoice();
|
|
|
|
// Emit game ready event
|
|
this._eventBus.emit('game:ready', {
|
|
gameId: 'quiz-game',
|
|
instanceId: this.name,
|
|
vocabulary: this._vocabulary.length,
|
|
questionsCount: this._questions.length
|
|
}, this.name);
|
|
|
|
this._setInitialized();
|
|
|
|
} catch (error) {
|
|
this._showError(error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async destroy() {
|
|
this._validateNotDestroyed();
|
|
|
|
// Clear timer
|
|
if (this._timer) {
|
|
clearInterval(this._timer);
|
|
this._timer = null;
|
|
}
|
|
|
|
// Remove CSS
|
|
this._removeCSS();
|
|
|
|
// Clean up event listeners
|
|
if (this._config.container) {
|
|
this._config.container.innerHTML = '';
|
|
}
|
|
|
|
// Emit game end event
|
|
this._eventBus.emit('game:ended', {
|
|
gameId: 'quiz-game',
|
|
instanceId: this.name,
|
|
score: this._score,
|
|
questionsAnswered: this._currentQuestion,
|
|
totalQuestions: this._questions.length,
|
|
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._currentQuestion,
|
|
totalQuestions: this._questions.length,
|
|
isComplete: this._currentQuestion >= this._questions.length,
|
|
duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0,
|
|
timeRemaining: this._timeRemaining
|
|
};
|
|
}
|
|
|
|
// Private methods
|
|
_extractVocabulary() {
|
|
const vocab = this._content?.vocabulary || {};
|
|
const vocabulary = [];
|
|
|
|
for (const [word, data] of Object.entries(vocab)) {
|
|
if (data.user_language) {
|
|
vocabulary.push({
|
|
english: word,
|
|
translation: data.user_language,
|
|
type: data.type || 'unknown'
|
|
});
|
|
}
|
|
}
|
|
|
|
return this._shuffleArray(vocabulary);
|
|
}
|
|
|
|
_injectCSS() {
|
|
const cssId = `quiz-game-styles-${this.name}`;
|
|
if (document.getElementById(cssId)) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.id = cssId;
|
|
style.textContent = `
|
|
.quiz-game {
|
|
padding: 20px;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
|
|
.quiz-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 30px;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%);
|
|
border-radius: 12px;
|
|
color: #2d3748;
|
|
border: 2px solid #4299e1;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.quiz-stats {
|
|
display: flex;
|
|
gap: 30px;
|
|
}
|
|
|
|
.quiz-stat {
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-label {
|
|
display: block;
|
|
font-size: 0.8rem;
|
|
opacity: 0.9;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.stat-value {
|
|
display: block;
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.quiz-direction-choice {
|
|
text-align: center;
|
|
padding: 40px;
|
|
background: #f8f9fa;
|
|
border-radius: 12px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.quiz-direction-choice h3 {
|
|
margin-bottom: 20px;
|
|
color: #333;
|
|
}
|
|
|
|
.direction-buttons {
|
|
display: flex;
|
|
gap: 20px;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.direction-btn {
|
|
padding: 15px 30px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
background: #007bff;
|
|
color: white;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.direction-btn:hover {
|
|
background: #0056b3;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
|
|
}
|
|
|
|
.question-container {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.question-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 25px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 2px solid #e9ecef;
|
|
}
|
|
|
|
.question-number {
|
|
font-size: 1.1rem;
|
|
color: #6c757d;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.question-timer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: #dc3545;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.timer-circle {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: #dc3545;
|
|
color: white;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.timer-circle.warning {
|
|
background: #ffc107;
|
|
color: #000;
|
|
}
|
|
|
|
.timer-circle.safe {
|
|
background: #28a745;
|
|
}
|
|
|
|
.question-text {
|
|
font-size: 1.5rem;
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
padding: 20px;
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
color: #333;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.quiz-options {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.quiz-option {
|
|
padding: 20px;
|
|
border: 2px solid #e9ecef;
|
|
border-radius: 8px;
|
|
background: white;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
text-align: center;
|
|
font-size: 1.1rem;
|
|
position: relative;
|
|
}
|
|
|
|
.quiz-option:hover {
|
|
border-color: #007bff;
|
|
background: #f8f9fa;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.2);
|
|
}
|
|
|
|
.quiz-option.selected {
|
|
border-color: #007bff;
|
|
background: #e3f2fd;
|
|
}
|
|
|
|
.quiz-option.correct {
|
|
border-color: #28a745;
|
|
background: #d4edda;
|
|
color: #155724;
|
|
}
|
|
|
|
.quiz-option.incorrect {
|
|
border-color: #dc3545;
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
}
|
|
|
|
.quiz-option.disabled {
|
|
pointer-events: none;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.quiz-feedback {
|
|
text-align: center;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
font-size: 1.1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.quiz-feedback.correct {
|
|
background: #d4edda;
|
|
color: #155724;
|
|
border: 1px solid #c3e6cb;
|
|
}
|
|
|
|
.quiz-feedback.incorrect {
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
border: 1px solid #f5c6cb;
|
|
}
|
|
|
|
.next-question-btn {
|
|
display: block;
|
|
margin: 20px auto;
|
|
padding: 12px 30px;
|
|
background: #28a745;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.next-question-btn:hover {
|
|
background: #218838;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.quiz-complete {
|
|
text-align: center;
|
|
padding: 40px;
|
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
|
color: white;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.quiz-complete h2 {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.final-score {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.quiz-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 20px;
|
|
margin: 30px 0;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.summary-stat {
|
|
text-align: center;
|
|
}
|
|
|
|
.summary-stat-value {
|
|
display: block;
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.summary-stat-label {
|
|
font-size: 0.9rem;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.restart-btn, .exit-btn {
|
|
margin: 10px;
|
|
padding: 12px 25px;
|
|
border: 2px solid white;
|
|
border-radius: 8px;
|
|
background: transparent;
|
|
color: white;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.restart-btn:hover, .exit-btn:hover {
|
|
background: white;
|
|
color: #28a745;
|
|
}
|
|
|
|
.quiz-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) {
|
|
.quiz-header {
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.quiz-stats {
|
|
gap: 20px;
|
|
}
|
|
|
|
.quiz-options {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.direction-buttons {
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
}
|
|
`;
|
|
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
_removeCSS() {
|
|
const cssId = `quiz-game-styles-${this.name}`;
|
|
const existingStyle = document.getElementById(cssId);
|
|
if (existingStyle) {
|
|
existingStyle.remove();
|
|
}
|
|
}
|
|
|
|
_createGameInterface() {
|
|
this._config.container.innerHTML = `
|
|
<div class="quiz-game">
|
|
<div class="quiz-header">
|
|
<div class="quiz-stats">
|
|
<div class="quiz-stat">
|
|
<span class="stat-label">Score</span>
|
|
<span class="stat-value" id="quiz-score">0</span>
|
|
</div>
|
|
<div class="quiz-stat">
|
|
<span class="stat-label">Question</span>
|
|
<span class="stat-value">
|
|
<span id="current-question">0</span>/${this._config.questionCount}
|
|
</span>
|
|
</div>
|
|
<div class="quiz-stat">
|
|
<span class="stat-label">Accuracy</span>
|
|
<span class="stat-value" id="quiz-accuracy">0%</span>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-outline btn-sm" id="exit-quiz">
|
|
<span class="btn-icon">←</span>
|
|
<span class="btn-text">Exit Quiz</span>
|
|
</button>
|
|
</div>
|
|
<div id="quiz-content"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_generateQuestions() {
|
|
this._questions = [];
|
|
const questionCount = Math.min(this._config.questionCount, this._vocabulary.length);
|
|
const selectedVocab = this._shuffleArray([...this._vocabulary]).slice(0, questionCount);
|
|
|
|
selectedVocab.forEach((vocab, index) => {
|
|
this._questions.push({
|
|
id: index,
|
|
vocabulary: vocab,
|
|
options: this._generateOptions(vocab),
|
|
correctAnswer: null, // Will be set based on quiz direction
|
|
userAnswer: null,
|
|
timeSpent: 0,
|
|
answered: false
|
|
});
|
|
});
|
|
}
|
|
|
|
_generateOptions(correctVocab) {
|
|
const options = [];
|
|
const otherVocab = this._vocabulary.filter(v => v !== correctVocab);
|
|
const shuffledOthers = this._shuffleArray(otherVocab);
|
|
|
|
// Add correct answer
|
|
options.push(correctVocab);
|
|
|
|
// Add incorrect options
|
|
for (let i = 0; i < this._config.optionsCount - 1 && i < shuffledOthers.length; i++) {
|
|
options.push(shuffledOthers[i]);
|
|
}
|
|
|
|
return this._shuffleArray(options);
|
|
}
|
|
|
|
_showQuizDirectionChoice() {
|
|
const content = document.getElementById('quiz-content');
|
|
content.innerHTML = `
|
|
<div class="quiz-direction-choice">
|
|
<h3>Choose Quiz Direction</h3>
|
|
<p>How would you like to be tested?</p>
|
|
<div class="direction-buttons">
|
|
<button class="direction-btn" data-direction="en-to-translation">
|
|
English → Translation
|
|
<br><small>See English words, choose translations</small>
|
|
</button>
|
|
<button class="direction-btn" data-direction="translation-to-en">
|
|
Translation → English
|
|
<br><small>See translations, choose English words</small>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_startQuiz(direction) {
|
|
this._quizDirection = direction;
|
|
this._currentQuestion = 0;
|
|
this._score = 0;
|
|
|
|
// Set correct answers based on direction
|
|
this._questions.forEach(question => {
|
|
if (direction === 'en-to-translation') {
|
|
question.correctAnswer = question.vocabulary.translation;
|
|
} else {
|
|
question.correctAnswer = question.vocabulary.english;
|
|
}
|
|
});
|
|
|
|
this._showQuestion();
|
|
}
|
|
|
|
_showQuestion() {
|
|
if (this._currentQuestion >= this._questions.length) {
|
|
this._showResults();
|
|
return;
|
|
}
|
|
|
|
const question = this._questions[this._currentQuestion];
|
|
const content = document.getElementById('quiz-content');
|
|
|
|
// Determine question text based on direction
|
|
const questionText = this._quizDirection === 'en-to-translation'
|
|
? question.vocabulary.english
|
|
: question.vocabulary.translation;
|
|
|
|
content.innerHTML = `
|
|
<div class="question-container">
|
|
<div class="question-header">
|
|
<div class="question-number">
|
|
Question ${this._currentQuestion + 1} of ${this._questions.length}
|
|
</div>
|
|
<div class="question-timer">
|
|
<div class="timer-circle safe" id="timer-circle">
|
|
<span id="timer-value">${this._config.timeLimit}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="question-text">
|
|
${questionText}
|
|
</div>
|
|
|
|
<div class="quiz-options" id="quiz-options">
|
|
${this._generateOptionHTML(question)}
|
|
</div>
|
|
|
|
<div id="quiz-feedback"></div>
|
|
</div>
|
|
`;
|
|
|
|
this._startQuestionTimer();
|
|
this._questionStartTime = Date.now();
|
|
}
|
|
|
|
_generateOptionHTML(question) {
|
|
return question.options.map((vocab, index) => {
|
|
const optionText = this._quizDirection === 'en-to-translation'
|
|
? vocab.translation
|
|
: vocab.english;
|
|
|
|
return `
|
|
<div class="quiz-option" data-option="${index}" data-value="${optionText}">
|
|
${optionText}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
_startQuestionTimer() {
|
|
this._timeRemaining = this._config.timeLimit;
|
|
this._timer = setInterval(() => {
|
|
this._timeRemaining--;
|
|
this._updateTimer();
|
|
|
|
if (this._timeRemaining <= 0) {
|
|
this._handleTimeout();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
_updateTimer() {
|
|
const timerValue = document.getElementById('timer-value');
|
|
const timerCircle = document.getElementById('timer-circle');
|
|
|
|
if (timerValue) {
|
|
timerValue.textContent = this._timeRemaining;
|
|
}
|
|
|
|
if (timerCircle) {
|
|
timerCircle.className = 'timer-circle';
|
|
if (this._timeRemaining <= 5) {
|
|
timerCircle.classList.add('warning');
|
|
} else if (this._timeRemaining > 15) {
|
|
timerCircle.classList.add('safe');
|
|
}
|
|
}
|
|
}
|
|
|
|
_setupEventListeners() {
|
|
// Direction choice listeners
|
|
this._config.container.addEventListener('click', (event) => {
|
|
if (event.target.matches('.direction-btn')) {
|
|
const direction = event.target.dataset.direction;
|
|
this._startQuiz(direction);
|
|
}
|
|
|
|
if (event.target.matches('.quiz-option')) {
|
|
this._handleAnswerClick(event.target);
|
|
}
|
|
|
|
if (event.target.matches('.next-question-btn')) {
|
|
this._nextQuestion();
|
|
}
|
|
|
|
if (event.target.matches('.restart-btn')) {
|
|
this._restartQuiz();
|
|
}
|
|
});
|
|
|
|
// Exit button
|
|
const exitButton = this._config.container.querySelector('#exit-quiz');
|
|
if (exitButton) {
|
|
exitButton.addEventListener('click', () => {
|
|
this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name);
|
|
});
|
|
}
|
|
}
|
|
|
|
_handleAnswerClick(optionElement) {
|
|
if (this._isAnswering || optionElement.classList.contains('disabled')) {
|
|
return;
|
|
}
|
|
|
|
this._isAnswering = true;
|
|
const question = this._questions[this._currentQuestion];
|
|
const selectedAnswer = optionElement.dataset.value;
|
|
const isCorrect = selectedAnswer === question.correctAnswer;
|
|
|
|
// Clear timer
|
|
if (this._timer) {
|
|
clearInterval(this._timer);
|
|
this._timer = null;
|
|
}
|
|
|
|
// Record answer
|
|
question.userAnswer = selectedAnswer;
|
|
question.answered = true;
|
|
question.timeSpent = this._questionStartTime ? Date.now() - this._questionStartTime : 0;
|
|
|
|
// Update score
|
|
if (isCorrect) {
|
|
const timeBonus = Math.max(0, this._timeRemaining * 2);
|
|
this._score += 100 + timeBonus;
|
|
}
|
|
|
|
// Show feedback
|
|
this._showAnswerFeedback(isCorrect, question);
|
|
this._updateStats();
|
|
|
|
// Emit answer event
|
|
this._eventBus.emit('quiz:answer', {
|
|
gameId: 'quiz-game',
|
|
instanceId: this.name,
|
|
questionNumber: this._currentQuestion + 1,
|
|
isCorrect,
|
|
score: this._score,
|
|
timeSpent: question.timeSpent
|
|
}, this.name);
|
|
}
|
|
|
|
_handleTimeout() {
|
|
if (this._timer) {
|
|
clearInterval(this._timer);
|
|
this._timer = null;
|
|
}
|
|
|
|
const question = this._questions[this._currentQuestion];
|
|
question.answered = true;
|
|
question.timeSpent = this._config.timeLimit * 1000;
|
|
|
|
this._showAnswerFeedback(false, question, true);
|
|
this._updateStats();
|
|
}
|
|
|
|
_showAnswerFeedback(isCorrect, question, timeout = false) {
|
|
const optionsContainer = document.getElementById('quiz-options');
|
|
const feedbackContainer = document.getElementById('quiz-feedback');
|
|
|
|
// Disable all options
|
|
optionsContainer.querySelectorAll('.quiz-option').forEach(option => {
|
|
option.classList.add('disabled');
|
|
|
|
const optionValue = option.dataset.value;
|
|
if (optionValue === question.correctAnswer) {
|
|
option.classList.add('correct');
|
|
} else if (optionValue === question.userAnswer) {
|
|
option.classList.add('incorrect');
|
|
}
|
|
});
|
|
|
|
// Show feedback message
|
|
let feedbackMessage;
|
|
if (timeout) {
|
|
feedbackMessage = `⏰ Time's up! The correct answer was: <strong>${question.correctAnswer}</strong>`;
|
|
} else if (isCorrect) {
|
|
feedbackMessage = `🎉 Correct! +${100 + Math.max(0, this._timeRemaining * 2)} points`;
|
|
} else {
|
|
feedbackMessage = `❌ Incorrect. The correct answer was: <strong>${question.correctAnswer}</strong>`;
|
|
}
|
|
|
|
feedbackContainer.innerHTML = `
|
|
<div class="quiz-feedback ${isCorrect ? 'correct' : 'incorrect'}">
|
|
${feedbackMessage}
|
|
</div>
|
|
<button class="next-question-btn">
|
|
${this._currentQuestion + 1 >= this._questions.length ? 'View Results' : 'Next Question'}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
_nextQuestion() {
|
|
this._currentQuestion++;
|
|
this._isAnswering = false;
|
|
this._showQuestion();
|
|
}
|
|
|
|
_showResults() {
|
|
const correctAnswers = this._questions.filter(q => q.userAnswer === q.correctAnswer).length;
|
|
const accuracy = Math.round((correctAnswers / this._questions.length) * 100);
|
|
const totalTime = this._gameStartTime ? Date.now() - this._gameStartTime : 0;
|
|
const avgTimePerQuestion = Math.round(totalTime / this._questions.length / 1000);
|
|
|
|
// Store best score
|
|
const gameKey = 'quiz-game';
|
|
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: 'Vocabulary Quiz',
|
|
currentScore,
|
|
bestScore: isNewBest ? currentScore : bestScore,
|
|
isNewBest,
|
|
stats: {
|
|
'Questions': `${correctAnswers}/${this._questions.length}`,
|
|
'Accuracy': `${accuracy}%`,
|
|
'Avg Time': `${avgTimePerQuestion}s`,
|
|
'Total Time': `${Math.round(totalTime / 1000)}s`
|
|
}
|
|
});
|
|
|
|
// Emit completion event
|
|
this._eventBus.emit('game:completed', {
|
|
gameId: 'quiz-game',
|
|
instanceId: this.name,
|
|
score: this._score,
|
|
correctAnswers,
|
|
totalQuestions: this._questions.length,
|
|
accuracy,
|
|
duration: totalTime
|
|
}, this.name);
|
|
}
|
|
|
|
_restartQuiz() {
|
|
this._currentQuestion = 0;
|
|
this._score = 0;
|
|
this._isAnswering = false;
|
|
this._generateQuestions();
|
|
this._showQuizDirectionChoice();
|
|
this._updateStats();
|
|
}
|
|
|
|
_updateStats() {
|
|
const scoreElement = document.getElementById('quiz-score');
|
|
const questionElement = document.getElementById('current-question');
|
|
const accuracyElement = document.getElementById('quiz-accuracy');
|
|
|
|
if (scoreElement) scoreElement.textContent = this._score;
|
|
if (questionElement) questionElement.textContent = this._currentQuestion + 1;
|
|
|
|
if (accuracyElement && this._currentQuestion > 0) {
|
|
const answeredQuestions = this._questions.slice(0, this._currentQuestion + 1).filter(q => q.answered);
|
|
const correctAnswers = answeredQuestions.filter(q => q.userAnswer === q.correctAnswer).length;
|
|
const accuracy = answeredQuestions.length > 0 ? Math.round((correctAnswers / answeredQuestions.length) * 100) : 0;
|
|
accuracyElement.textContent = `${accuracy}%`;
|
|
}
|
|
}
|
|
|
|
_showError(message) {
|
|
if (this._config.container) {
|
|
this._config.container.innerHTML = `
|
|
<div class="quiz-error">
|
|
<div class="error-icon">❌</div>
|
|
<h3>Quiz Error</h3>
|
|
<p>${message}</p>
|
|
<button class="btn btn-primary" onclick="history.back()">Go Back</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
_handlePause() {
|
|
if (this._timer) {
|
|
clearInterval(this._timer);
|
|
this._timer = null;
|
|
}
|
|
this._eventBus.emit('game:paused', { instanceId: this.name }, this.name);
|
|
}
|
|
|
|
_handleResume() {
|
|
if (this._timeRemaining > 0 && !this._isAnswering) {
|
|
this._startQuestionTimer();
|
|
}
|
|
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
|
|
}
|
|
|
|
_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);
|
|
|
|
// Animate in
|
|
requestAnimationFrame(() => {
|
|
popup.classList.add('show');
|
|
});
|
|
|
|
// Add event listeners
|
|
popup.querySelector('#play-again-btn').addEventListener('click', () => {
|
|
popup.remove();
|
|
this._restartQuiz();
|
|
});
|
|
|
|
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 = '/';
|
|
}
|
|
});
|
|
|
|
// Close on backdrop click
|
|
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';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export default QuizGame; |