10afc2bbb0
- 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>
131 lines
4.3 KiB
TypeScript
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>
|
|
)
|
|
}
|