import { useAuth, useBroadcast, useCollection, usePresence, playSound } from '@deplixo/sdk'; /* @component-map * App — Main container, game state management [app.jsx] * GameHeader — Timer, hints counter, stage indicator [components/GameHeader.jsx] * WelcomeScreen — Intro screen with start button [components/WelcomeScreen.jsx] * PuzzleStage — Stage router for all puzzles [components/PuzzleStage.jsx] * Stage1Cipher — Caesar cipher puzzle [components/stages/Stage1Cipher.jsx] * Stage2Wires — Wire connection puzzle [components/stages/Stage2Wires.jsx] * Stage3Riddles — Riddle sequence puzzle [components/stages/Stage3Riddles.jsx] * Stage4Memory — Symbol memory puzzle [components/stages/Stage4Memory.jsx] * Stage5Final — Final vault code puzzle [components/stages/Stage5Final.jsx] * VictoryScreen — Escape success screen [components/VictoryScreen.jsx] * GameOverScreen — Time ran out screen [components/GameOverScreen.jsx] * HintModal — Hint display modal [components/HintModal.jsx] * @end-component-map */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { GameHeader } from './components/GameHeader.jsx'; import { WelcomeScreen } from './components/WelcomeScreen.jsx'; import { PuzzleStage } from './components/PuzzleStage.jsx'; import { VictoryScreen } from './components/VictoryScreen.jsx'; import { GameOverScreen } from './components/GameOverScreen.jsx'; import { HintModal } from './components/HintModal.jsx'; function App() { const [gameState, setGameState] = useState('welcome'); const [currentStage, setCurrentStage] = useState(1); const [timeLeft, setTimeLeft] = useState(3600); const [hintsUsed, setHintsUsed] = useState(0); const [showHint, setShowHint] = useState(false); const [startTime, setStartTime] = useState(null); const [completedStages, setCompletedStages] = useState([]); const [roomInfo, setRoomInfo] = useState(null); const [roomCode, setRoomCode] = useState(''); const [roomName, setRoomName] = useState(''); const [joinCode, setJoinCode] = useState(''); const [roomLoading, setRoomLoading] = useState(false); const [roomError, setRoomError] = useState(''); const [timerWarningPlayed, setTimerWarningPlayed] = useState(false); const [leaderboardError, setLeaderboardError] = useState(''); const { user, loading: authLoading } = useAuth(); const { items: rooms, loading: roomsLoading, add: addRoom, update: updateRoom } = useCollection('escape_rooms'); const { items: leaderboardEntries, loading: leaderboardLoading, add: addLeaderboardEntry } = useCollection('escape_leaderboard'); const { users: presenceUsers, update: updatePresence } = usePresence({ userId: user?.id || null, name: user?.name || 'Guest', roomCode: roomCode || null, roomName: roomInfo?.name || roomName || '' }); const normalizeCode = useCallback((value) => (value || '').trim().toUpperCase(), []); const buildRoomCode = useCallback(() => { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code = ''; for (let i = 0; i < 6; i += 1) { code += chars[Math.floor(Math.random() * chars.length)]; } return code; }, []); const findRoomByCode = useCallback((code) => { const target = normalizeCode(code); return rooms.find((room) => normalizeCode(room.code) === target) || null; }, [rooms, normalizeCode]); const syncPresence = useCallback((nextRoomCode, nextRoomName) => { updatePresence({ userId: user?.id || null, name: user?.name || 'Guest', roomCode: nextRoomCode || null, roomName: nextRoomName || '' }); }, [updatePresence, user]); const syncLocalFromRoom = useCallback((room) => { if (!room) return; setRoomInfo(room); setRoomCode(room.code || ''); setCurrentStage(room.currentStage || 1); setTimeLeft(typeof room.timeLeft === 'number' ? room.timeLeft : 3600); setHintsUsed(typeof room.hintsUsed === 'number' ? room.hintsUsed : 0); setCompletedStages(Array.isArray(room.completedStages) ? room.completedStages : []); setStartTime(room.startedAt || Date.now()); setGameState(room.gameState || 'playing'); setTimerWarningPlayed((room.timeLeft || 3600) <= 30); syncPresence(room.code || '', room.name || ''); }, [syncPresence]); useEffect(() => { if (!roomCode) return; const latest = findRoomByCode(roomCode); if (latest) syncLocalFromRoom(latest); }, [rooms, roomCode, findRoomByCode, syncLocalFromRoom]); useEffect(() => { if (gameState !== 'playing') return; const interval = setInterval(() => { setTimeLeft((prev) => { if (prev <= 1) { clearInterval(interval); setGameState('gameover'); if (roomInfo?.id) { updateRoom(roomInfo.id, { gameState: 'gameover', timeLeft: 0 }); } return 0; } const next = prev - 1; if (roomInfo?.id) { updateRoom(roomInfo.id, { timeLeft: next }); } return next; }); }, 1000); return () => clearInterval(interval); }, [gameState, roomInfo, updateRoom]); useEffect(() => { if (gameState !== 'playing') return; if (timeLeft > 30) { setTimerWarningPlayed(false); return; } if (timeLeft > 0 && !timerWarningPlayed) { playSound('@beep'); setTimerWarningPlayed(true); } }, [gameState, timeLeft, timerWarningPlayed]); const persistRoomState = useCallback((nextState) => { if (!roomInfo?.id) return; updateRoom(roomInfo.id, nextState); }, [roomInfo, updateRoom]); const formatTime = useCallback((seconds) => { const safe = Math.max(0, Math.floor(seconds || 0)); const mins = Math.floor(safe / 60); const secs = safe % 60; return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; }, []); const recordLeaderboardEntry = useCallback(async ({ finalTimeLeft, finalHintsUsed }) => { const elapsedSeconds = Math.max(0, 3600 - (finalTimeLeft ?? timeLeft)); const entry = { roomCode: roomCode || roomInfo?.code || '', roomName: roomInfo?.name || roomName || '', userId: user?.id || null, userName: user?.name || 'Player', elapsedSeconds, timeLeft: Math.max(0, finalTimeLeft ?? timeLeft), hintsUsed: typeof finalHintsUsed === 'number' ? finalHintsUsed : hintsUsed, completedAt: Date.now(), createdAt: Date.now() }; try { await addLeaderboardEntry(entry); setLeaderboardError(''); } catch (err) { setLeaderboardError('Could not save your leaderboard result right now.'); } }, [addLeaderboardEntry, hintsUsed, roomCode, roomInfo, roomName, timeLeft, user]); const startGame = useCallback(async () => { setRoomLoading(true); setRoomError(''); try { const code = buildRoomCode(); const created = await addRoom({ code, name: roomName.trim() || `Team Room ${code}`, hostId: user?.id || null, hostName: user?.name || 'Host', currentStage: 1, timeLeft: 3600, hintsUsed: 0, startedAt: Date.now(), completedStages: [], gameState: 'playing' }); setRoomInfo(created); setRoomCode(code); setGameState('playing'); setCurrentStage(1); setTimeLeft(3600); setHintsUsed(0); setStartTime(Date.now()); setCompletedStages([]); setTimerWarningPlayed(false); playSound('@success'); syncPresence(code, created?.name || roomName.trim() || `Team Room ${code}`); } catch (err) { setRoomError('Could not create a room. Please try again.'); } finally { setRoomLoading(false); } }, [addRoom, buildRoomCode, roomName, syncPresence, user]); const joinGame = useCallback(async () => { setRoomLoading(true); setRoomError(''); try { const found = findRoomByCode(joinCode); if (!found) { setRoomError('Room not found. Check the code and try again.'); return; } setRoomInfo(found); setRoomCode(found.code || normalizeCode(joinCode)); syncLocalFromRoom(found); syncPresence(found.code || normalizeCode(joinCode), found.name || ''); playSound('@ding'); } finally { setRoomLoading(false); } }, [findRoomByCode, joinCode, normalizeCode, syncLocalFromRoom, syncPresence]); const advanceStage = useCallback(async () => { const nextCompleted = [...completedStages, currentStage]; setCompletedStages(nextCompleted); playSound('@success'); if (roomInfo?.id) { await updateRoom(roomInfo.id, { completedStages: nextCompleted, currentStage: currentStage >= 5 ? 5 : currentStage + 1, gameState: currentStage >= 5 ? 'victory' : 'playing' }); } if (currentStage >= 5) { setGameState('victory'); } else { setCurrentStage((prev) => prev + 1); setRoomInfo((prev) => prev ? { ...prev, currentStage: currentStage + 1 } : prev); } }, [completedStages, currentStage, roomInfo, updateRoom]); const useHint = useCallback(() => { setHintsUsed(prev => prev + 1); setShowHint(true); playSound('@ping'); if (roomInfo?.id) { updateRoom(roomInfo.id, { hintsUsed: hintsUsed + 1 }); } }, [hintsUsed, roomInfo, updateRoom]); const elapsed = startTime ? Math.floor((Date.now() - startTime) / 1000) : 0; const handleRoomSolved = useCallback(async () => { if (!roomInfo?.id) return; const nextCompleted = Array.from(new Set([...(completedStages || []), currentStage])); const nextStage = currentStage >= 5 ? 5 : currentStage + 1; const nextState = currentStage >= 5 ? 'victory' : 'playing'; await updateRoom(roomInfo.id, { completedStages: nextCompleted, currentStage: nextStage, gameState: nextState, timeLeft, hintsUsed }); setCompletedStages(nextCompleted); setCurrentStage(nextStage); setGameState(nextState); playSound('@success'); }, [completedStages, currentStage, hintsUsed, roomInfo, timeLeft, updateRoom]); const handleVictory = useCallback(async () => { const finalElapsed = startTime ? Math.max(0, Math.floor((Date.now() - startTime) / 1000)) : Math.max(0, 3600 - timeLeft); const finalTimeLeft = Math.max(0, timeLeft); if (roomInfo?.id) { await updateRoom(roomInfo.id, { gameState: 'victory', timeLeft: finalTimeLeft, hintsUsed, completedStages: Array.from(new Set([...(completedStages || []), 1, 2, 3, 4, 5])).slice(0, 5) }); } await recordLeaderboardEntry({ finalTimeLeft, finalHintsUsed: hintsUsed, finalElapsed }); }, [completedStages, hintsUsed, recordLeaderboardEntry, roomInfo, startTime, timeLeft, updateRoom]); useBroadcast('escape-room-solved', (data) => { if (!data || normalizeCode(data.roomCode) !== normalizeCode(roomCode)) return; if (typeof data.currentStage === 'number' && data.currentStage >= currentStage) { setCurrentStage(data.nextStage || data.currentStage + 1); setCompletedStages((prev) => Array.from(new Set([...(prev || []), data.currentStage]))); if (data.gameState) setGameState(data.gameState); playSound('@success'); } if (typeof data.timeLeft === 'number') setTimeLeft(data.timeLeft); if (typeof data.hintsUsed === 'number') setHintsUsed(data.hintsUsed); }); const handleSolveBroadcast = useCallback((payload) => { if (!payload || normalizeCode(payload.roomCode) !== normalizeCode(roomCode)) return; setCompletedStages((prev) => Array.from(new Set([...(prev || []), payload.stage]))); setCurrentStage(payload.nextStage); setGameState(payload.gameState || 'playing'); setTimeLeft(typeof payload.timeLeft === 'number' ? payload.timeLeft : timeLeft); setHintsUsed(typeof payload.hintsUsed === 'number' ? payload.hintsUsed : hintsUsed); playSound('@success'); }, [hintsUsed, normalizeCode, roomCode, timeLeft]); const roomPresence = useMemo(() => { const targetRoomCode = normalizeCode(roomCode); const inRoom = (presenceUsers || []).filter((p) => normalizeCode(p?.roomCode) === targetRoomCode); const unique = []; const seen = new Set(); inRoom.forEach((p) => { const key = p?.userId || p?.id || p?.name || JSON.stringify(p); if (seen.has(key)) return; seen.add(key); unique.push(p); }); return unique; }, [normalizeCode, presenceUsers, roomCode]); const leaderboardTopResults = useMemo(() => { return [...(leaderboardEntries || [])] .filter((entry) => typeof entry?.elapsedSeconds === 'number') .sort((a, b) => a.elapsedSeconds - b.elapsedSeconds) .slice(0, 5); }, [leaderboardEntries]); const leaderboardDisplay = useMemo(() => leaderboardTopResults.map((entry, index) => ({ ...entry, rank: index + 1, timeLabel: formatTime(entry.elapsedSeconds) })), [formatTime, leaderboardTopResults]); return (