|
| 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 | +// ─── Animal definitions ───────────────────────────────────────────── |
| 9 | +interface AnimalDef { |
| 10 | + emoji: string |
| 11 | + name: string |
| 12 | + sound: string |
| 13 | + habitat: string |
| 14 | + baby: string |
| 15 | +} |
| 16 | + |
| 17 | +const ANIMALS: AnimalDef[] = [ |
| 18 | + { emoji: '🐶', name: 'Dog', sound: 'Woof!', habitat: 'Farm', baby: 'Puppy' }, |
| 19 | + { emoji: '🐱', name: 'Cat', sound: 'Meow!', habitat: 'Farm', baby: 'Kitten' }, |
| 20 | + { emoji: '🐮', name: 'Cow', sound: 'Moo!', habitat: 'Farm', baby: 'Calf' }, |
| 21 | + { emoji: '🐷', name: 'Pig', sound: 'Oink!', habitat: 'Farm', baby: 'Piglet' }, |
| 22 | + { emoji: '🐔', name: 'Chicken', sound: 'Cluck!', habitat: 'Farm', baby: 'Chick' }, |
| 23 | + { emoji: '🐴', name: 'Horse', sound: 'Neigh!', habitat: 'Farm', baby: 'Foal' }, |
| 24 | + { emoji: '🐸', name: 'Frog', sound: 'Ribbit!', habitat: 'Pond', baby: 'Tadpole' }, |
| 25 | + { emoji: '🦁', name: 'Lion', sound: 'Roar!', habitat: 'Jungle', baby: 'Cub' }, |
| 26 | + { emoji: '🐘', name: 'Elephant', sound: 'Trumpet!', habitat: 'Jungle', baby: 'Calf' }, |
| 27 | + { emoji: '🐍', name: 'Snake', sound: 'Hiss!', habitat: 'Desert', baby: 'Hatchling' }, |
| 28 | + { emoji: '🐧', name: 'Penguin', sound: 'Squawk!', habitat: 'Arctic', baby: 'Chick' }, |
| 29 | + { emoji: '🐳', name: 'Whale', sound: 'Song!', habitat: 'Ocean', baby: 'Calf' }, |
| 30 | + { emoji: '🐠', name: 'Fish', sound: 'Blub!', habitat: 'Ocean', baby: 'Fry' }, |
| 31 | + { emoji: '🦊', name: 'Fox', sound: 'Yip!', habitat: 'Forest', baby: 'Kit' }, |
| 32 | + { emoji: '🐻', name: 'Bear', sound: 'Growl!', habitat: 'Forest', baby: 'Cub' }, |
| 33 | +] |
| 34 | + |
| 35 | +const ALL_SOUNDS = [...new Set(ANIMALS.map(a => a.sound))] |
| 36 | +const ALL_HABITATS = [...new Set(ANIMALS.map(a => a.habitat))] |
| 37 | +const ALL_BABIES = [...new Set(ANIMALS.map(a => a.baby))] |
| 38 | + |
| 39 | +type GameMode = 'sounds' | 'habitat' | 'baby' |
| 40 | +type Mode = 'menu' | GameMode | 'result' |
| 41 | + |
| 42 | +interface Question { |
| 43 | + animal: AnimalDef |
| 44 | + options: string[] |
| 45 | + correct: string |
| 46 | + prompt: string |
| 47 | +} |
| 48 | + |
| 49 | +function shuffle<T>(arr: T[]): T[] { |
| 50 | + 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 |
| 51 | +} |
| 52 | + |
| 53 | +function generateQuestion(gameMode: GameMode): Question { |
| 54 | + const animal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)] |
| 55 | + |
| 56 | + if (gameMode === 'sounds') { |
| 57 | + const correct = animal.sound |
| 58 | + const wrongs = shuffle(ALL_SOUNDS.filter(s => s !== correct)).slice(0, 2) |
| 59 | + return { animal, correct, options: shuffle([correct, ...wrongs]), prompt: `What sound does the ${animal.name} make?` } |
| 60 | + } |
| 61 | + |
| 62 | + if (gameMode === 'habitat') { |
| 63 | + const correct = animal.habitat |
| 64 | + const wrongs = shuffle(ALL_HABITATS.filter(h => h !== correct)).slice(0, 2) |
| 65 | + return { animal, correct, options: shuffle([correct, ...wrongs]), prompt: `Where does the ${animal.name} live?` } |
| 66 | + } |
| 67 | + |
| 68 | + // baby |
| 69 | + const correct = animal.baby |
| 70 | + const wrongs = shuffle(ALL_BABIES.filter(b => b !== correct)).slice(0, 2) |
| 71 | + return { animal, correct, options: shuffle([correct, ...wrongs]), prompt: `What is a baby ${animal.name} called?` } |
| 72 | +} |
| 73 | + |
| 74 | +const ROUNDS_PER_GAME = 5 |
| 75 | +const MODES_LIST: { id: GameMode; label: string; emoji: string; desc: string }[] = [ |
| 76 | + { id: 'sounds', label: 'Animal Sounds', emoji: '🔊', desc: 'What sound does it make?' }, |
| 77 | + { id: 'habitat', label: 'Where Do I Live?', emoji: '🏠', desc: 'Find the animal\'s home!' }, |
| 78 | + { id: 'baby', label: 'Baby Animals', emoji: '🍼', desc: 'Name the baby animal!' }, |
| 79 | +] |
| 80 | + |
| 81 | +const HABITAT_EMOJIS: Record<string, string> = { |
| 82 | + Farm: '🏡', Ocean: '🌊', Jungle: '🌴', Forest: '🌲', Arctic: '🧊', Desert: '🏜️', Pond: '🪷', |
| 83 | +} |
| 84 | + |
| 85 | +export default function AnimalsGame() { |
| 86 | + const [mode, setMode] = useState<Mode>('menu') |
| 87 | + const [gameMode, setGameMode] = useState<GameMode>('sounds') |
| 88 | + const [question, setQuestion] = useState<Question | null>(null) |
| 89 | + const [round, setRound] = useState(0) |
| 90 | + const [score, setScore] = useState(0) |
| 91 | + const [feedback, setFeedback] = useState<{ correct: boolean; message: string } | null>(null) |
| 92 | + const [streak, setStreak] = useState(0) |
| 93 | + |
| 94 | + useEffect(() => { sendToPlatform('ui_ready', '', {}) }, []) |
| 95 | + |
| 96 | + useEffect(() => { |
| 97 | + function handleMessage(event: MessageEvent) { |
| 98 | + const msg = event.data |
| 99 | + if (!msg || msg.type !== 'tool_invoke') return |
| 100 | + const { correlationId, tool } = msg |
| 101 | + if (tool === 'get_state') { |
| 102 | + sendToPlatform('tool_result', correlationId, { tool: 'get_state', mode, round, score, streak }) |
| 103 | + } else if (tool === 'restore_state') { |
| 104 | + sendToPlatform('tool_result', correlationId, { tool: 'restore_state', message: 'Restored' }) |
| 105 | + } else { |
| 106 | + sendToPlatform('error', correlationId, { message: `Unknown tool: ${tool}` }) |
| 107 | + } |
| 108 | + } |
| 109 | + window.addEventListener('message', handleMessage) |
| 110 | + return () => window.removeEventListener('message', handleMessage) |
| 111 | + }, [mode, round, score, streak]) |
| 112 | + |
| 113 | + const startGame = useCallback((m: GameMode) => { |
| 114 | + playPop() |
| 115 | + setGameMode(m) |
| 116 | + setRound(0) |
| 117 | + setScore(0) |
| 118 | + setStreak(0) |
| 119 | + setFeedback(null) |
| 120 | + setQuestion(generateQuestion(m)) |
| 121 | + setMode(m) |
| 122 | + sendToPlatform('state_update', '', { type: 'game_start', mode: m }) |
| 123 | + }, []) |
| 124 | + |
| 125 | + function handleAnswer(answer: string) { |
| 126 | + if (!question || feedback) return |
| 127 | + const correct = answer === question.correct |
| 128 | + if (correct) { |
| 129 | + setScore(s => s + 1) |
| 130 | + setStreak(s => s + 1) |
| 131 | + playCorrect() |
| 132 | + const msgs = ['Amazing! 🎉', 'You got it! ⭐', 'Wow, great job! 🌟', 'Super smart! 🧠', 'Yes! You rock! 🎸'] |
| 133 | + setFeedback({ correct: true, message: msgs[Math.floor(Math.random() * msgs.length)] }) |
| 134 | + } else { |
| 135 | + setStreak(0) |
| 136 | + playWrong() |
| 137 | + setFeedback({ correct: false, message: `The answer is ${question.correct}! Let's keep going! 💪` }) |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + function nextRound() { |
| 142 | + if (round + 1 >= ROUNDS_PER_GAME) { |
| 143 | + playCelebration() |
| 144 | + setMode('result') |
| 145 | + sendToPlatform('completion', '', { summary: `Animals game complete! Score: ${score}/${ROUNDS_PER_GAME}` }) |
| 146 | + return |
| 147 | + } |
| 148 | + setRound(r => r + 1) |
| 149 | + setFeedback(null) |
| 150 | + setQuestion(generateQuestion(gameMode)) |
| 151 | + } |
| 152 | + |
| 153 | + const font = 'system-ui, -apple-system, sans-serif' |
| 154 | + const bigBtn = (onClick: () => void, children: React.ReactNode, color = '#3b82f6'): React.ReactNode => ( |
| 155 | + <button onClick={onClick} style={{ |
| 156 | + padding: '16px 28px', fontSize: '20px', fontWeight: 700, color: 'white', |
| 157 | + background: color, border: 'none', borderRadius: '16px', cursor: 'pointer', |
| 158 | + boxShadow: '0 4px 12px rgba(0,0,0,0.15)', transition: 'transform 0.1s', |
| 159 | + minWidth: '80px', |
| 160 | + }} |
| 161 | + onMouseDown={e => (e.currentTarget.style.transform = 'scale(0.95)')} |
| 162 | + onMouseUp={e => (e.currentTarget.style.transform = 'scale(1)')} |
| 163 | + > |
| 164 | + {children} |
| 165 | + </button> |
| 166 | + ) |
| 167 | + |
| 168 | + // ─── Menu ──────────────────────────────────────────────────── |
| 169 | + if (mode === 'menu') { |
| 170 | + return ( |
| 171 | + <div style={{ padding: '24px 16px', maxWidth: '400px', margin: '0 auto', fontFamily: font, textAlign: 'center' }}> |
| 172 | + <div style={{ fontSize: '48px', marginBottom: '8px' }}>🐾</div> |
| 173 | + <div style={{ fontSize: '24px', fontWeight: 700, color: '#111827', marginBottom: '4px' }}>Animals Game</div> |
| 174 | + <div style={{ fontSize: '16px', color: '#6b7280', marginBottom: '24px' }}>Tap the right answer!</div> |
| 175 | + <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> |
| 176 | + {MODES_LIST.map(m => ( |
| 177 | + <button key={m.id} onClick={() => startGame(m.id)} style={{ |
| 178 | + display: 'flex', alignItems: 'center', gap: '16px', padding: '20px', |
| 179 | + background: 'white', border: '2px solid #e5e7eb', borderRadius: '16px', |
| 180 | + cursor: 'pointer', textAlign: 'left', fontSize: '16px', transition: 'all 0.15s', |
| 181 | + }} |
| 182 | + onMouseEnter={e => { e.currentTarget.style.borderColor = '#93c5fd'; e.currentTarget.style.background = '#eff6ff' }} |
| 183 | + onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'white' }} |
| 184 | + > |
| 185 | + <span style={{ fontSize: '36px' }}>{m.emoji}</span> |
| 186 | + <div> |
| 187 | + <div style={{ fontWeight: 700, color: '#111827' }}>{m.label}</div> |
| 188 | + <div style={{ fontSize: '13px', color: '#6b7280' }}>{m.desc}</div> |
| 189 | + </div> |
| 190 | + </button> |
| 191 | + ))} |
| 192 | + </div> |
| 193 | + </div> |
| 194 | + ) |
| 195 | + } |
| 196 | + |
| 197 | + // ─── Result ────────────────────────────────────────────────── |
| 198 | + if (mode === 'result') { |
| 199 | + const pct = Math.round((score / ROUNDS_PER_GAME) * 100) |
| 200 | + const emoji = pct === 100 ? '🏆' : pct >= 60 ? '🌟' : '💪' |
| 201 | + return ( |
| 202 | + <div style={{ padding: '24px 16px', maxWidth: '400px', margin: '0 auto', fontFamily: font, textAlign: 'center' }}> |
| 203 | + <div style={{ fontSize: '64px', marginBottom: '8px' }}>{emoji}</div> |
| 204 | + <div style={{ fontSize: '28px', fontWeight: 700, color: '#111827' }}> |
| 205 | + {pct === 100 ? 'Perfect! You did it!' : pct >= 60 ? 'Great job!' : 'Good try!'} |
| 206 | + </div> |
| 207 | + <div style={{ fontSize: '48px', fontWeight: 700, color: '#2563eb', margin: '16px 0' }}> |
| 208 | + {score} <span style={{ fontSize: '24px', color: '#9ca3af' }}>/ {ROUNDS_PER_GAME}</span> |
| 209 | + </div> |
| 210 | + <div style={{ display: 'flex', gap: '12px', justifyContent: 'center', marginTop: '24px', flexWrap: 'wrap' }}> |
| 211 | + {bigBtn(() => startGame(gameMode), '🔄 Play Again')} |
| 212 | + {bigBtn(() => setMode('menu'), '🏠 Pick New Game', '#6b7280')} |
| 213 | + </div> |
| 214 | + </div> |
| 215 | + ) |
| 216 | + } |
| 217 | + |
| 218 | + // ─── Game round ────────────────────────────────────────────── |
| 219 | + if (!question) return null |
| 220 | + |
| 221 | + return ( |
| 222 | + <div style={{ padding: '20px 16px', maxWidth: '420px', margin: '0 auto', fontFamily: font }}> |
| 223 | + {/* Progress */} |
| 224 | + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}> |
| 225 | + <span style={{ fontSize: '14px', color: '#6b7280' }}> |
| 226 | + {round + 1} / {ROUNDS_PER_GAME} |
| 227 | + </span> |
| 228 | + <span style={{ fontSize: '14px', fontWeight: 600, color: '#2563eb' }}> |
| 229 | + ⭐ {score} |
| 230 | + </span> |
| 231 | + {streak >= 2 && ( |
| 232 | + <span style={{ fontSize: '13px', color: '#f59e0b', fontWeight: 600 }}> |
| 233 | + 🔥 {streak} in a row! |
| 234 | + </span> |
| 235 | + )} |
| 236 | + </div> |
| 237 | + |
| 238 | + {/* Progress bar */} |
| 239 | + <div style={{ height: '8px', background: '#e5e7eb', borderRadius: '4px', marginBottom: '20px', overflow: 'hidden' }}> |
| 240 | + <div style={{ height: '100%', background: '#3b82f6', borderRadius: '4px', width: `${((round + (feedback ? 1 : 0)) / ROUNDS_PER_GAME) * 100}%`, transition: 'width 0.3s' }} /> |
| 241 | + </div> |
| 242 | + |
| 243 | + {/* Question prompt */} |
| 244 | + <div style={{ fontSize: '22px', fontWeight: 700, color: '#111827', textAlign: 'center', marginBottom: '16px' }}> |
| 245 | + {question.prompt} |
| 246 | + </div> |
| 247 | + |
| 248 | + {/* Animal display */} |
| 249 | + <div style={{ |
| 250 | + padding: '24px', borderRadius: '20px', background: 'white', border: '2px solid #e5e7eb', |
| 251 | + textAlign: 'center', marginBottom: '20px', |
| 252 | + minHeight: '140px', display: 'flex', alignItems: 'center', justifyContent: 'center', |
| 253 | + }}> |
| 254 | + <span style={{ fontSize: '96px' }}>{question.animal.emoji}</span> |
| 255 | + </div> |
| 256 | + |
| 257 | + {/* Feedback */} |
| 258 | + {feedback && ( |
| 259 | + <div style={{ |
| 260 | + padding: '14px', borderRadius: '14px', marginBottom: '16px', textAlign: 'center', |
| 261 | + fontSize: '18px', fontWeight: 600, |
| 262 | + background: feedback.correct ? '#dcfce7' : '#fee2e2', |
| 263 | + color: feedback.correct ? '#166534' : '#991b1b', |
| 264 | + }}> |
| 265 | + {feedback.message} |
| 266 | + </div> |
| 267 | + )} |
| 268 | + |
| 269 | + {/* Answer buttons */} |
| 270 | + {!feedback ? ( |
| 271 | + <div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}> |
| 272 | + {question.options.map((opt, i) => { |
| 273 | + // For habitat mode, show emoji + label |
| 274 | + if (gameMode === 'habitat') { |
| 275 | + const habitatEmoji = HABITAT_EMOJIS[opt] || '🏠' |
| 276 | + return ( |
| 277 | + <button key={i} onClick={() => handleAnswer(opt)} style={{ |
| 278 | + minWidth: '90px', height: '90px', fontSize: '14px', fontWeight: 700, |
| 279 | + color: '#111827', background: 'white', border: '3px solid #d1d5db', |
| 280 | + borderRadius: '16px', cursor: 'pointer', transition: 'all 0.1s', |
| 281 | + boxShadow: '0 2px 8px rgba(0,0,0,0.08)', display: 'flex', flexDirection: 'column', |
| 282 | + alignItems: 'center', justifyContent: 'center', gap: '4px', padding: '8px', |
| 283 | + }} |
| 284 | + onMouseEnter={e => { e.currentTarget.style.borderColor = '#3b82f6'; e.currentTarget.style.background = '#eff6ff' }} |
| 285 | + onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.background = 'white' }} |
| 286 | + onMouseDown={e => (e.currentTarget.style.transform = 'scale(0.9)')} |
| 287 | + onMouseUp={e => (e.currentTarget.style.transform = 'scale(1)')} |
| 288 | + > |
| 289 | + <span style={{ fontSize: '32px' }}>{habitatEmoji}</span> |
| 290 | + <span>{opt}</span> |
| 291 | + </button> |
| 292 | + ) |
| 293 | + } |
| 294 | + |
| 295 | + return ( |
| 296 | + <button key={i} onClick={() => handleAnswer(opt)} style={{ |
| 297 | + minWidth: '80px', height: '80px', fontSize: '18px', fontWeight: 700, |
| 298 | + color: '#111827', background: 'white', border: '3px solid #d1d5db', |
| 299 | + borderRadius: '16px', cursor: 'pointer', transition: 'all 0.1s', |
| 300 | + boxShadow: '0 2px 8px rgba(0,0,0,0.08)', padding: '8px 16px', |
| 301 | + display: 'flex', alignItems: 'center', justifyContent: 'center', |
| 302 | + }} |
| 303 | + onMouseEnter={e => { e.currentTarget.style.borderColor = '#3b82f6'; e.currentTarget.style.background = '#eff6ff' }} |
| 304 | + onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.background = 'white' }} |
| 305 | + onMouseDown={e => (e.currentTarget.style.transform = 'scale(0.9)')} |
| 306 | + onMouseUp={e => (e.currentTarget.style.transform = 'scale(1)')} |
| 307 | + > |
| 308 | + {opt} |
| 309 | + </button> |
| 310 | + ) |
| 311 | + })} |
| 312 | + </div> |
| 313 | + ) : ( |
| 314 | + <div style={{ textAlign: 'center' }}> |
| 315 | + {bigBtn(nextRound, round + 1 >= ROUNDS_PER_GAME ? '🎉 See Results!' : 'Next ➡️')} |
| 316 | + </div> |
| 317 | + )} |
| 318 | + </div> |
| 319 | + ) |
| 320 | +} |
0 commit comments