|
2 | 2 | import { base } from '$app/paths'; |
3 | 3 | import type { QuizMode, Sample, SampleManifest } from '$lib/types'; |
4 | 4 | import GifPlayer from '$lib/components/GifPlayer.svelte'; |
5 | | - import { onMount } from 'svelte'; |
| 5 | + import { onMount, onDestroy } from 'svelte'; |
6 | 6 |
|
7 | 7 | // Constants |
8 | | - const QUESTIONS_PER_ROUND = 5; |
9 | 8 | const OPTIONS_PER_QUESTION = 4; |
10 | 9 | const POINTS_CORRECT = 10; |
11 | 10 | const STREAK_BONUS = 5; |
|
24 | 23 | // State |
25 | 24 | let gamePhase = $state<GamePhase>('setup'); |
26 | 25 | let selectedMode = $state<QuizMode>('phase-to-intensity'); |
| 26 | + let selectedQuestionCount = $state<number>(10); |
27 | 27 | let allSamples = $state<Sample[]>([]); |
28 | 28 | let questions = $state<QuizQuestion[]>([]); |
29 | 29 | let currentQuestionIndex = $state(0); |
|
33 | 33 | let answers = $state<boolean[]>([]); |
34 | 34 | let loading = $state(true); |
35 | 35 | let error = $state<string | null>(null); |
| 36 | + let startTime = $state<number>(0); |
| 37 | + let elapsedTime = $state<number>(0); |
| 38 | + let timerInterval = $state<number | null>(null); |
36 | 39 |
|
37 | 40 | // Derived |
38 | 41 | let currentQuestion = $derived(questions[currentQuestionIndex]); |
39 | 42 | let isLastQuestion = $derived(currentQuestionIndex === questions.length - 1); |
40 | 43 | let streakBonus = $derived(Math.min(streak * STREAK_BONUS, MAX_STREAK_BONUS)); |
| 44 | + let formattedTime = $derived(formatTime(elapsedTime)); |
41 | 45 |
|
42 | 46 | onMount(async () => { |
43 | 47 | try { |
|
55 | 59 | } |
56 | 60 | }); |
57 | 61 |
|
| 62 | + onDestroy(() => { |
| 63 | + stopTimer(); |
| 64 | + }); |
| 65 | +
|
| 66 | + function formatTime(seconds: number): string { |
| 67 | + const mins = Math.floor(seconds / 60); |
| 68 | + const secs = seconds % 60; |
| 69 | + return `${mins}:${secs.toString().padStart(2, '0')}`; |
| 70 | + } |
| 71 | +
|
| 72 | + function startTimer() { |
| 73 | + startTime = Date.now(); |
| 74 | + elapsedTime = 0; |
| 75 | + if (timerInterval) clearInterval(timerInterval); |
| 76 | + timerInterval = window.setInterval(() => { |
| 77 | + elapsedTime = Math.floor((Date.now() - startTime) / 1000); |
| 78 | + }, 1000); |
| 79 | + } |
| 80 | +
|
| 81 | + function stopTimer() { |
| 82 | + if (timerInterval) { |
| 83 | + clearInterval(timerInterval); |
| 84 | + timerInterval = null; |
| 85 | + } |
| 86 | + } |
| 87 | +
|
58 | 88 | function shuffleArray<T>(array: T[]): T[] { |
59 | 89 | const shuffled = [...array]; |
60 | 90 | for (let i = shuffled.length - 1; i > 0; i--) { |
|
68 | 98 | const shuffledSamples = shuffleArray(allSamples); |
69 | 99 | const generatedQuestions: QuizQuestion[] = []; |
70 | 100 |
|
71 | | - for (let i = 0; i < QUESTIONS_PER_ROUND; i++) { |
| 101 | + for (let i = 0; i < selectedQuestionCount; i++) { |
72 | 102 | // Pick correct answer (cycle through shuffled samples) |
73 | 103 | const correctSample = shuffledSamples[i % shuffledSamples.length]; |
74 | 104 |
|
|
99 | 129 | selectedAnswer = null; |
100 | 130 | answers = []; |
101 | 131 | gamePhase = 'playing'; |
| 132 | + startTimer(); |
102 | 133 | } |
103 | 134 |
|
104 | 135 | function selectAnswer(index: number) { |
|
121 | 152 |
|
122 | 153 | function nextQuestion() { |
123 | 154 | if (isLastQuestion) { |
| 155 | + stopTimer(); |
124 | 156 | gamePhase = 'results'; |
125 | 157 | } else { |
126 | 158 | currentQuestionIndex += 1; |
|
130 | 162 | } |
131 | 163 |
|
132 | 164 | function resetQuiz() { |
| 165 | + stopTimer(); |
133 | 166 | gamePhase = 'setup'; |
134 | 167 | questions = []; |
135 | 168 | currentQuestionIndex = 0; |
136 | 169 | score = 0; |
137 | 170 | streak = 0; |
138 | 171 | selectedAnswer = null; |
139 | 172 | answers = []; |
| 173 | + elapsedTime = 0; |
140 | 174 | } |
141 | 175 |
|
142 | 176 | function resolvePath(path: string): string { |
|
236 | 270 | </div> |
237 | 271 | </section> |
238 | 272 |
|
| 273 | + <section class="option-group"> |
| 274 | + <h2>Number of Questions</h2> |
| 275 | + <div class="options difficulty-options"> |
| 276 | + <button |
| 277 | + class="option-btn small" |
| 278 | + class:active={selectedQuestionCount === 5} |
| 279 | + onclick={() => selectedQuestionCount = 5} |
| 280 | + > |
| 281 | + <span class="option-title">5</span> |
| 282 | + <span class="option-desc">Quick</span> |
| 283 | + </button> |
| 284 | + <button |
| 285 | + class="option-btn small" |
| 286 | + class:active={selectedQuestionCount === 10} |
| 287 | + onclick={() => selectedQuestionCount = 10} |
| 288 | + > |
| 289 | + <span class="option-title">10</span> |
| 290 | + <span class="option-desc">Standard</span> |
| 291 | + </button> |
| 292 | + </div> |
| 293 | + </section> |
| 294 | + |
239 | 295 | <div class="start-section"> |
240 | 296 | <button class="start-btn primary" onclick={startQuiz}> |
241 | 297 | Start Quiz |
242 | 298 | </button> |
243 | | - <p class="hint">{QUESTIONS_PER_ROUND} questions per round</p> |
| 299 | + <p class="hint">{selectedQuestionCount} questions per round</p> |
244 | 300 | </div> |
245 | 301 | </div> |
246 | 302 | {:else if gamePhase === 'playing' || gamePhase === 'feedback'} |
|
255 | 311 | <span class="score-label">Score</span> |
256 | 312 | <span class="score-value">{score}</span> |
257 | 313 | </div> |
| 314 | + <div class="score-item timer-item"> |
| 315 | + <span class="score-label">Time</span> |
| 316 | + <span class="score-value timer-value">{formattedTime}</span> |
| 317 | + </div> |
258 | 318 | <div class="score-item"> |
259 | 319 | <span class="score-label">Streak</span> |
260 | 320 | <span class="score-value">{streak}{streak > 0 ? ` (+${streakBonus})` : ''}</span> |
|
286 | 346 | <p class="options-prompt">Select the matching {selectedMode === 'phase-to-intensity' ? 'intensity' : 'phase'}:</p> |
287 | 347 | <div class="options-grid"> |
288 | 348 | {#each currentQuestion.options as option, index} |
289 | | - <button |
290 | | - class="option-card {getOptionClass(index)}" |
291 | | - onclick={() => selectAnswer(index)} |
292 | | - disabled={gamePhase === 'feedback'} |
293 | | - > |
294 | | - <GifPlayer |
295 | | - src={getOptionGif(option)} |
296 | | - alt="Option {index + 1}" |
297 | | - /> |
298 | | - {#if gamePhase === 'feedback'} |
299 | | - <span class="option-label">{option.name}</span> |
| 349 | + <div class="option-wrapper"> |
| 350 | + <button |
| 351 | + class="option-card {getOptionClass(index)}" |
| 352 | + onclick={() => selectAnswer(index)} |
| 353 | + disabled={gamePhase === 'feedback'} |
| 354 | + id="option-{index}" |
| 355 | + > |
| 356 | + <GifPlayer |
| 357 | + src={getOptionGif(option)} |
| 358 | + alt="Option {index + 1}" |
| 359 | + /> |
| 360 | + {#if gamePhase === 'feedback'} |
| 361 | + <span class="option-label">{option.name}</span> |
| 362 | + {/if} |
| 363 | + </button> |
| 364 | + |
| 365 | + <!-- Feedback overlay on selected option --> |
| 366 | + {#if gamePhase === 'feedback' && selectedAnswer === index} |
| 367 | + <div class="feedback-overlay"> |
| 368 | + <div class="feedback-content"> |
| 369 | + {#if selectedAnswer === currentQuestion.correctIndex} |
| 370 | + <p class="feedback-text correct">Correct! +{POINTS_CORRECT}{streak > 1 ? ` +${Math.min((streak - 1) * STREAK_BONUS, MAX_STREAK_BONUS)} streak bonus` : ''}</p> |
| 371 | + {:else} |
| 372 | + <p class="feedback-text incorrect">Incorrect. The answer was: {currentQuestion.correctSample.name}</p> |
| 373 | + {/if} |
| 374 | + <button class="next-btn primary" onclick={nextQuestion}> |
| 375 | + {isLastQuestion ? 'See Results' : 'Next Question'} |
| 376 | + </button> |
| 377 | + </div> |
| 378 | + </div> |
300 | 379 | {/if} |
301 | | - </button> |
| 380 | + </div> |
302 | 381 | {/each} |
303 | 382 | </div> |
304 | 383 | </div> |
305 | | - |
306 | | - <!-- Feedback / Next --> |
307 | | - {#if gamePhase === 'feedback'} |
308 | | - <div class="feedback-section"> |
309 | | - {#if selectedAnswer === currentQuestion.correctIndex} |
310 | | - <p class="feedback-text correct">Correct! +{POINTS_CORRECT}{streak > 1 ? ` +${Math.min((streak - 1) * STREAK_BONUS, MAX_STREAK_BONUS)} streak bonus` : ''}</p> |
311 | | - {:else} |
312 | | - <p class="feedback-text incorrect">Incorrect. The answer was: {currentQuestion.correctSample.name}</p> |
313 | | - {/if} |
314 | | - <button class="next-btn primary" onclick={nextQuestion}> |
315 | | - {isLastQuestion ? 'See Results' : 'Next Question'} |
316 | | - </button> |
317 | | - </div> |
318 | | - {/if} |
319 | 384 | </div> |
320 | 385 | {:else if gamePhase === 'results'} |
321 | 386 | <div class="results"> |
322 | 387 | <div class="results-card"> |
323 | 388 | <h2>Quiz Complete</h2> |
324 | | - <div class="final-score"> |
325 | | - <span class="score-number">{score}</span> |
326 | | - <span class="score-label">points</span> |
| 389 | + <div class="final-scores"> |
| 390 | + <div class="final-score"> |
| 391 | + <span class="score-number">{score}</span> |
| 392 | + <span class="score-label">points</span> |
| 393 | + </div> |
| 394 | + <div class="final-score"> |
| 395 | + <span class="score-number">{formattedTime}</span> |
| 396 | + <span class="score-label">time</span> |
| 397 | + </div> |
327 | 398 | </div> |
328 | 399 | <div class="results-stats"> |
329 | 400 | <div class="stat"> |
|
489 | 560 | gap: 2px; |
490 | 561 | } |
491 | 562 |
|
| 563 | + .timer-item { |
| 564 | + align-items: center; |
| 565 | + } |
| 566 | +
|
492 | 567 | .score-label { |
493 | 568 | font-size: 0.75rem; |
494 | 569 | text-transform: uppercase; |
|
501 | 576 | color: var(--accent); |
502 | 577 | } |
503 | 578 |
|
| 579 | + .timer-value { |
| 580 | + font-size: 1.3rem; |
| 581 | + font-weight: 600; |
| 582 | + color: var(--text-primary); |
| 583 | + } |
| 584 | +
|
504 | 585 | .question-section { |
505 | 586 | display: flex; |
506 | 587 | flex-direction: column; |
|
550 | 631 | } |
551 | 632 | } |
552 | 633 |
|
| 634 | + .option-wrapper { |
| 635 | + position: relative; |
| 636 | + } |
| 637 | +
|
553 | 638 | .option-card { |
554 | 639 | display: flex; |
555 | 640 | flex-direction: column; |
|
559 | 644 | border: 2px solid var(--border); |
560 | 645 | cursor: pointer; |
561 | 646 | transition: border-color 0.15s, opacity 0.15s; |
| 647 | + width: 100%; |
562 | 648 | } |
563 | 649 |
|
564 | 650 | .option-card:hover:not(:disabled) { |
|
592 | 678 | white-space: nowrap; |
593 | 679 | } |
594 | 680 |
|
| 681 | + .feedback-overlay { |
| 682 | + position: absolute; |
| 683 | + top: 0; |
| 684 | + left: 0; |
| 685 | + right: 0; |
| 686 | + bottom: 0; |
| 687 | + display: flex; |
| 688 | + align-items: center; |
| 689 | + justify-content: center; |
| 690 | + pointer-events: none; |
| 691 | + z-index: 10; |
| 692 | + } |
| 693 | +
|
| 694 | + .feedback-content { |
| 695 | + display: flex; |
| 696 | + flex-direction: column; |
| 697 | + align-items: center; |
| 698 | + gap: var(--spacing-md); |
| 699 | + padding: var(--spacing-lg); |
| 700 | + background-color: rgba(0, 0, 0, 0.85); |
| 701 | + backdrop-filter: blur(4px); |
| 702 | + border-radius: 8px; |
| 703 | + pointer-events: auto; |
| 704 | + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
| 705 | + } |
| 706 | +
|
595 | 707 | .feedback-section { |
596 | 708 | display: flex; |
597 | 709 | flex-direction: column; |
|
605 | 717 | .feedback-text { |
606 | 718 | font-size: 1.1rem; |
607 | 719 | font-weight: 500; |
| 720 | + margin: 0; |
| 721 | + text-align: center; |
608 | 722 | } |
609 | 723 |
|
610 | 724 | .feedback-text.correct { |
|
617 | 731 |
|
618 | 732 | .next-btn { |
619 | 733 | padding: var(--spacing-sm) var(--spacing-lg); |
| 734 | + background-color: rgba(255, 255, 255, 0.5); |
| 735 | + backdrop-filter: blur(4px); |
| 736 | + } |
| 737 | +
|
| 738 | + .next-btn:hover { |
| 739 | + background-color: rgba(255, 255, 255, 0.7); |
620 | 740 | } |
621 | 741 |
|
622 | 742 | /* Results */ |
|
640 | 760 | margin: 0; |
641 | 761 | } |
642 | 762 |
|
| 763 | + .final-scores { |
| 764 | + display: flex; |
| 765 | + gap: var(--spacing-xl); |
| 766 | + align-items: center; |
| 767 | + } |
| 768 | +
|
643 | 769 | .final-score { |
644 | 770 | display: flex; |
645 | 771 | flex-direction: column; |
|
0 commit comments