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:
+3
-9
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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