const { useState, useEffect, useRef } = React; const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); const ROW_LAYOUT = [6, 8, 8, 4]; const API_URL = "game_server.php"; const MOVES_PER_TURN = 4; const BLOCK_COLORS = ALPHABET.map((_, i) => [ "#E84B3A", "#F07A2A", "#F5C518", "#4CAF50", "#2196F3", "#9C27B0", "#00BCD4", "#FF5722", "#E91E63", "#607D8B", "#795548", "#009688", "#3F51B5", "#8BC34A", "#FF9800", "#F44336", "#673AB7", "#03A9F4", "#CDDC39", "#FFC107", "#76FF03", "#00E5FF", "#FF4081", "#AA00FF", "#FFAB40", "#69F0AE" ][i % 26]); const FALLEN_STYLES = ALPHABET.map((_, i) => { const rotation = (Math.random() - 0.5) * 70; return { rotate: rotation, spellingRotate: rotation * 0.4, dx: (Math.random() - 0.5) * 25, dy: (Math.random() - 0.5) * 15 + 5, scale: 0.85 + Math.random() * 0.15, }; }); function darken(hex) { if (!hex) return "#000"; const num = parseInt(hex.replace("#", ""), 16); const r = Math.max(0, ((num >> 16) & 0xff) - 50); const g = Math.max(0, ((num >> 8) & 0xff) - 50); const b = Math.max(0, (num & 0xff) - 50); return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`; } const playSound = (fileName) => { try { const audio = new Audio(`sounds/${fileName}`); audio.play().catch(() => { }); // catch and ignore if browser blocks autoplay } catch (e) { } }; // Cache the best available English voice once the browser loads its voice list let _bestVoice = null; function getBestVoice() { if (_bestVoice) return _bestVoice; const voices = window.speechSynthesis.getVoices(); if (!voices.length) return null; // Priority: Google US English > Google English > Microsoft (neural) > any en-US > any en const priority = [ v => v.name === 'Google US English', v => v.name.startsWith('Google') && v.lang.startsWith('en'), v => v.name.startsWith('Microsoft') && v.lang === 'en-US' && v.name.includes('Natural'), v => v.name.startsWith('Microsoft') && v.lang === 'en-US', v => v.lang === 'en-US', v => v.lang.startsWith('en'), ]; for (const test of priority) { const found = voices.find(test); if (found) { _bestVoice = found; return found; } } return null; } // Warm up the voice cache as soon as voices are ready if (window.speechSynthesis) { window.speechSynthesis.onvoiceschanged = () => { _bestVoice = null; getBestVoice(); }; getBestVoice(); } const playTTS = (text) => { if (!window.speechSynthesis) return; window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = "en-US"; const voice = getBestVoice(); if (voice) utterance.voice = voice; window.speechSynthesis.speak(utterance); }; async function safeFetch(url) { try { const res = await fetch(url + "&_t=" + Date.now()); const contentType = res.headers.get("content-type"); if (!contentType || !contentType.includes("application/json")) return { status: "error" }; return await res.json(); } catch (err) { return { status: "error" }; } } function Cloud({ x, y, scale, speed }) { return (
{/* Fluffy white ellipses with flat bottom structure */}
); } function Toys() { return (
{/* Green Block */}
{/* Red Block A */}
{/* Yellow Triangle */} {/* Blue Circle Block */}
); } function Star({ x, y, size, delay = 0 }) { return (
); } function MonkeyBubble({ visible, message }) { return (
{message}
); } function FancyTitle({ style }) { const letters1 = [ { char: 'A', color: "#FF453A" }, { char: 'L', color: "#FF9F0A" }, { char: 'P', color: "#FFD60A" }, { char: 'H', color: "#4CAF50" }, { char: 'A', color: "#32ADE6" }, { char: 'B', color: "#007AFF" }, { char: 'E', color: "#AF52DE" }, { char: 'T', color: "#FF2D55" } ]; const letters2 = [ { char: 'S', color: "#FF453A" }, { char: 'O', color: "#FF9F0A" }, { char: 'R', color: "#FFD60A" }, { char: 'T', color: "#4CAF50" }, { char: 'E', color: "#32ADE6" }, { char: 'R', color: "#AF52DE" } ]; const renderRow = (arr) => (
{arr.map((l, i) => ( {l.char} ))}
); return (
{renderRow(letters1)} {renderRow(letters2)}
A Game for Young Explorers!
); } function Balloons({ style, isPlaying, isDragging }) { // Float higher out of the way on mobile if we're actively playing and dragging const shiftUp = isPlaying && isDragging; return ( ); } function Lobby({ onJoin }) { const [name, setName] = useState(""); const [room, setRoom] = useState(""); const [isInvite, setIsInvite] = useState(false); const [vocabData, setVocabData] = useState([]); const [levels, setLevels] = useState([]); const [selectedMode, setSelectedMode] = useState(null); // null, 'alphabet', or 'spelling' useEffect(() => { const urlParams = new URL(window.location.href).searchParams; const r = urlParams.get('room'); if (r) { setRoom(r.toUpperCase()); setIsInvite(true); } // Fetch Eiken vocab Promise.all([ fetch('vocab/EIKEN-5-vocab.json').then(r => r.json()), fetch('vocab/EIKEN-4-vocab.json').then(r => r.json()) ]).then(([eiken5, eiken4]) => { const combinedVocab = [...(eiken5.grade_5_vocab || []), ...(eiken4.grade_4_vocab || [])]; setVocabData(combinedVocab); // Group into levels of 15 words const numLevels = Math.ceil(combinedVocab.length / 15); const newLevels = Array.from({ length: numLevels }, (_, i) => ({ id: i + 1, words: combinedVocab.slice(i * 15, (i + 1) * 15) })); setLevels(newLevels); }).catch(err => console.error("Failed to load vocab:", err)); }, []); const handleJoin = async (levelId = null, forcedMode = null) => { if (!name.trim()) return alert("Enter name!"); let targetRoom = room.toUpperCase(); // Creating a new room if (forcedMode === 'alphabet' || levelId !== null) { if (forcedMode === 'alphabet') { const data = await safeFetch(`${API_URL}?action=create&mode=alphabet`); if (data.room) targetRoom = data.room; else return; } else if (levelId !== null) { const selectedLevel = levels.find(l => l.id === levelId); if (!selectedLevel) return; // Pick 10 random words const shuffled = [...selectedLevel.words].sort(() => 0.5 - Math.random()); const selectedWords = shuffled.slice(0, 10).map(w => ({ word: w.word, japanese: w.japanese })); targetRoom = `LVL${levelId}`; const firstWord = selectedWords[0].word; const data = await safeFetch(`${API_URL}?action=create&custom_room=${targetRoom}&mode=spelling&word=${encodeURIComponent(firstWord)}&words=${encodeURIComponent(JSON.stringify(selectedWords))}`); if (data.room) targetRoom = data.room; else return; } } else { if (!targetRoom) return alert("Enter Room Code!"); } onJoin(name, targetRoom); }; return (
setName(e.target.value)} style={{ width: "100%", padding: 14, borderRadius: 12, border: "2px solid #ddd", marginBottom: 10, fontSize: 16, textAlign: "center" }} /> {!isInvite ? ( <> setRoom(e.target.value.toUpperCase())} style={{ width: "100%", padding: 14, borderRadius: 12, border: "2px solid #ddd", marginBottom: 20, fontSize: 16, textAlign: "center" }} />

Create New Game

{!selectedMode && (
)} {selectedMode === 'spelling' && levels.length > 0 && (

Select Level

{levels.map(level => ( ))}
)} ) : (
)}
); } function AlphabetGame() { const [user, setUser] = useState(null); const [game, setGame] = useState(null); const [dragItem, setDragItem] = useState(null); const [highlightSlot, setHighlightSlot] = useState(null); const [introPhase, setIntroPhase] = useState("idle"); const [shakeSlot, setShakeSlot] = useState(null); const [fallingLetter, setFallingLetter] = useState(null); const [copied, setCopied] = useState(false); useEffect(() => { if (!user) return; const poll = setInterval(async () => { const data = await safeFetch(`${API_URL}?action=state&room=${user.room}`); if (data.status !== "error") { setGame(prev => { if (data.phase === 'intro' && (!prev || prev.phase !== 'intro')) triggerIntroAnimation(data); return data; }); } }, 1500); return () => clearInterval(poll); }, [user]); const prevPhaseRef = useRef(null); const prevWordIndexRef = useRef(null); useEffect(() => { if (!game || !user) return; // Auto-TTS for spelling mode when a new word appears in the intro or playing phase if (game.mode === 'spelling' && (game.phase === 'intro' || game.phase === 'playing') && game.words_list) { if (prevWordIndexRef.current !== game.current_word_index) { const currentWord = game.words_list[game.current_word_index]?.word; if (currentWord) { playTTS(currentWord); prevWordIndexRef.current = game.current_word_index; } } } // Handle sounds that trigger on state change if (prevPhaseRef.current !== game.phase && game.phase === 'word_complete') { playSound('correct sound.mp3'); } if (prevPhaseRef.current !== 'game_over' && game.game_over) { playSound('end game.mp3'); } prevPhaseRef.current = game.game_over ? 'game_over' : game.phase; if (game.phase === 'word_complete' && user.playerId === 0) { const timer = setTimeout(() => { safeFetch(`${API_URL}?action=next_word&room=${user.room}`); }, 4000); return () => clearTimeout(timer); } }, [game?.phase, game?.game_over, game?.current_word_index, user?.playerId, user?.room]); const triggerIntroAnimation = async (data) => { playSound('start game.mp3'); setIntroPhase("intro_showing"); await new Promise(r => setTimeout(r, 2000)); setIntroPhase("intro_shaking"); await new Promise(r => setTimeout(r, 800)); setIntroPhase("intro_falling"); await new Promise(r => setTimeout(r, 1500)); setIntroPhase("idle"); await safeFetch(`${API_URL}?action=start_playing&room=${user.room}`); }; // --- Unified Pointer Events --- const handlePointerDown = (e, letter, index) => { if (!game || game.turn_index !== user.playerId || game.phase !== 'playing') return; setDragItem({ letter, index, x: e.clientX, y: e.clientY }); const el = e.currentTarget; el.setPointerCapture(e.pointerId); }; const handlePointerMove = (e) => { if (!dragItem) return; setDragItem(prev => ({ ...prev, x: e.clientX, y: e.clientY })); // Find closest slot within leniency radius const slots = document.querySelectorAll('.slot'); let closestSlot = null; let minDistance = 60; // Leniency radius in pixels (very forgiving) slots.forEach(slot => { const rect = slot.getBoundingClientRect(); const slotCenterX = rect.left + rect.width / 2; const slotCenterY = rect.top + rect.height / 2; const dist = Math.hypot(slotCenterX - e.clientX, slotCenterY - e.clientY); if (dist < minDistance) { minDistance = dist; const slotIdx = slot.getAttribute("data-slot-idx"); if (slotIdx !== null) closestSlot = parseInt(slotIdx); } }); setHighlightSlot(closestSlot); }; const handlePointerUp = async (e) => { if (!dragItem) return; const { letter, index, x, y } = dragItem; const targetSlot = highlightSlot; setDragItem(null); setHighlightSlot(null); if (targetSlot !== null && game.slots[targetSlot] === null) { const data = await safeFetch(`${API_URL}?action=move&room=${user.room}&letter=${letter}&slot_idx=${targetSlot}&tray_idx=${index}&player_id=${user.playerId}`); if (data.status === "ok") { setGame(data); const targetLetters = data.target_letters || ALPHABET; if (letter !== targetLetters[targetSlot]) { setFallingLetter({ letter, x, y }); setTimeout(() => setFallingLetter(null), 1250); setShakeSlot(targetSlot); setTimeout(() => setShakeSlot(null), 800); } else { // It was correct if (game.mode !== 'spelling') { playSound('correct sound.mp3'); } } } } }; const handleHint = async () => { if (!game || game.turn_index !== user.playerId || game.phase !== 'playing') return; const data = await safeFetch(`${API_URL}?action=move&room=${user.room}&is_hint=true&player_id=${user.playerId}`); if (data.status === "ok") { setGame(data); } }; const handleJoin = async (name, room) => { const data = await safeFetch(`${API_URL}?action=join&name=${encodeURIComponent(name)}&room=${room}`); if (data.player_id !== undefined) { setUser({ name, room, playerId: data.player_id }); window.history.pushState(null, "", "?room=" + room); } else alert(data.error); }; if (!user) return ; if (!game) return
; const myTurn = game.turn_index === user.playerId && game.phase === 'playing'; const isIntro = introPhase !== "idle" || game.phase === 'intro'; return (
{/* Balloons move dynamically out of the way when the user drags a letter */} {game.phase === 'playing' && }
{/* Header */}
{user.room}
{game.players.map((p, i) => (
{p.name}: {p.score}
))}
{/* Fancy Title fades entirely away during gameplay so it doesn't block the screen */}
{game.phase === 'lobby' && (
{user.playerId === 0 ? ( <>
Send this code to your friend:
{user.room}
) : (
Waiting for host to start game...
)}
)} {/* Board Grid */} {game.mode === 'spelling' && game.words_list && game.words_list[game.current_word_index] && (
{game.words_list[game.current_word_index].japanese}
)}
{(() => { const targetLetters = game.target_letters || ALPHABET; const totalSlots = targetLetters.length; // If spelling mode, calculate laylout dynamically based on word length const isSpelling = (game.mode === 'spelling'); const layout = isSpelling ? [totalSlots] : ROW_LAYOUT; return layout.map((count, ri) => { const start = layout.slice(0, ri).reduce((a, b) => a + b, 0); return (
{Array.from({ length: count }, (_, ci) => { const si = start + ci; if (si >= totalSlots) return null; // Safety check const letter = game.slots[si]; const targetLetter = targetLetters[si]; const isHighlight = highlightSlot === si; const isFalling = introPhase === "intro_falling"; const isShaking = shakeSlot === si; return (
{!letter && game.mode !== 'spelling' && {targetLetter}} {letter && (
{letter}
)}
); })}
); }); })()}
{myTurn && game.players.length > 1 &&
{game.mode === 'spelling' ? 'Your Turn to Spell!' : `Your Turn: ${game.moves_left} left`}
} {myTurn && game.mode === 'spelling' && ( )}
{/* Shared Tray (Optimized for all 26) */}
{(game.tray || []).map((l, i) => { let alphaIdx = ALPHABET.indexOf(l); if (alphaIdx === -1) alphaIdx = 0; // fallback const style = FALLEN_STYLES[alphaIdx]; const isDragging = dragItem?.index === i; return (
handlePointerDown(e, l, i)} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} className="block" style={{ width: clamp(44, 9, 64), height: clamp(44, 9, 64), fontSize: clamp(20, 4.5, 30), background: BLOCK_COLORS[alphaIdx], boxShadow: `0 5px 0 ${darken(BLOCK_COLORS[alphaIdx])}`, cursor: myTurn ? "grab" : "default", opacity: (myTurn || isIntro) ? 1 : 0.6, transform: isDragging ? "scale(1.15) rotate(5deg) scale(0)" : introPhase === "intro_falling" ? "scale(0)" : (game.mode === 'alphabet' ? `translate(${style.dx}px, ${style.dy}px) rotate(${style.rotate}deg) scale(${style.scale})` : `rotate(${style.spellingRotate}deg) translateY(${style.dy}px) scale(${style.scale})`), transition: isDragging ? "none" : "transform 0.3s ease-out", margin: "4px" }}>{l}
); })} {(game.tray || []).length === 0 && !game.game_over && game.phase === 'playing' &&
WAITING...
}
{/* Dragging Overlay */} {dragItem && (
{dragItem.letter}
)} {/* Falling Incorrect Letter Overlay */} {fallingLetter && (
{fallingLetter.letter}
)} {/* Word Complete Modal */} {game.phase === 'word_complete' && (

{game.words_list[game.current_word_index]?.word || game.words_list[game.current_word_index]}

{game.words_list[game.current_word_index]?.japanese}

{game.players.length > 1 ? `Get ready ${game.players[game.turn_index]?.name}, your turn is next!` : "Get ready, next word is coming..."}

)} {/* Victory Modal */} {game.game_over && (
👑

Mission Complete!

{game.message}

{[...game.players].sort((a, b) => b.score - a.score).map((p, i) => (
{i === 0 ? "🥇 " : ""}{p.name} {p.score} pts
))}
)}
); } function clamp(min, v, max) { return `clamp(${min}px, ${v}vmin, ${max}px)`; } const rootElement = document.getElementById('root'); if (rootElement) ReactDOM.createRoot(rootElement).render();