mafia / frontend /src /ui /QuizModal.ts
Alfaxad's picture
Migrate Mafia game to ZeroGPU Gradio Space
c670567 verified
Raw
History Blame Contribute Delete
9.5 kB
/**
* ============================================================================
* QUIZ MODAL - Quiz question popup dialog
* ============================================================================
*
* Extends ModalOverlay to display quiz questions with:
* - Question text
* - Multiple choice answer buttons
* - Correct/wrong visual feedback
* - Explanation panel (for educational games)
*
* EVENTS:
* - 'answered': (correct: boolean, selectedIndex: number) => void
* - 'feedbackDismissed': () => void
*
* USAGE:
* const quiz = new QuizModal(scene);
* quiz.showQuestion({
* question: 'What is 2+2?',
* options: ['3', '4', '5', '6'],
* correctIndex: 1,
* explanation: '2+2=4',
* });
* quiz.on('answered', (correct, index) => { ... });
*/
import Phaser from 'phaser';
import { ModalOverlay, type ModalConfig } from './ModalOverlay';
import { type QuizQuestion } from '../scenes/BaseBattleScene';
export interface QuizModalConfig extends ModalConfig {
/** Time limit in seconds (0 = no limit, default: 0) */
timeLimit?: number;
/** Show explanation after answering (default: true) */
showExplanation?: boolean;
/** Question text style */
questionStyle?: Phaser.Types.GameObjects.Text.TextStyle;
/** Answer button style */
answerStyle?: Phaser.Types.GameObjects.Text.TextStyle;
}
export class QuizModal extends ModalOverlay {
private quizConfig: QuizModalConfig;
private currentQuestion?: QuizQuestion;
private questionText?: Phaser.GameObjects.Text;
private answerButtons: Phaser.GameObjects.Container[] = [];
private explanationText?: Phaser.GameObjects.Text;
private feedbackText?: Phaser.GameObjects.Text;
private continueText?: Phaser.GameObjects.Text;
private answered: boolean = false;
// Track pending dismiss handlers to prevent leaks
private pendingDismissHandler?: () => void;
private pendingDismissEnterKey?: Phaser.Input.Keyboard.Key;
constructor(scene: Phaser.Scene, config?: QuizModalConfig) {
super(scene, { width: 700, height: 500, ...(config ?? {}) });
this.quizConfig = config ?? {};
}
// -- Public API --
/** Show a quiz question in the modal. */
showQuestion(question: QuizQuestion): void {
// Clean up any stale dismiss handlers from a previous explanation
this.cleanupDismissHandlers();
this.currentQuestion = question;
this.answered = false;
this.clearQuizContent();
this.createQuestionUI(question);
this.show();
}
/** Show the explanation panel (after answering). */
showExplanation(question: QuizQuestion, wasCorrect: boolean): void {
// Remove answer buttons
this.answerButtons.forEach((b) => b.destroy());
this.answerButtons = [];
// Feedback result
const resultStr = wasCorrect ? 'CORRECT!' : 'WRONG!';
const resultColor = wasCorrect ? '#00ff00' : '#ff4444';
this.feedbackText = this.scene.add
.text(0, -80, resultStr, {
fontSize: '32px',
fontFamily: 'Arial',
color: resultColor,
fontStyle: 'bold',
stroke: '#000000',
strokeThickness: 4,
})
.setOrigin(0.5);
this.content.add(this.feedbackText);
// Explanation
if (question.explanation) {
this.explanationText = this.scene.add
.text(0, -20, question.explanation, {
fontSize: '16px',
fontFamily: 'Arial',
color: '#dddddd',
wordWrap: { width: 550 },
align: 'center',
lineSpacing: 4,
})
.setOrigin(0.5, 0);
this.content.add(this.explanationText);
}
// Continue prompt
this.continueText = this.scene.add
.text(0, 120, 'Click or press Enter to continue...', {
fontSize: '14px',
fontFamily: 'Arial',
color: '#888888',
})
.setOrigin(0.5);
this.content.add(this.continueText);
// Blink
this.scene.tweens.add({
targets: this.continueText,
alpha: { from: 1, to: 0.3 },
duration: 500,
yoyo: true,
repeat: -1,
});
// Wait for dismiss
const dismiss = () => {
this.cleanupDismissHandlers();
this.hide();
this.emit('feedbackDismissed');
};
// Store references for cleanup
this.pendingDismissHandler = dismiss;
const enterKey = this.scene.input.keyboard?.addKey(
Phaser.Input.Keyboard.KeyCodes.ENTER,
);
this.pendingDismissEnterKey = enterKey;
this.scene.time.delayedCall(500, () => {
if (this.pendingDismissHandler === dismiss) {
this.scene.input.on('pointerdown', dismiss);
enterKey?.on('down', dismiss);
}
});
}
/** Remove any pending dismiss event handlers to prevent leaks. */
private cleanupDismissHandlers(): void {
if (this.pendingDismissHandler) {
this.scene.input.off('pointerdown', this.pendingDismissHandler);
this.pendingDismissHandler = undefined;
}
if (this.pendingDismissEnterKey) {
this.pendingDismissEnterKey.off('down');
this.pendingDismissEnterKey = undefined;
}
}
// -- Internal --
private createQuestionUI(question: QuizQuestion): void {
const panelW = (this.modalConfig.width ?? 700) - 60;
// Question text
this.questionText = this.scene.add
.text(0, -150, question.question, {
fontSize: '22px',
fontFamily: 'Arial',
color: '#ffffff',
fontStyle: 'bold',
wordWrap: { width: panelW },
align: 'center',
lineSpacing: 6,
...(this.quizConfig.questionStyle ?? {}),
})
.setOrigin(0.5, 0);
this.content.add(this.questionText);
// Answer buttons
const btnWidth = panelW - 40;
const btnHeight = 45;
const spacing = 12;
const startY = -30;
question.options.forEach((option, index) => {
const y = startY + index * (btnHeight + spacing);
const btn = this.createAnswerButton(
option,
index,
y,
btnWidth,
btnHeight,
);
this.answerButtons.push(btn);
this.content.add(btn);
});
}
private createAnswerButton(
text: string,
index: number,
y: number,
width: number,
height: number,
): Phaser.GameObjects.Container {
const container = this.scene.add.container(0, y);
const letters = ['A', 'B', 'C', 'D', 'E', 'F'];
const prefix = letters[index] ?? String(index + 1);
// Background
const bg = this.scene.add.rectangle(0, 0, width, height, 0x2a2a4a, 0.9);
bg.setOrigin(0.5);
bg.setStrokeStyle(1, 0x6666aa);
container.add(bg);
// Letter circle
const circle = this.scene.add.circle(-width / 2 + 30, 0, 15, 0x4444aa);
container.add(circle);
const letterText = this.scene.add
.text(-width / 2 + 30, 0, prefix, {
fontSize: '14px',
fontFamily: 'Arial',
color: '#ffffff',
fontStyle: 'bold',
})
.setOrigin(0.5);
container.add(letterText);
// Answer text
const answerText = this.scene.add
.text(-width / 2 + 55, 0, text, {
fontSize: '16px',
fontFamily: 'Arial',
color: '#ffffff',
...(this.quizConfig.answerStyle ?? {}),
})
.setOrigin(0, 0.5);
container.add(answerText);
// Interactivity
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => {
if (!this.answered) {
bg.setFillStyle(0x4444aa, 1);
}
});
bg.on('pointerout', () => {
if (!this.answered) {
bg.setFillStyle(0x2a2a4a, 0.9);
}
});
bg.on('pointerdown', () => {
if (!this.answered) {
this.answered = true;
this.onAnswerSelected(index, bg);
}
});
return container;
}
private onAnswerSelected(
index: number,
clickedBg: Phaser.GameObjects.Rectangle,
): void {
if (!this.currentQuestion) return;
const correct = index === this.currentQuestion.correctIndex;
// Visual feedback: highlight correct/wrong
this.answerButtons.forEach((btn, i) => {
const bg = btn.list[0] as Phaser.GameObjects.Rectangle;
if (bg) {
bg.disableInteractive();
if (i === this.currentQuestion!.correctIndex) {
bg.setFillStyle(0x006600, 1); // Green for correct
} else if (i === index && !correct) {
bg.setFillStyle(0x660000, 1); // Red for wrong selection
}
}
});
this.emit('answered', correct, index);
// Show explanation after brief delay (only if modal is still visible)
if (this.quizConfig.showExplanation !== false) {
this.scene.time.delayedCall(800, () => {
// Guard: don't show explanation if the modal was already hidden
// (e.g., resolveCardAction hides it before the explanation fires)
if (this.isShowing) {
this.showExplanation(this.currentQuestion!, correct);
}
});
}
}
private clearQuizContent(): void {
this.answerButtons.forEach((b) => b.destroy());
this.answerButtons = [];
if (this.questionText) {
this.questionText.destroy();
this.questionText = undefined;
}
if (this.explanationText) {
this.explanationText.destroy();
this.explanationText = undefined;
}
if (this.feedbackText) {
this.feedbackText.destroy();
this.feedbackText = undefined;
}
if (this.continueText) {
this.continueText.destroy();
this.continueText = undefined;
}
// Clear all from content container
this.content.removeAll(true);
}
}