Files
morsequest/client/src/pages/GamePage.tsx
T
kitadmin 10afc2bbb0 feat: game practice loop with audio, scoring, and level-up modal (Task 11)
- useMorseAudio: wraps MorseAudioEngine, exposes currentSignal for FlashArea
- useProgress: fetches /api/progress + /api/mnemonics, recordAnswer with local stats
- FlashArea: gold flash on signal, height varies dot/dash
- ScoreBar: level name, score, streak display
- PracticeCard: play button, answer input, correct/incorrect feedback with mnemonic
- GamePage: full practice loop, level-up modal at streak%10, anti-repeat history
- Fix: incorrect attempt recorded only once per challenge (no stat inflation on retries)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 02:19:58 +00:00

131 lines
4.3 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react'
import { pickChallenge, NOVICE_LETTERS, OPERATOR_WORDS } from '../lib/morse'
import { useMorseAudio } from '../hooks/useMorseAudio'
import { useProgress } from '../hooks/useProgress'
import { ScoreBar } from '../components/ScoreBar'
import { PracticeCard } from '../components/PracticeCard'
type GamePageProps = {
token: string
onLogout: () => void
}
export function GamePage({ token, onLogout }: GamePageProps) {
const audio = useMorseAudio()
const { progress, letterStats, mnemonics, loading, recordAnswer } = useProgress(token)
const [challenge, setChallenge] = useState<string | null>(null)
const [result, setResult] = useState<'correct' | 'incorrect' | null>(null)
const [showLevelUp, setShowLevelUp] = useState(false)
const recentHistoryRef = useRef<string[]>([])
const incorrectRecordedRef = useRef(false)
// Pick a new challenge based on current level
const nextChallenge = useCallback(() => {
const level = progress?.level ?? 1
const pool = level >= 2 ? OPERATOR_WORDS : NOVICE_LETTERS
const c = pickChallenge(pool, letterStats, recentHistoryRef.current)
recentHistoryRef.current = [...recentHistoryRef.current.slice(-4), c]
setChallenge(c)
setResult(null)
incorrectRecordedRef.current = false
audio.stop()
}, [progress?.level, letterStats])
// Load first challenge after progress loads
useEffect(() => {
if (!loading && progress && !challenge) {
nextChallenge()
}
}, [loading, progress])
async function handleAnswer(answer: string) {
if (!challenge || result === 'correct') return
const isCorrect = answer === challenge.toUpperCase()
setResult(isCorrect ? 'correct' : 'incorrect')
if (isCorrect) {
const updated = await recordAnswer(challenge, true)
// Level-up trigger: 10 consecutive correct
if (updated.streak > 0 && updated.streak % 10 === 0) {
setShowLevelUp(true)
}
} else if (!incorrectRecordedRef.current) {
// Only record first incorrect attempt per challenge (allow re-tries without inflating stats)
incorrectRecordedRef.current = true
await recordAnswer(challenge, false)
}
}
function playChallenge() {
if (!challenge) return
audio.play(challenge)
}
if (loading || !progress || !challenge) {
return (
<div style={{ textAlign: 'center', paddingTop: '3rem' }}>
<p style={{ fontFamily: 'var(--font-header)' }}>Loading quest...</p>
</div>
)
}
return (
<div>
<ScoreBar score={progress.score} streak={progress.streak} level={progress.level} />
<PracticeCard
challenge={challenge}
signal={audio.currentSignal}
isPlaying={audio.isPlaying}
onPlay={playChallenge}
onAnswer={handleAnswer}
result={result}
mnemonic={mnemonics[challenge.toUpperCase()] ?? null}
/>
{result === 'correct' && (
<div style={{ textAlign: 'center', marginTop: '1.5rem' }}>
<button className="btn" onClick={nextChallenge}>
Next Challenge
</button>
</div>
)}
{/* Level-up modal */}
{showLevelUp && (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 100,
}}>
<div style={{
background: 'var(--color-parchment)',
border: '3px solid var(--color-gold)',
padding: '2rem',
maxWidth: 380,
textAlign: 'center',
}}>
<h2 style={{ fontFamily: 'var(--font-header)', marginBottom: '1rem' }}>
Quest Complete!
</h2>
<p style={{ marginBottom: '1.5rem' }}>
10 in a row! {progress.level < 2 ? "You've advanced to Operator level!" : 'Outstanding skill!'}
</p>
<button className="btn" onClick={() => { setShowLevelUp(false); nextChallenge() }}>
Continue
</button>
</div>
</div>
)}
{/* Nav */}
<div style={{ marginTop: '2rem', borderTop: '1px solid var(--color-brown)', paddingTop: '1rem' }}>
<button className="btn btn-ghost" onClick={onLogout} style={{ fontSize: '0.85rem' }}>
Log out
</button>
</div>
</div>
)
}