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:
2026-04-30 02:19:58 +00:00
parent 8415e569dd
commit 10afc2bbb0
7 changed files with 377 additions and 9 deletions
+3 -9
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { useAuth } from './hooks/useAuth' import { useAuth } from './hooks/useAuth'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { OnboardingPage } from './pages/OnboardingPage' import { OnboardingPage } from './pages/OnboardingPage'
import { GamePage } from './pages/GamePage'
import './index.css' import './index.css'
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin' type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
@@ -51,15 +52,8 @@ export default function App() {
{route === 'onboarding' && token && ( {route === 'onboarding' && token && (
<OnboardingPage token={token} onComplete={() => setRoute('game')} /> <OnboardingPage token={token} onComplete={() => setRoute('game')} />
)} )}
{route === 'game' && ( {route === 'game' && token && (
<div> <GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
<h1 style={{ fontFamily: 'var(--font-header)' }}>Practice</h1>
<p>Hello, {profile?.display_name || profile?.email || 'Adventurer'}!</p>
<p>Game loop coming in Task 11.</p>
<button className="btn" style={{ marginTop: '1rem' }} onClick={() => { logout(); setRoute('login') }}>
Log out
</button>
</div>
)} )}
{route === 'admin' && ( {route === 'admin' && (
<div> <div>
+15
View File
@@ -0,0 +1,15 @@
type FlashAreaProps = { signal: 'dot' | 'dash' | null }
export function FlashArea({ signal }: FlashAreaProps) {
const active = signal !== null
return (
<div style={{
width: '100%',
height: signal === 'dash' ? 120 : 80,
background: active ? 'var(--color-gold)' : '#2a1a0a',
border: '3px solid var(--color-forest)',
transition: 'background 0.05s, height 0.05s',
borderRadius: 4,
}} />
)
}
+106
View File
@@ -0,0 +1,106 @@
import { useState, useEffect, useRef } from 'react'
import { MORSE } from '../lib/morse'
import { FlashArea } from './FlashArea'
type PracticeCardProps = {
challenge: string // the letter or word to guess
signal: 'dot' | 'dash' | null
isPlaying: boolean
onPlay: () => void
onAnswer: (answer: string) => void // called when user submits
result: 'correct' | 'incorrect' | null
mnemonic: string | null
}
export function PracticeCard({
challenge, signal, isPlaying, onPlay, onAnswer, result, mnemonic
}: PracticeCardProps) {
const [answer, setAnswer] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
// Clear answer on new challenge (when result goes from non-null back to null)
useEffect(() => {
if (result === null) {
setAnswer('')
inputRef.current?.focus()
}
}, [result, challenge])
function submit() {
if (!answer.trim()) return
onAnswer(answer.trim().toUpperCase())
}
const morseDisplay = MORSE[challenge.toUpperCase()]
? challenge.toUpperCase().split('').map(c => MORSE[c] ?? '').join(' ')
: ''
return (
<div>
<FlashArea signal={signal} />
<div style={{ textAlign: 'center', margin: '1.5rem 0' }}>
<button
className="btn"
onClick={onPlay}
disabled={isPlaying}
style={{ minWidth: 120 }}
>
{isPlaying ? 'Playing...' : 'Play'}
</button>
</div>
{/* Show morse pattern hint */}
{morseDisplay && (
<div style={{
textAlign: 'center',
fontFamily: 'var(--font-mono)',
fontSize: '1.2rem',
color: 'var(--color-gold)',
marginBottom: '1rem',
letterSpacing: '0.2em',
}}>
{morseDisplay.split('').map(c => c === '.' ? '·' : c === '-' ? '—' : ' ').join('')}
</div>
)}
{/* Answer input */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
ref={inputRef}
type="text"
value={answer}
onChange={e => setAnswer(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit() }}
placeholder="Type your answer..."
disabled={result === 'correct'}
style={{ flex: 1 }}
/>
<button
className="btn"
onClick={submit}
disabled={!answer.trim() || result === 'correct'}
>
Submit
</button>
</div>
{/* Result feedback */}
{result === 'correct' && (
<div style={{ padding: '1rem', background: '#d4edda', border: '2px solid #28a745', borderRadius: 4 }}>
<strong style={{ color: '#155724' }}>Correct! The answer was: {challenge.toUpperCase()}</strong>
{mnemonic && (
<p style={{ margin: '0.5rem 0 0', color: '#155724', fontStyle: 'italic' }}>
Your mnemonic: "{mnemonic}"
</p>
)}
</div>
)}
{result === 'incorrect' && (
<div style={{ padding: '1rem', background: '#fff3cd', border: '2px solid #ffc107', borderRadius: 4 }}>
<strong style={{ color: '#856404' }}>Try again, adventurer!</strong>
</div>
)}
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
type ScoreBarProps = {
score: number
streak: number
level: number
}
const LEVEL_NAMES: Record<number, string> = { 1: 'Novice', 2: 'Operator' }
export function ScoreBar({ score, streak, level }: ScoreBarProps) {
return (
<div style={{
display: 'flex',
gap: '1.5rem',
padding: '0.75rem 1rem',
background: 'var(--color-forest)',
color: 'var(--color-parchment)',
fontFamily: 'var(--font-body)',
fontSize: '0.9rem',
marginBottom: '1.5rem',
borderRadius: 4,
}}>
<span><strong style={{ color: 'var(--color-gold)' }}>{LEVEL_NAMES[level] ?? `Level ${level}`}</strong></span>
<span>Score: <strong>{score}</strong></span>
<span>Streak: <strong style={{ color: streak >= 5 ? 'var(--color-gold)' : 'inherit' }}>{streak}</strong></span>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { useRef, useState, useCallback } from 'react'
import { MorseAudioEngine } from '../lib/audio'
import { DEFAULT_WPM } from '../lib/morse'
type SignalType = 'dot' | 'dash' | null
export function useMorseAudio() {
const engineRef = useRef<MorseAudioEngine | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentSignal, setCurrentSignal] = useState<SignalType>(null)
function getEngine() {
if (!engineRef.current) {
const engine = new MorseAudioEngine()
engine.on('signal-start', ({ type }: { type: 'dot' | 'dash' }) => setCurrentSignal(type))
engine.on('signal-end', () => setCurrentSignal(null))
engine.on('play-end', () => { setIsPlaying(false); setCurrentSignal(null) })
engineRef.current = engine
}
return engineRef.current
}
const play = useCallback((text: string) => {
const engine = getEngine()
engine.stop()
setIsPlaying(true)
setCurrentSignal(null)
engine.play(text, DEFAULT_WPM)
}, [])
const stop = useCallback(() => {
engineRef.current?.stop()
setIsPlaying(false)
setCurrentSignal(null)
}, [])
return { play, stop, isPlaying, currentSignal }
}
+58
View File
@@ -0,0 +1,58 @@
import { useState, useEffect, useCallback } from 'react'
type Progress = {
level: number
score: number
streak: number
best_streak: number
total_correct: number
total_attempts: number
}
type LetterStats = Record<string, { correct: number; attempts: number }>
export function useProgress(token: string | null) {
const [progress, setProgress] = useState<Progress | null>(null)
const [letterStats, setLetterStats] = useState<LetterStats>({})
const [mnemonics, setMnemonics] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!token) return
const headers = { Authorization: `Bearer ${token}` }
Promise.all([
fetch('/api/progress', { headers }).then(r => r.json()),
fetch('/api/mnemonics', { headers }).then(r => r.json()),
])
.then(([prog, mnems]) => {
setProgress(prog)
setMnemonics(mnems)
})
.finally(() => setLoading(false))
}, [token])
const recordAnswer = useCallback(async (letter: string, correct: boolean): Promise<Progress> => {
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const r = await fetch('/api/progress/answer', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ letter, correct }),
})
const updated: Progress = await r.json()
setProgress(updated)
// Update local letter stats
setLetterStats(prev => {
const stat = prev[letter.toUpperCase()] ?? { correct: 0, attempts: 0 }
return {
...prev,
[letter.toUpperCase()]: {
correct: stat.correct + (correct ? 1 : 0),
attempts: stat.attempts + 1,
},
}
})
return updated
}, [token])
return { progress, letterStats, mnemonics, loading, recordAnswer }
}
+130
View File
@@ -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>
)
}