// MODIBU - Main App (Game Engine) const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useCallback: useCallbackA } = React; const PLAYER_COLORS = ['red', 'blue', 'green', 'yellow']; const PLAYER_ICONS = ['/car.png', '/cruise.png', '/motorbike.png', '/train.png']; const PLAYER_NAMES_ID = ['Merah', 'Biru', 'Hijau', 'Kuning']; // Generate session code function generateCode() { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; return 'MDB-' + Array.from({length:3}, () => chars[Math.floor(Math.random()*chars.length)]).join(''); } // Shuffle helper function shuffle(arr) { 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; } function App() { // ===== Role & Flow ===== const [role, setRole] = useStateA(null); // 'siswa' | 'guru' const [screen, setScreen] = useStateA('landing'); const [sessionCode, setSessionCode] = useStateA(generateCode()); // ===== Player Setup ===== const [playerNames, setPlayerNames] = useStateA(['', '', '', '']); const [players, setPlayers] = useStateA([ { id: 1, name: 'Pemain 1', color: 'red', score: 0, position: 0, icon: '/car.png', isInJail: false, jailTurns: 0, skipTurn: false, extraTurn: false, laps: 0, stats: { edukasi: 0, fakta: 0, tantangan: 0, refleksi: 0, kesempatan: 0, hukuman: 0 } }, { id: 2, name: 'Pemain 2', color: 'blue', score: 0, position: 0, icon: '/cruise.png', isInJail: false, jailTurns: 0, skipTurn: false, extraTurn: false, laps: 0, stats: { edukasi: 0, fakta: 0, tantangan: 0, refleksi: 0, kesempatan: 0, hukuman: 0 } }, { id: 3, name: 'Pemain 3', color: 'green', score: 0, position: 0, icon: '/motorbike.png', isInJail: false, jailTurns: 0, skipTurn: false, extraTurn: false, laps: 0, stats: { edukasi: 0, fakta: 0, tantangan: 0, refleksi: 0, kesempatan: 0, hukuman: 0 } }, { id: 4, name: 'Pemain 4', color: 'yellow', score: 0, position: 0, icon: '/train.png', isInJail: false, jailTurns: 0, skipTurn: false, extraTurn: false, laps: 0, stats: { edukasi: 0, fakta: 0, tantangan: 0, refleksi: 0, kesempatan: 0, hukuman: 0 } }, ]); // ===== Game State ===== const [currentTurnIdx, setCurrentTurnIdx] = useStateA(0); const [diceValues, setDiceValues] = useStateA([1, 1]); const [isRolling, setIsRolling] = useStateA(false); const [isAnimating, setIsAnimating] = useStateA(false); const [activeModal, setActiveModal] = useStateA(null); const [currentQuestion, setCurrentQuestion] = useStateA(null); const [currentCard, setCurrentCard] = useStateA(null); const [gameStatus, setGameStatus] = useStateA('waiting'); // waiting | playing | paused | finished const [round, setRound] = useStateA(1); const [gradingQueue, setGradingQueue] = useStateA([]); const [waitingForGrade, setWaitingForGrade] = useStateA(false); const [maxRounds, setMaxRounds] = useStateA(2); const [currentRoom, setCurrentRoom] = useStateA(null); const [gameLog, setGameLog] = useStateA([]); const [isLoaded, setIsLoaded] = useStateA(false); // Ref to prevent sync loops const lastSyncRef = useRefA(null); // ===== Real-time Sync (Listen) ===== useEffectA(() => { if (!currentRoom || !window.EchoInstance) return; const channelName = 'room.' + currentRoom.code; console.log('App: Listening for updates on', channelName); window.EchoInstance.on(channelName, 'room.updated', (data) => { console.log('App: room.updated event received', data); // Avoid syncing if the data is exactly what we just sent const dataStr = JSON.stringify({ p: data.players, s: data.game_state, st: data.status }); if (dataStr === lastSyncRef.current) return; console.log('App: Received remote update', data); lastSyncRef.current = dataStr; if (data.players) { if (role === 'siswa') { // Students: only merge scores from server, keep local positions/state setPlayers(prev => prev.map((localP, i) => { const serverP = data.players[i]; if (!serverP) return localP; if (serverP.score !== localP.score) { console.log(`App: WS synced score for ${localP.name}: ${localP.score} → ${serverP.score}`); return { ...localP, score: serverP.score }; } return localP; })); } else { // Guru: accept full player data (positions come from students) setPlayers(data.players); } } if (data.game_state) { // Only sync grading_queue (teacher clears items after grading) if (data.game_state.grading_queue !== undefined) setGradingQueue(data.game_state.grading_queue); if (data.game_state.game_log !== undefined) setGameLog(data.game_state.game_log); // SYNC MODALS (Triggered by Guru) if (data.game_state.active_modal !== undefined) setActiveModal(data.game_state.active_modal); if (data.game_state.current_question !== undefined) setCurrentQuestion(data.game_state.current_question); if (data.game_state.current_card !== undefined) setCurrentCard(data.game_state.current_card); // For guru: also sync positions, turns, dice from students if (role === 'guru') { if (data.game_state.current_turn_idx !== undefined) setCurrentTurnIdx(data.game_state.current_turn_idx); if (data.game_state.round !== undefined) setRound(data.game_state.round); if (data.game_state.dice !== undefined) setDiceValues(data.game_state.dice); } // Sync question pools for both if (data.game_state.edukasi_idx) setEdukasiPool(data.game_state.edukasi_idx); if (data.game_state.fakta_idx) setFaktaPool(data.game_state.fakta_idx); if (data.game_state.tantangan_idx) setTantanganPool(data.game_state.tantangan_idx); if (data.game_state.refleksi_idx) setRefleksiPool(data.game_state.refleksi_idx); if (data.game_state.kesempatan_idx) setKesempatanPool(data.game_state.kesempatan_idx); if (data.game_state.hukuman_idx) setHukumanPool(data.game_state.hukuman_idx); } if (data.status) { setGameStatus(data.status); if (data.status === 'finished') { setScreen('hasil'); setActiveModal(null); } } }); // Dedicated finish listener — uses minimal payload, never blocked by size or dedup logic window.EchoInstance.on(channelName, 'room.finished', (data) => { console.log('App: room.finished received! Event Data:', data); setGameStatus('finished'); setScreen('hasil'); setActiveModal(null); }); return () => { if (window.EchoInstance) window.EchoInstance.leave(channelName); }; }, [currentRoom?.code ? currentRoom.code.toUpperCase() : null]); // ===== Polling Fallback (Score-only sync for students) ===== useEffectA(() => { if (!currentRoom || gameStatus === 'finished') return; const interval = setInterval(async () => { try { const res = await window.RoomAPI.getRoom(currentRoom.code.toUpperCase()); if (!res.success) return; if (res.room.status === 'finished') { console.log('App: Polling detected game finished.'); setGameStatus('finished'); setScreen('hasil'); setActiveModal(null); return; } // For students: only merge SCORES and grading_queue from server // Never overwrite positions, turns, or other local game state if (role === 'siswa' && res.room.players) { const serverPlayers = res.room.players; setPlayers(prev => prev.map((localP, i) => { const serverP = serverPlayers[i]; if (!serverP) return localP; // Only take the score from server if it's higher (Guru gave points) const serverScore = serverP.score || 0; if (serverScore !== localP.score) { console.log(`App: Polling synced score for ${localP.name}: ${localP.score} → ${serverScore}`); return { ...localP, score: serverScore }; } return localP; })); // Sync grading queue from server (guru removes items after grading) if (res.room.game_state?.grading_queue !== undefined) { setGradingQueue(res.room.game_state.grading_queue); } // Sync active modal (guru triggers material) if (res.room.game_state?.active_modal !== undefined) { setActiveModal(res.room.game_state.active_modal); } } } catch (err) { console.warn('App: Polling fallback failed', err); } }, 5000); // Check every 5 seconds return () => clearInterval(interval); }, [currentRoom?.code, gameStatus, role]); // ===== Push State to Server (Both Guru and Siswa) ===== useEffectA(() => { if (!currentRoom || !window.RoomAPI) return; if (gameStatus !== 'playing' && gameStatus !== 'finished') return; const stateToSync = { status: gameStatus, players: players, game_state: { current_turn_idx: currentTurnIdx, round: round, dice: diceValues, grading_queue: gradingQueue, game_log: gameLog, active_modal: activeModal, current_question: currentQuestion, current_card: currentCard, edukasi_idx: edukasiPool, fakta_idx: faktaPool, tantangan_idx: tantanganPool, refleksi_idx: refleksiPool, kesempatan_idx: kesempatanPool, hukuman_idx: hukumanPool } }; const stateStr = JSON.stringify({ p: stateToSync.players, s: stateToSync.game_state, st: gameStatus }); if (stateStr === lastSyncRef.current) return; // Debounce sync to avoid spamming API const timer = setTimeout(() => { console.log(`App: ${role} pushing state update...`); lastSyncRef.current = stateStr; window.RoomAPI.updateState(currentRoom.code, stateToSync).catch(e => console.error('Push error:', e)); }, 500); return () => clearTimeout(timer); }, [players, currentTurnIdx, round, diceValues, gradingQueue, gameLog, gameStatus, currentRoom?.code, role, activeModal, currentQuestion, currentCard, edukasiPool, faktaPool, tantanganPool, refleksiPool, kesempatanPool, hukumanPool]); // ===== Manual Broadcast (Bypass Debounce for Critical Actions) ===== const broadcastUpdate = useCallbackA((customPlayers, customQueue) => { if (!currentRoom || !window.RoomAPI) return; const stateToSync = { status: gameStatus, players: customPlayers || players, game_state: { current_turn_idx: currentTurnIdx, round: round, dice: diceValues, grading_queue: customQueue || gradingQueue, game_log: gameLog, active_modal: customModal || activeModal, current_question: currentQuestion, current_card: currentCard, edukasi_idx: edukasiPool, fakta_idx: faktaPool, tantangan_idx: tantanganPool, refleksi_idx: refleksiPool, kesempatan_idx: kesempatanPool, hukuman_idx: hukumanPool } }; const stateStr = JSON.stringify({ p: stateToSync.players, s: stateToSync.game_state, st: gameStatus }); lastSyncRef.current = stateStr; console.log('App: Immediate broadcast push...'); window.RoomAPI.updateState(currentRoom.code, stateToSync).catch(e => console.error('Broadcast error:', e)); }, [players, currentTurnIdx, round, diceValues, gradingQueue, gameLog, gameStatus, currentRoom, activeModal, currentQuestion, currentCard, edukasiPool, faktaPool, tantanganPool, refleksiPool, kesempatanPool, hukumanPool]); // ===== Question Pools (indices only to save bandwidth) ===== const [edukasiPool, setEdukasiPool] = useStateA(() => shuffle((window.ALL_EDUKASI || []).map((_, i) => i))); const [faktaPool, setFaktaPool] = useStateA(() => shuffle((window.ALL_FAKTA || []).map((_, i) => i))); const [tantanganPool, setTantanganPool] = useStateA(() => shuffle((window.ALL_TANTANGAN || []).map((_, i) => i))); const [refleksiPool, setRefleksiPool] = useStateA(() => shuffle((window.ALL_REFLEKSI || []).map((_, i) => i))); const [kesempatanPool, setKesempatanPool] = useStateA(() => shuffle((window.ALL_KARTU_KESEMPATAN || []).map((_, i) => i))); const [hukumanPool, setHukumanPool] = useStateA(() => shuffle((window.ALL_KARTU_HUKUMAN || []).map((_, i) => i))); // ===== Grading Queue (for guru) ===== const addLog = (msg) => setGameLog(prev => [{ time: new Date().toLocaleTimeString('id-ID', {hour:'2-digit',minute:'2-digit'}), msg }, ...prev].slice(0, 50)); // ===== Persistence ===== useEffectA(() => { const saved = localStorage.getItem('modibu_state'); if (saved) { try { const parsed = JSON.parse(saved); if (parsed.role) setRole(parsed.role); if (parsed.screen) setScreen(parsed.screen); if (parsed.sessionCode) setSessionCode(parsed.sessionCode); if (parsed.players) setPlayers(parsed.players); if (parsed.currentTurnIdx !== undefined) setCurrentTurnIdx(parsed.currentTurnIdx); if (parsed.gameStatus) setGameStatus(parsed.gameStatus); if (parsed.round) setRound(parsed.round); if (parsed.gradingQueue) setGradingQueue(parsed.gradingQueue); if (parsed.gameLog) setGameLog(parsed.gameLog); if (parsed.currentRoom?.code && window.RoomAPI) { // Validate with server window.RoomAPI.getRoom(parsed.currentRoom.code).then(res => { if (res.success && res.room.status !== 'finished') { setCurrentRoom(res.room); } else { handleResetSilent(); } setIsLoaded(true); }).catch(() => { handleResetSilent(); setIsLoaded(true); }); return; // Wait for server before setting isLoaded } } catch (e) { console.error("Failed to load state", e); handleResetSilent(); } } setIsLoaded(true); }, []); useEffectA(() => { if (!isLoaded) return; const state = { role, screen, sessionCode, players, currentTurnIdx, gameStatus, round, gradingQueue, gameLog, currentRoom }; localStorage.setItem('modibu_state', JSON.stringify(state)); }, [role, screen, sessionCode, players, currentTurnIdx, gameStatus, round, gradingQueue, gameLog, currentRoom, isLoaded]); const handleResetSilent = () => { localStorage.removeItem('modibu_state'); setScreen('landing'); setRole(null); setCurrentRoom(null); }; const handleReset = () => { if (confirm('Mereset aplikasi akan menghapus semua data tersimpan. Lanjut?')) { localStorage.removeItem('modibu_state'); window.location.reload(); } }; const [renderError, setRenderError] = useStateA(null); // ===== Pool Picker (cycles through indices) ===== const pickFromPool = (indices, setIndices, allItems) => { if (indices.length === 0) { const reshuffled = shuffle((allItems || []).map((_, i) => i)); setIndices(reshuffled.slice(1)); return allItems[reshuffled[0]]; } const idx = indices[0]; setIndices(indices.slice(1)); return allItems[idx]; }; // ===== Landing → Role ===== const handlePickRole = (r) => { setRole(r); if (r === 'guru') { setScreen('teacher-lobby'); } else { setScreen('room-code-entry'); } }; // ===== Room → Start Game (triggered by API room data) ===== const handleStartGame = (roomData) => { console.log('App: Starting game with data:', roomData); if (!roomData) return; // roomData can be either a room API object or a names array (legacy) let newPlayers; if (Array.isArray(roomData)) { // Legacy: called with names array newPlayers = roomData.map((name, i) => ({ id: i + 1, name: name || `Pemain ${i+1}`, color: PLAYER_COLORS[i], score: 0, position: 0, icon: PLAYER_ICONS[i], isInJail: false, jailTurns: 0, skipTurn: false, extraTurn: false, laps: 0, stats: { edukasi: 0, fakta: 0, tantangan: 0, refleksi: 0, kesempatan: 0, hukuman: 0 }, })); } else { // From room API: roomData is the room object const serverPlayers = roomData.players || []; newPlayers = serverPlayers.map((p, i) => ({ id: i + 1, name: p.name || `Pemain ${i+1}`, color: p.color || PLAYER_COLORS[i], score: p.score || 0, position: p.position || 0, icon: p.icon || PLAYER_ICONS[i], isInJail: !!p.isInJail, jailTurns: p.jailTurns || 0, skipTurn: !!p.skipTurn, extraTurn: !!p.extraTurn, laps: p.laps || 0, stats: p.stats || { edukasi: 0, fakta: 0, tantangan: 0, refleksi: 0, kesempatan: 0, hukuman: 0 }, })); if (roomData.code) { setCurrentRoom(roomData); setSessionCode(roomData.code); } } setPlayers(newPlayers); setCurrentTurnIdx(0); setGameStatus('playing'); setRound(1); setGameLog([]); setGradingQueue([]); addLog(`Permainan dimulai! Giliran ${newPlayers[0]?.name}.`); if (role === 'guru') { setScreen('guru'); } else { setScreen('board'); } }; // ===== Dice Roll & Animated Movement ===== const handleRoll = () => { if (isRolling || isAnimating || gameStatus !== 'playing') return; const player = players[currentTurnIdx]; // Check jail if (player.isInJail) { setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, isInJail: false, jailTurns: 0 } : p)); addLog(`${player.name} keluar dari penjara.`); doAdvanceTurn(); return; } // Check skip if (player.skipTurn) { setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, skipTurn: false } : p)); addLog(`${player.name} kehilangan giliran.`); doAdvanceTurn(); return; } setIsRolling(true); const d1 = Math.floor(Math.random() * 6) + 1; const d2 = Math.floor(Math.random() * 6) + 1; const total = d1 + d2; const isDouble = d1 === d2; setTimeout(() => { setDiceValues([d1, d2]); setIsRolling(false); addLog(`${player.name} melempar dadu: ${d1}+${d2}=${total}${isDouble ? ' (DOUBLE!)' : ''}`); // Start sequential movement movePlayerSteps(currentTurnIdx, total, isDouble, player.position); }, 900); }; const movePlayerSteps = (playerIndex, stepsLeft, isDouble, currentPos) => { if (stepsLeft <= 0) { setIsAnimating(false); setTimeout(() => { handleTileLanding(currentPos, isDouble); }, 400); return; } setIsAnimating(true); const nextPos = (currentPos + 1) % window.BOARD_TILES.length; const passedStart = (currentPos === window.BOARD_TILES.length - 1 && nextPos === 0); setPlayers(prev => { const newPlayers = [...prev]; const p = newPlayers[playerIndex]; newPlayers[playerIndex] = { ...p, position: nextPos, laps: p.laps + (passedStart ? 1 : 0), score: p.score + (passedStart ? 5 : 0), extraTurn: isDouble && stepsLeft === 1 ? true : p.extraTurn }; return newPlayers; }); if (currentPos === window.BOARD_TILES.length - 1) { addLog(`${players[playerIndex].name} melewati START! +5 bonus poin.`); } setTimeout(() => { movePlayerSteps(playerIndex, stepsLeft - 1, isDouble, nextPos); }, 450); // Movement speed }; // ===== Tile Landing Logic ===== const handleTileLanding = (pos, isDouble) => { const tile = BOARD_TILES[pos]; const player = players[currentTurnIdx]; const t = tile.type; if (t === 'edukasi') { const q = pickFromPool(edukasiPool, setEdukasiPool, ALL_EDUKASI); setCurrentQuestion({ ...q, icon: tile.icon }); setActiveModal('edukasi'); } else if (t === 'fakta') { const q = pickFromPool(faktaPool, setFaktaPool, ALL_FAKTA); setCurrentQuestion({ ...q, icon: tile.icon }); setActiveModal('fakta'); } else if (t === 'tantangan') { const q = pickFromPool(tantanganPool, setTantanganPool, ALL_TANTANGAN); setCurrentQuestion({ ...q, icon: tile.icon }); setActiveModal('tantangan'); } else if (t === 'refleksi') { const q = pickFromPool(refleksiPool, setRefleksiPool, ALL_REFLEKSI); setCurrentQuestion({ ...q, icon: tile.icon }); setActiveModal('refleksi'); } else if (t === 'kesempatan') { const card = pickFromPool(kesempatanPool, setKesempatanPool, ALL_KARTU_KESEMPATAN); setCurrentCard({ ...card, icon: tile.icon }); setActiveModal('kartu-pos'); } else if (t === 'hukuman') { const card = pickFromPool(hukumanPool, setHukumanPool, ALL_KARTU_HUKUMAN); setCurrentCard({ ...card, icon: tile.icon }); setActiveModal('kartu-neg'); } else if (t === 'penjara') { setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, isInJail: true, jailTurns: 1 } : p)); addLog(`${player.name} masuk penjara! Kehilangan 1 giliran.`); setTimeout(() => doAdvanceTurn(), 1200); } else if (t === 'bonus') { const amount = parseInt(tile.price?.replace('+', '') || '15'); setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, score: p.score + amount } : p)); addLog(`${player.name} mendapat BONUS +${amount} poin!`); setTimeout(() => doAdvanceTurn(), 1200); } else if (t === 'finish') { addLog(`${player.name} melewati FINISH!`); checkGameEnd(); if (gameStatus === 'playing') setTimeout(() => doAdvanceTurn(), 800); } else { // start or regular setTimeout(() => doAdvanceTurn(), 600); } }; // ===== Answer Handlers ===== const handleQuizAnswer = (points) => { const player = players[currentTurnIdx]; const category = currentQuestion?.type === 'fakta_hoax' ? 'fakta' : 'edukasi'; setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, score: p.score + points, stats: { ...p.stats, [category]: p.stats[category] + (points > 0 ? 1 : 0) } } : p)); addLog(`${player.name} menjawab soal ${category}: ${points > 0 ? `Benar! +${points}` : 'Salah (0)'} poin.`); setActiveModal(null); setCurrentQuestion(null); // Show material popup after answering setTimeout(() => { setActiveModal('materi'); }, 300); }; const handleMaterialClose = () => { setActiveModal(null); doAdvanceTurn(); }; const handleChallengeSubmit = (text) => { const player = players[currentTurnIdx]; const qType = currentQuestion?.type || 'tantangan'; const submission = { id: Date.now(), playerIdx: currentTurnIdx, player: { ...player }, type: qType, question: currentQuestion?.question || '', answer: text, timestamp: new Date(), }; setGradingQueue(prev => [...prev, submission]); addLog(`${player.name} mengirim jawaban ${qType} (menunggu nilai dari Guru BK).`); setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, stats: { ...p.stats, [qType]: p.stats[qType] + 1 } } : p)); setActiveModal(null); setCurrentQuestion(null); setWaitingForGrade(false); doAdvanceTurn(); }; const handleCardAccept = () => { if (!currentCard) return; const player = players[currentTurnIdx]; const { effectType, effectValue } = currentCard; if (effectType === 'score') { setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, score: Math.max(0, p.score + effectValue) } : p)); addLog(`${player.name}: ${currentCard.effect}`); } else if (effectType === 'move') { setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, position: Math.max(0, (p.position + effectValue + BOARD_TILES.length) % BOARD_TILES.length) } : p)); addLog(`${player.name}: ${currentCard.effect}`); } else if (effectType === 'skip_turn') { setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, skipTurn: true } : p)); addLog(`${player.name}: Kehilangan 1 giliran!`); } else if (effectType === 'extra_turn') { setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, extraTurn: true } : p)); addLog(`${player.name}: Dapat giliran ekstra!`); } const category = currentCard.type === 'hukuman' ? 'hukuman' : 'kesempatan'; setPlayers(prev => prev.map((p, i) => i === currentTurnIdx ? { ...p, stats: { ...p.stats, [category]: p.stats[category] + 1 } } : p)); setActiveModal(null); setCurrentCard(null); doAdvanceTurn(); }; // ===== Grading (Guru) ===== const handleGrade = (submissionId, points) => { const sub = gradingQueue.find(s => s.id === submissionId); if (!sub) return; const newPlayers = players.map((p, i) => i === sub.playerIdx ? { ...p, score: p.score + points } : p); const newQueue = gradingQueue.filter(s => s.id !== submissionId); setPlayers(newPlayers); setGradingQueue(newQueue); addLog(`Guru memberi nilai ${points} poin untuk jawaban ${sub.player.name}.`); // Immediate sync to server broadcastUpdate(newPlayers, newQueue); }; // ===== Advance Turn ===== const doAdvanceTurn = () => { setPlayers(prev => { const current = prev[currentTurnIdx]; if (current && current.extraTurn) { // Extra turn — stay on same player addLog(`${current.name} mendapat giliran ekstra! (Double)`); return prev.map((p, i) => i === currentTurnIdx ? { ...p, extraTurn: false } : p); } return prev; }); // Need to check extraTurn from current state setTimeout(() => { setPlayers(prev => { const current = prev[currentTurnIdx]; if (current && !current.extraTurn) { const nextIdx = (currentTurnIdx + 1) % 4; setCurrentTurnIdx(nextIdx); addLog(`Giliran ${prev[nextIdx].name}.`); } return prev; }); }, 100); }; // ===== Check Game End ===== const checkGameEnd = () => { const allLaps = players.every(p => p.laps >= maxRounds); if (allLaps) { setGameStatus('finished'); setScreen('hasil'); addLog('Permainan selesai!'); if (currentRoom) window.RoomAPI.finishGame(currentRoom.code); } }; // ===== End Game Manually ===== const handleEndGame = async () => { if (confirm('Apakah Anda yakin ingin mengakhiri sesi ini?')) { try { setGameStatus('finished'); setScreen('hasil'); addLog('Permainan diakhiri oleh Guru BK.'); if (currentRoom) { const res = await window.RoomAPI.finishGame(currentRoom.code.toUpperCase()); if (!res.success) throw new Error(res.message); } } catch (err) { alert('Gagal mengakhiri sesi di server: ' + err.message); } } }; // ===== Manual Points (Guru) ===== const handleManualPoints = (playerIdx, amount) => { const newPlayers = players.map((p, i) => i === playerIdx ? { ...p, score: Math.max(0, p.score + amount) } : p); setPlayers(newPlayers); addLog(`Guru mengubah poin ${players[playerIdx].name}: ${amount > 0 ? '+' : ''}${amount}`); // Immediately broadcast to server so students see the change broadcastUpdate(newPlayers); }; // ===== Render ===== if (renderError) { return (
Terjadi kesalahan saat memuat tampilan. Silakan reset aplikasi untuk mencoba lagi.
Terjadi kesalahan. Silakan muat ulang halaman.