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 { LoginPage } from './pages/LoginPage'
|
||||
import { OnboardingPage } from './pages/OnboardingPage'
|
||||
import { GamePage } from './pages/GamePage'
|
||||
import './index.css'
|
||||
|
||||
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
|
||||
@@ -51,15 +52,8 @@ export default function App() {
|
||||
{route === 'onboarding' && token && (
|
||||
<OnboardingPage token={token} onComplete={() => setRoute('game')} />
|
||||
)}
|
||||
{route === 'game' && (
|
||||
<div>
|
||||
<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 === 'game' && token && (
|
||||
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
|
||||
)}
|
||||
{route === 'admin' && (
|
||||
<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