|
| 1 | +import { useState, useEffect, useCallback } from 'react' |
| 2 | +import { playCorrect, playWrong, playCelebration, playPop } from './sounds' |
| 3 | + |
| 4 | +function sendToPlatform(type: string, correlationId: string, data: Record<string, unknown>) { |
| 5 | + window.parent.postMessage({ type, correlationId, data }, '*') |
| 6 | +} |
| 7 | + |
| 8 | +// ─── Letter data with pictures ────────────────────────────────────── |
| 9 | +const LETTER_DATA: { letter: string; word: string; emoji: string }[] = [ |
| 10 | + { letter: 'A', word: 'Apple', emoji: '🍎' }, |
| 11 | + { letter: 'B', word: 'Bear', emoji: '🐻' }, |
| 12 | + { letter: 'C', word: 'Cat', emoji: '🐱' }, |
| 13 | + { letter: 'D', word: 'Dog', emoji: '🐶' }, |
| 14 | + { letter: 'E', word: 'Elephant', emoji: '🐘' }, |
| 15 | + { letter: 'F', word: 'Fish', emoji: '🐟' }, |
| 16 | + { letter: 'G', word: 'Grapes', emoji: '🍇' }, |
| 17 | + { letter: 'H', word: 'House', emoji: '🏠' }, |
| 18 | + { letter: 'I', word: 'Ice cream', emoji: '🍦' }, |
| 19 | + { letter: 'J', word: 'Juice', emoji: '🧃' }, |
| 20 | + { letter: 'K', word: 'Kite', emoji: '🪁' }, |
| 21 | + { letter: 'L', word: 'Lion', emoji: '🦁' }, |
| 22 | + { letter: 'M', word: 'Moon', emoji: '🌙' }, |
| 23 | + { letter: 'N', word: 'Nest', emoji: '🪺' }, |
| 24 | + { letter: 'O', word: 'Orange', emoji: '🍊' }, |
| 25 | + { letter: 'P', word: 'Penguin', emoji: '🐧' }, |
| 26 | + { letter: 'Q', word: 'Queen', emoji: '👑' }, |
| 27 | + { letter: 'R', word: 'Rainbow', emoji: '🌈' }, |
| 28 | + { letter: 'S', word: 'Sun', emoji: '☀️' }, |
| 29 | + { letter: 'T', word: 'Tree', emoji: '🌳' }, |
| 30 | + { letter: 'U', word: 'Umbrella', emoji: '☂️' }, |
| 31 | + { letter: 'V', word: 'Violin', emoji: '🎻' }, |
| 32 | + { letter: 'W', word: 'Whale', emoji: '🐳' }, |
| 33 | + { letter: 'X', word: 'Xylophone', emoji: '🎵' }, |
| 34 | + { letter: 'Y', word: 'Yarn', emoji: '🧶' }, |
| 35 | + { letter: 'Z', word: 'Zebra', emoji: '🦓' }, |
| 36 | +] |
| 37 | + |
| 38 | +type Mode = 'menu' | 'find-letter' | 'what-starts' | 'uppercase-lowercase' | 'result' |
| 39 | + |
| 40 | +interface Question { |
| 41 | + prompt: string |
| 42 | + display: string // big visual (emoji or letter) |
| 43 | + options: string[] |
| 44 | + correct: string |
| 45 | +} |
| 46 | + |
| 47 | +function shuffle<T>(arr: T[]): T[] { |
| 48 | + const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]] }; return a |
| 49 | +} |
| 50 | + |
| 51 | +function pickRandom<T>(arr: T[], exclude?: T): T { |
| 52 | + const filtered = exclude ? arr.filter(x => x !== exclude) : arr |
| 53 | + return filtered[Math.floor(Math.random() * filtered.length)] |
| 54 | +} |
| 55 | + |
| 56 | +function generateQuestion(mode: 'find-letter' | 'what-starts' | 'uppercase-lowercase'): Question { |
| 57 | + const item = LETTER_DATA[Math.floor(Math.random() * LETTER_DATA.length)] |
| 58 | + |
| 59 | + if (mode === 'find-letter') { |
| 60 | + // Show emoji, ask "What letter does WORD start with?" |
| 61 | + const wrongA = pickRandom(LETTER_DATA, item).letter |
| 62 | + const wrongB = pickRandom(LETTER_DATA.filter(l => l.letter !== item.letter && l.letter !== wrongA)).letter |
| 63 | + return { |
| 64 | + prompt: `What letter does "${item.word}" start with?`, |
| 65 | + display: item.emoji, |
| 66 | + options: shuffle([item.letter, wrongA, wrongB]), |
| 67 | + correct: item.letter, |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + if (mode === 'what-starts') { |
| 72 | + // Show a letter, ask which picture starts with it |
| 73 | + const wrongA = pickRandom(LETTER_DATA, item) |
| 74 | + const wrongB = pickRandom(LETTER_DATA.filter(l => l.letter !== item.letter && l.letter !== wrongA.letter)) |
| 75 | + return { |
| 76 | + prompt: `Which one starts with the letter ${item.letter}?`, |
| 77 | + display: item.letter, |
| 78 | + options: shuffle([`${item.emoji} ${item.word}`, `${wrongA.emoji} ${wrongA.word}`, `${wrongB.emoji} ${wrongB.word}`]), |
| 79 | + correct: `${item.emoji} ${item.word}`, |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + // uppercase-lowercase: match upper to lower |
| 84 | + const wrongA = pickRandom(LETTER_DATA, item).letter.toLowerCase() |
| 85 | + const wrongB = pickRandom(LETTER_DATA.filter(l => l.letter !== item.letter && l.letter.toLowerCase() !== wrongA)).letter.toLowerCase() |
| 86 | + return { |
| 87 | + prompt: `Which is the lowercase version of ${item.letter}?`, |
| 88 | + display: item.letter, |
| 89 | + options: shuffle([item.letter.toLowerCase(), wrongA, wrongB]), |
| 90 | + correct: item.letter.toLowerCase(), |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +const ROUNDS_PER_GAME = 5 |
| 95 | +const GAME_MODES: { id: 'find-letter' | 'what-starts' | 'uppercase-lowercase'; label: string; emoji: string; desc: string }[] = [ |
| 96 | + { id: 'find-letter', label: 'Find the Letter', emoji: '🔤', desc: 'What letter does this start with?' }, |
| 97 | + { id: 'what-starts', label: 'Match the Picture', emoji: '🖼️', desc: 'Which picture starts with this letter?' }, |
| 98 | + { id: 'uppercase-lowercase', label: 'Big & Small Letters', emoji: '🔡', desc: 'Match uppercase to lowercase!' }, |
| 99 | +] |
| 100 | + |
| 101 | +export default function ABCLetters() { |
| 102 | + const [mode, setMode] = useState<Mode>('menu') |
| 103 | + const [gameMode, setGameMode] = useState<'find-letter' | 'what-starts' | 'uppercase-lowercase'>('find-letter') |
| 104 | + const [question, setQuestion] = useState<Question | null>(null) |
| 105 | + const [round, setRound] = useState(0) |
| 106 | + const [score, setScore] = useState(0) |
| 107 | + const [feedback, setFeedback] = useState<{ correct: boolean; message: string } | null>(null) |
| 108 | + const [streak, setStreak] = useState(0) |
| 109 | + |
| 110 | + useEffect(() => { sendToPlatform('ui_ready', '', {}) }, []) |
| 111 | + |
| 112 | + useEffect(() => { |
| 113 | + function handleMessage(event: MessageEvent) { |
| 114 | + const msg = event.data |
| 115 | + if (!msg || msg.type !== 'tool_invoke') return |
| 116 | + const { correlationId, tool } = msg |
| 117 | + if (tool === 'get_state') { |
| 118 | + sendToPlatform('tool_result', correlationId, { tool: 'get_state', mode, round, score, streak }) |
| 119 | + } else if (tool === 'restore_state') { |
| 120 | + sendToPlatform('tool_result', correlationId, { tool: 'restore_state', message: 'Restored' }) |
| 121 | + } else { |
| 122 | + sendToPlatform('error', correlationId, { message: `Unknown tool: ${tool}` }) |
| 123 | + } |
| 124 | + } |
| 125 | + window.addEventListener('message', handleMessage) |
| 126 | + return () => window.removeEventListener('message', handleMessage) |
| 127 | + }, [mode, round, score, streak]) |
| 128 | + |
| 129 | + const startGame = useCallback((m: 'find-letter' | 'what-starts' | 'uppercase-lowercase') => { |
| 130 | + playPop() |
| 131 | + setGameMode(m) |
| 132 | + setRound(0) |
| 133 | + setScore(0) |
| 134 | + setStreak(0) |
| 135 | + setFeedback(null) |
| 136 | + setQuestion(generateQuestion(m)) |
| 137 | + setMode(m) |
| 138 | + sendToPlatform('state_update', '', { type: 'game_start', mode: m }) |
| 139 | + }, []) |
| 140 | + |
| 141 | + function handleAnswer(answer: string) { |
| 142 | + if (!question || feedback) return |
| 143 | + const correct = answer === question.correct |
| 144 | + if (correct) { |
| 145 | + setScore(s => s + 1) |
| 146 | + setStreak(s => s + 1) |
| 147 | + playCorrect() |
| 148 | + const msgs = ['Awesome! 🎉', 'You know your letters! ⭐', 'Great job! 🌟', 'So smart! 🧠', 'Perfect! 🎸'] |
| 149 | + setFeedback({ correct: true, message: msgs[Math.floor(Math.random() * msgs.length)] }) |
| 150 | + } else { |
| 151 | + setStreak(0) |
| 152 | + playWrong() |
| 153 | + setFeedback({ correct: false, message: `It's "${question.correct}"! You'll get it next time! 💪` }) |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + function nextRound() { |
| 158 | + if (round + 1 >= ROUNDS_PER_GAME) { |
| 159 | + playCelebration() |
| 160 | + setMode('result') |
| 161 | + sendToPlatform('completion', '', { summary: `ABC game complete! Score: ${score}/${ROUNDS_PER_GAME}` }) |
| 162 | + return |
| 163 | + } |
| 164 | + setRound(r => r + 1) |
| 165 | + setFeedback(null) |
| 166 | + setQuestion(generateQuestion(gameMode)) |
| 167 | + } |
| 168 | + |
| 169 | + const font = 'system-ui, -apple-system, sans-serif' |
| 170 | + |
| 171 | + // ─── Menu ──────────────────────────────────────────────────── |
| 172 | + if (mode === 'menu') { |
| 173 | + return ( |
| 174 | + <div style={{ padding: '24px 16px', maxWidth: '400px', margin: '0 auto', fontFamily: font, textAlign: 'center' }}> |
| 175 | + <div style={{ fontSize: '48px', marginBottom: '8px' }}>🔤</div> |
| 176 | + <div style={{ fontSize: '24px', fontWeight: 700, color: '#111827', marginBottom: '4px' }}>ABC Letters</div> |
| 177 | + <div style={{ fontSize: '16px', color: '#6b7280', marginBottom: '24px' }}>Learn your letters!</div> |
| 178 | + <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> |
| 179 | + {GAME_MODES.map(m => ( |
| 180 | + <button key={m.id} onClick={() => startGame(m.id)} style={{ |
| 181 | + display: 'flex', alignItems: 'center', gap: '16px', padding: '20px', |
| 182 | + background: 'white', border: '2px solid #e5e7eb', borderRadius: '16px', |
| 183 | + cursor: 'pointer', textAlign: 'left', fontSize: '16px', transition: 'all 0.15s', |
| 184 | + }} |
| 185 | + onMouseEnter={e => { e.currentTarget.style.borderColor = '#93c5fd'; e.currentTarget.style.background = '#eff6ff' }} |
| 186 | + onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'white' }} |
| 187 | + > |
| 188 | + <span style={{ fontSize: '36px' }}>{m.emoji}</span> |
| 189 | + <div> |
| 190 | + <div style={{ fontWeight: 700, color: '#111827' }}>{m.label}</div> |
| 191 | + <div style={{ fontSize: '13px', color: '#6b7280' }}>{m.desc}</div> |
| 192 | + </div> |
| 193 | + </button> |
| 194 | + ))} |
| 195 | + </div> |
| 196 | + </div> |
| 197 | + ) |
| 198 | + } |
| 199 | + |
| 200 | + // ─── Result ────────────────────────────────────────────────── |
| 201 | + if (mode === 'result') { |
| 202 | + const pct = Math.round((score / ROUNDS_PER_GAME) * 100) |
| 203 | + const emoji = pct === 100 ? '🏆' : pct >= 60 ? '🌟' : '💪' |
| 204 | + return ( |
| 205 | + <div style={{ padding: '24px 16px', maxWidth: '400px', margin: '0 auto', fontFamily: font, textAlign: 'center' }}> |
| 206 | + <div style={{ fontSize: '64px', marginBottom: '8px' }}>{emoji}</div> |
| 207 | + <div style={{ fontSize: '28px', fontWeight: 700, color: '#111827' }}> |
| 208 | + {pct === 100 ? 'Perfect! Amazing!' : pct >= 60 ? 'Great job!' : 'Good try!'} |
| 209 | + </div> |
| 210 | + <div style={{ fontSize: '48px', fontWeight: 700, color: '#2563eb', margin: '16px 0' }}> |
| 211 | + {score} <span style={{ fontSize: '24px', color: '#9ca3af' }}>/ {ROUNDS_PER_GAME}</span> |
| 212 | + </div> |
| 213 | + <div style={{ display: 'flex', gap: '12px', justifyContent: 'center', marginTop: '24px', flexWrap: 'wrap' }}> |
| 214 | + <button onClick={() => startGame(gameMode)} style={{ padding: '16px 28px', fontSize: '20px', fontWeight: 700, color: 'white', background: '#3b82f6', border: 'none', borderRadius: '16px', cursor: 'pointer' }}>🔄 Play Again</button> |
| 215 | + <button onClick={() => setMode('menu')} style={{ padding: '16px 28px', fontSize: '20px', fontWeight: 700, color: 'white', background: '#6b7280', border: 'none', borderRadius: '16px', cursor: 'pointer' }}>🏠 Menu</button> |
| 216 | + </div> |
| 217 | + </div> |
| 218 | + ) |
| 219 | + } |
| 220 | + |
| 221 | + // ─── Game round ────────────────────────────────────────────── |
| 222 | + if (!question) return null |
| 223 | + |
| 224 | + return ( |
| 225 | + <div style={{ padding: '20px 16px', maxWidth: '420px', margin: '0 auto', fontFamily: font }}> |
| 226 | + {/* Progress */} |
| 227 | + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}> |
| 228 | + <span style={{ fontSize: '14px', color: '#6b7280' }}>{round + 1} / {ROUNDS_PER_GAME}</span> |
| 229 | + <span style={{ fontSize: '14px', fontWeight: 600, color: '#2563eb' }}>⭐ {score}</span> |
| 230 | + {streak >= 2 && <span style={{ fontSize: '13px', color: '#f59e0b', fontWeight: 600 }}>🔥 {streak} in a row!</span>} |
| 231 | + </div> |
| 232 | + |
| 233 | + {/* Progress bar */} |
| 234 | + <div style={{ height: '8px', background: '#e5e7eb', borderRadius: '4px', marginBottom: '20px', overflow: 'hidden' }}> |
| 235 | + <div style={{ height: '100%', background: '#8b5cf6', borderRadius: '4px', width: `${((round + (feedback ? 1 : 0)) / ROUNDS_PER_GAME) * 100}%`, transition: 'width 0.3s' }} /> |
| 236 | + </div> |
| 237 | + |
| 238 | + {/* Question */} |
| 239 | + <div style={{ fontSize: '22px', fontWeight: 700, color: '#111827', textAlign: 'center', marginBottom: '16px' }}> |
| 240 | + {question.prompt} |
| 241 | + </div> |
| 242 | + |
| 243 | + {/* Big display */} |
| 244 | + <div style={{ |
| 245 | + padding: '24px', borderRadius: '20px', background: 'white', border: '2px solid #e5e7eb', |
| 246 | + textAlign: 'center', fontSize: question.display.length <= 2 ? '80px' : '64px', |
| 247 | + marginBottom: '20px', minHeight: '120px', display: 'flex', alignItems: 'center', justifyContent: 'center', |
| 248 | + fontWeight: 700, |
| 249 | + }}> |
| 250 | + {question.display} |
| 251 | + </div> |
| 252 | + |
| 253 | + {/* Feedback */} |
| 254 | + {feedback && ( |
| 255 | + <div style={{ |
| 256 | + padding: '14px', borderRadius: '14px', marginBottom: '16px', textAlign: 'center', |
| 257 | + fontSize: '18px', fontWeight: 600, |
| 258 | + background: feedback.correct ? '#dcfce7' : '#fee2e2', |
| 259 | + color: feedback.correct ? '#166534' : '#991b1b', |
| 260 | + }}> |
| 261 | + {feedback.message} |
| 262 | + </div> |
| 263 | + )} |
| 264 | + |
| 265 | + {/* Answer buttons */} |
| 266 | + {!feedback ? ( |
| 267 | + <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}> |
| 268 | + {question.options.map((opt, i) => ( |
| 269 | + <button key={i} onClick={() => handleAnswer(opt)} style={{ |
| 270 | + padding: '18px 24px', fontSize: '22px', fontWeight: 700, |
| 271 | + color: '#111827', background: 'white', border: '3px solid #d1d5db', |
| 272 | + borderRadius: '16px', cursor: 'pointer', transition: 'all 0.1s', |
| 273 | + boxShadow: '0 2px 8px rgba(0,0,0,0.08)', textAlign: 'center', |
| 274 | + }} |
| 275 | + onMouseEnter={e => { e.currentTarget.style.borderColor = '#8b5cf6'; e.currentTarget.style.background = '#f5f3ff' }} |
| 276 | + onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.background = 'white' }} |
| 277 | + onMouseDown={e => (e.currentTarget.style.transform = 'scale(0.95)')} |
| 278 | + onMouseUp={e => (e.currentTarget.style.transform = 'scale(1)')} |
| 279 | + > |
| 280 | + {opt} |
| 281 | + </button> |
| 282 | + ))} |
| 283 | + </div> |
| 284 | + ) : ( |
| 285 | + <div style={{ textAlign: 'center' }}> |
| 286 | + <button onClick={nextRound} style={{ padding: '16px 28px', fontSize: '20px', fontWeight: 700, color: 'white', background: '#8b5cf6', border: 'none', borderRadius: '16px', cursor: 'pointer' }}> |
| 287 | + {round + 1 >= ROUNDS_PER_GAME ? '🎉 See Results!' : 'Next ➡️'} |
| 288 | + </button> |
| 289 | + </div> |
| 290 | + )} |
| 291 | + </div> |
| 292 | + ) |
| 293 | +} |
0 commit comments