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>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user